diff --git a/nodyx-core/src/migrations/131_octoguard_hold.sql b/nodyx-core/src/migrations/131_octoguard_hold.sql new file mode 100644 index 00000000..6e74fc6a --- /dev/null +++ b/nodyx-core/src/migrations/131_octoguard_hold.sql @@ -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); diff --git a/nodyx-core/src/routes/admin.ts b/nodyx-core/src/routes/admin.ts index f7dc6ad4..051cce02 100644 --- a/nodyx-core/src/routes/admin.ts +++ b/nodyx-core/src/routes/admin.ts @@ -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 ──────────────────────────────────────────────────────── diff --git a/nodyx-core/src/routes/forums.ts b/nodyx-core/src/routes/forums.ts index 9b338493..28281711 100644 --- a/nodyx-core/src/routes/forums.ts +++ b/nodyx-core/src/routes/forums.ts @@ -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, @@ -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, @@ -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, @@ -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 }) diff --git a/nodyx-core/src/routes/octoguard.ts b/nodyx-core/src/routes/octoguard.ts index 02207a5b..77e5bfd4 100644 --- a/nodyx-core/src/routes/octoguard.ts +++ b/nodyx-core/src/routes/octoguard.ts @@ -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' @@ -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(), @@ -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') @@ -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 = { + 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) => { @@ -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) => { diff --git a/nodyx-core/src/routes/wiki.ts b/nodyx-core/src/routes/wiki.ts index b0339c34..91c70a2c 100644 --- a/nodyx-core/src/routes/wiki.ts +++ b/nodyx-core/src/routes/wiki.ts @@ -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' @@ -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) @@ -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'] diff --git a/nodyx-core/src/services/octoguard/held.ts b/nodyx-core/src/services/octoguard/held.ts new file mode 100644 index 00000000..80968cbe --- /dev/null +++ b/nodyx-core/src/services/octoguard/held.ts @@ -0,0 +1,346 @@ +/** + * OctoGuard 2, cran « Retenir » (CDC E4h, lot 2, D17). + * + * Un contenu retenu n'est jamais écrit dans channel_messages / posts / + * threads : il attend dans octoguard_held. Personne ne le voit, sauf son + * auteur (« en attente de validation ») et l'équipe (file « À relire »). + * À l'approbation, il est publié pour de vrai, par les mêmes modèles que la + * publication normale. Au refus, ou après 72 h sans décision, il est jeté et + * l'auteur est prévenu. + * + * Toutes les décisions passent par un UPDATE … WHERE status = 'pending' + * RETURNING : deux membres de l'équipe qui cliquent en même temps ne publient + * jamais deux fois le même message. + */ + +import { randomUUID } from 'crypto' +import { db, redis } from '../../config/database' +import * as ChannelModel from '../../models/channel' +import * as PostModel from '../../models/post' +import * as ThreadModel from '../../models/thread' +import * as TagModel from '../../models/tag' +import * as NotificationModel from '../../models/notification' +import { awardPoints, REPUTATION } from '../../models/reputation' +import { io } from '../../socket/io' +import { logOctoGuardAction } from './logger' + +/** D17 : un contenu retenu sans décision est refusé au bout de 72 h. */ +export const HELD_EXPIRY_HOURS = 72 +/** Au-delà, un auteur ne peut plus rien mettre en attente : sa file est pleine + * et l'envoi est refusé. Empêche un compte de noyer l'équipe sous la relecture. */ +export const HELD_MAX_PENDING_PER_AUTHOR = 5 +/** Le texte d'un contenu refusé ou expiré n'est pas gardé : effacé 30 jours après la décision. */ +export const HELD_RETENTION_DAYS = 30 + +export type HeldKind = 'chat' | 'post' | 'thread' | 'post_edit' + +export interface HeldPayload { + content: string + title?: string + reply_to_id?: string | null + tag_ids?: string[] +} + +export interface HoldInput { + kind: HeldKind + authorId: string + channelId?: string | null + threadId?: string | null + postId?: string | null + categoryId?: string | null + payload: HeldPayload + rule: { rule_id: string; rule_name: string; excerpt: string } +} + +export interface HeldRow { + id: string + kind: HeldKind + author_id: string + channel_id: string | null + thread_id: string | null + post_id: string | null + category_id: string | null + payload: HeldPayload + rule_id: string | null + rule_name: string + excerpt: string + status: 'pending' | 'approved' | 'refused' | 'expired' + decided_by: string | null + decided_at: string | null + published_id: string | null + created_at: string +} + +/** Ce que l'auteur voit de son propre contenu en attente (jamais le nom de la règle, D20). */ +export function forAuthor(r: HeldRow) { + return { + id: r.id, kind: r.kind, channel_id: r.channel_id, thread_id: r.thread_id, post_id: r.post_id, + category_id: r.category_id, content: r.payload.content, title: r.payload.title ?? null, + reply_to_id: r.payload.reply_to_id ?? null, status: r.status, created_at: r.created_at, + } +} + +export type HoldResult = { ok: true; row: HeldRow } | { ok: false; code: 'HELD_LIMIT' | 'HOLD_FAILED' } + +/** + * Met un contenu de côté. Ne lève jamais. L'appelant ne publie JAMAIS sur un + * échec (ok: false) : un contenu qu'une règle voulait retenir n'est pas publié + * parce que la base a eu un hoquet (on refuse l'envoi, l'auteur réessaiera). + */ +export async function holdContent(input: HoldInput): Promise { + let row: HeldRow | null = null + try { + // Insertion conditionnelle : pas plus de N contenus en attente par auteur + const { rows } = await db.query( + `INSERT INTO octoguard_held (kind, author_id, channel_id, thread_id, post_id, category_id, payload, rule_id, rule_name, excerpt) + SELECT $1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, $10 + WHERE (SELECT COUNT(*) FROM octoguard_held WHERE author_id = $2 AND status = 'pending') < $11 + RETURNING *`, + [input.kind, input.authorId, input.channelId ?? null, input.threadId ?? null, input.postId ?? null, + input.categoryId ?? null, JSON.stringify(input.payload), input.rule.rule_id, input.rule.rule_name, input.rule.excerpt, + HELD_MAX_PENDING_PER_AUTHOR] + ) + row = rows[0] ?? null + } catch (err) { + console.warn('[octoguard:held] hold failed, refusing the content:', err) + return { ok: false, code: 'HOLD_FAILED' } + } + if (!row) return { ok: false, code: 'HELD_LIMIT' } + { + logOctoGuardAction({ + action: 'octoguard.message_held', + target_type: 'user', + target_id: input.authorId, + target_label: input.rule.excerpt, + metadata: { event_id: randomUUID(), rule_id: input.rule.rule_id, rule_name: input.rule.rule_name, held_id: row.id, kind: input.kind, actor: 'octoguard:auto', would_be: false }, + }) + } + return { ok: true, row } +} + +/** Corps de refus quand la mise en attente n'a pas pu se faire (jamais de publication). */ +export function holdRefusal(code: 'HELD_LIMIT' | 'HOLD_FAILED') { + return code === 'HELD_LIMIT' + ? { error: 'Too many messages awaiting review', code: 'OCTOGUARD_HELD_LIMIT', i18n_key: 'octoguard.held_limit' } + : { error: 'Could not hold the message for review', code: 'OCTOGUARD_BLOCKED', i18n_key: 'octoguard.blocked' } +} + +export async function listPending(limit = 100): Promise> { + const { rows } = await db.query( + `SELECT h.*, u.username AS author_username, u.created_at AS author_created_at, ch.name AS channel_name, t.title AS thread_title + FROM octoguard_held h + JOIN users u ON u.id = h.author_id + LEFT JOIN channels ch ON ch.id = h.channel_id + LEFT JOIN threads t ON t.id = h.thread_id + WHERE h.status = 'pending' + ORDER BY h.created_at ASC + LIMIT $1`, + [limit] + ) + return rows as any +} + +export async function countPending(): Promise { + const { rows } = await db.query<{ n: number }>(`SELECT COUNT(*)::int AS n FROM octoguard_held WHERE status = 'pending'`) + return rows[0]?.n ?? 0 +} + +/** Contenus en attente d'un auteur, pour un salon ou un fil donné. */ +export async function listMine(authorId: string, where: { channelId?: string; threadId?: string }): Promise { + const { rows } = await db.query( + `SELECT * FROM octoguard_held + WHERE author_id = $1 AND status = 'pending' + AND ($2::uuid IS NULL OR channel_id = $2::uuid) + AND ($3::uuid IS NULL OR thread_id = $3::uuid) + ORDER BY created_at ASC + LIMIT 50`, + [authorId, where.channelId ?? null, where.threadId ?? null] + ) + return rows +} + +/** Prend la décision de façon atomique. null = déjà décidé (ou inexistant). */ +async function claim(id: string, status: 'approved' | 'refused' | 'expired', actorId: string | null): Promise { + const { rows } = await db.query( + `UPDATE octoguard_held SET status = $2, decided_by = $3, decided_at = NOW() + WHERE id = $1 AND status = 'pending' + RETURNING *`, + [id, status, actorId] + ) + return rows[0] ?? null +} + +export type PublishBlock = 'AUTHOR_BANNED' | 'THREAD_LOCKED' | 'PUBLISH_FAILED' + +/** + * Ce qui a pu changer depuis la mise en attente : l'auteur a été banni, le fil + * a été verrouillé. Approuver ne doit pas contourner ces décisions. + */ +async function blockedNow(h: HeldRow): Promise { + const { rows } = await db.query<{ banned: boolean; locked: boolean }>( + `SELECT EXISTS (SELECT 1 FROM community_bans cb WHERE cb.user_id = $1 + AND (cb.expires_at IS NULL OR cb.expires_at > NOW())) AS banned, + COALESCE((SELECT t.is_locked FROM threads t WHERE t.id = $2), false) AS locked`, + [h.author_id, h.thread_id] + ) + if (rows[0]?.banned) return 'AUTHOR_BANNED' + if (rows[0]?.locked && (h.kind === 'post' || h.kind === 'post_edit')) return 'THREAD_LOCKED' + return null +} + +/** Effet secondaire d'une publication (points, notification, étiquettes, cache) : + * son échec ne doit JAMAIS faire croire que rien n'a été publié. */ +async function sideEffect(label: string, fn: () => Promise): Promise { + try { await fn() } catch (err) { console.warn(`[octoguard:held] ${label} failed after publishing (kept):`, err) } +} + +/** + * Publie pour de vrai ce qui était retenu. Renvoie l'identifiant publié, ou + * null si RIEN n'a été écrit. Règle : dès que le message (ou le fil) existe, la + * publication est acquise ; tout ce qui suit est au mieux. Avant (relu le + * 07/11), une erreur sur les points de réputation après la création renvoyait + * « rien publié », le contenu repassait en attente et une seconde approbation + * le publiait DEUX fois. + */ +async function publish(h: HeldRow): Promise { + const p = h.payload + if (h.kind === 'chat' && h.channel_id) { + const message = await ChannelModel.addMessage({ + channel_id: h.channel_id, author_id: h.author_id, content: p.content, reply_to_id: p.reply_to_id ?? null, + }) + io?.to(`channel:${h.channel_id}`).emit('chat:message', message) + return message.id + } + if (h.kind === 'post' && h.thread_id) { + const thread = await ThreadModel.findById(h.thread_id) + if (!thread) return null + const post = await PostModel.create({ thread_id: h.thread_id, author_id: h.author_id, content: p.content }) + await sideEffect('cache', async () => { await redis.incr('threads:cache:ver') }) + await sideEffect('reputation', () => awardPoints(h.author_id, REPUTATION.REPLY)) + if (thread.author_id !== h.author_id) { + await sideEffect('notification', () => NotificationModel.create({ user_id: thread.author_id, type: 'thread_reply', actor_id: h.author_id, thread_id: thread.id, post_id: post.id })) + } + return post.id + } + if (h.kind === 'thread' && h.category_id && p.title) { + const thread = await ThreadModel.create({ category_id: h.category_id, author_id: h.author_id, title: p.title }) + try { + await PostModel.create({ thread_id: thread.id, author_id: h.author_id, content: p.content }) + } catch (err) { + // Un fil sans son premier message ne doit pas rester : on le retire, rien n'est publié + await ThreadModel.remove(thread.id).catch(() => {}) + throw err + } + if (p.tag_ids?.length) await sideEffect('tags', () => TagModel.setThreadTags(thread.id, p.tag_ids!)) + await sideEffect('cache', async () => { await redis.incr('threads:cache:ver') }) + await sideEffect('reputation', () => awardPoints(h.author_id, REPUTATION.THREAD)) + return thread.id + } + if (h.kind === 'post_edit' && h.post_id) { + const post = await PostModel.updateContent(h.post_id, p.content) + await sideEffect('cache', async () => { await redis.incr('threads:cache:ver') }) + return post?.id ?? null + } + return null +} + +function tellAuthor(h: HeldRow, outcome: 'approved' | 'refused' | 'expired', publishedId: string | null) { + io?.to(`user:${h.author_id}`).emit('octoguard:held_decided', { + id: h.id, kind: h.kind, outcome, channel_id: h.channel_id, thread_id: h.thread_id, published_id: publishedId, + }) + void NotificationModel.create({ + user_id: h.author_id, + type: outcome === 'approved' ? 'held_approved' : 'held_refused', + thread_id: h.kind === 'thread' && outcome === 'approved' ? publishedId : h.thread_id, + meta: { kind: h.kind, excerpt: h.payload.content.slice(0, 2000).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 120), expired: outcome === 'expired' }, + }).then(async () => { + const count = await NotificationModel.getUnreadCount(h.author_id).catch(() => 0) + io?.to(`user:${h.author_id}`).emit('notification:new', { unreadCount: count }) + }).catch(() => {}) +} + +export type DecideResult = + | { ok: true; held: HeldRow; published_id: string | null } + | { ok: false; code: 'ALREADY_DECIDED' | PublishBlock } + +export async function approveHeld(id: string, actor: { id: string; username: string }): Promise { + const h = await claim(id, 'approved', actor.id) + if (!h) return { ok: false, code: 'ALREADY_DECIDED' } + let publishedId: string | null = null + let block: PublishBlock | null = null + try { + block = await blockedNow(h) + if (!block) publishedId = await publish(h) + } catch (err) { + console.warn('[octoguard:held] publish failed:', err) + } + if (!publishedId) { + // Rien n'a été publié (auteur banni, fil verrouillé, salon supprimé) : on remet en attente + await db.query(`UPDATE octoguard_held SET status = 'pending', decided_by = NULL, decided_at = NULL WHERE id = $1`, [id]) + return { ok: false, code: block ?? 'PUBLISH_FAILED' } + } + await db.query(`UPDATE octoguard_held SET published_id = $2 WHERE id = $1`, [id, publishedId]) + logOctoGuardAction({ + action: 'octoguard.held_approved', actor_id: actor.id, + target_type: 'user', target_id: h.author_id, target_label: h.excerpt, + metadata: { event_id: randomUUID(), actor: actor.username, held_id: h.id, kind: h.kind, rule_name: h.rule_name, published_id: publishedId }, + }) + tellAuthor(h, 'approved', publishedId) + return { ok: true, held: { ...h, published_id: publishedId }, published_id: publishedId } +} + +export async function refuseHeld(id: string, actor: { id: string; username: string }): Promise { + const h = await claim(id, 'refused', actor.id) + if (!h) return { ok: false, code: 'ALREADY_DECIDED' } + logOctoGuardAction({ + action: 'octoguard.held_refused', actor_id: actor.id, + target_type: 'user', target_id: h.author_id, target_label: h.excerpt, + metadata: { event_id: randomUUID(), actor: actor.username, held_id: h.id, kind: h.kind, rule_name: h.rule_name }, + }) + tellAuthor(h, 'refused', null) + return { ok: true, held: h, published_id: null } +} + +/** Efface les contenus décidés depuis plus de 30 jours (le journal garde l'extrait). */ +export async function purgeDecidedHeld(): Promise { + const { rows } = await db.query( + `DELETE FROM octoguard_held + WHERE status <> 'pending' AND decided_at < NOW() - ($1 || ' days')::interval + RETURNING id`, + [String(HELD_RETENTION_DAYS)] + ) + return rows.length +} + +/** D17 : appelé par le planificateur. Refuse ce qui attend depuis plus de 72 h. */ +export async function expireHeld(): Promise { + const { rows } = await db.query( + `UPDATE octoguard_held SET status = 'expired', decided_at = NOW() + WHERE status = 'pending' AND created_at < NOW() - ($1 || ' hours')::interval + RETURNING *`, + [String(HELD_EXPIRY_HOURS)] + ) + for (const h of rows) { + logOctoGuardAction({ + action: 'octoguard.held_expired', target_type: 'user', target_id: h.author_id, target_label: h.excerpt, + metadata: { event_id: randomUUID(), actor: 'octoguard:auto', held_id: h.id, kind: h.kind, rule_name: h.rule_name }, + }) + tellAuthor(h, 'expired', null) + } + return rows.length +} + +let _timer: NodeJS.Timeout | null = null +/** Toutes les 10 minutes : refuse ce qui attend depuis plus de 72 h (D17). */ +export function startHeldExpiryWorker(): void { + if (_timer) return + _timer = setInterval(() => { + expireHeld() + .then(n => { if (n > 0) console.log(`[octoguard:held] ${n} held item(s) expired after ${HELD_EXPIRY_HOURS}h`) }) + .catch(err => console.warn('[octoguard:held] expiry failed:', err)) + purgeDecidedHeld() + .then(n => { if (n > 0) console.log(`[octoguard:held] ${n} decided item(s) erased after ${HELD_RETENTION_DAYS} days`) }) + .catch(err => console.warn('[octoguard:held] purge failed:', err)) + }, 10 * 60_000) + _timer.unref?.() +} diff --git a/nodyx-core/src/services/octoguard/index.ts b/nodyx-core/src/services/octoguard/index.ts index cb3721c3..dd4cad11 100644 --- a/nodyx-core/src/services/octoguard/index.ts +++ b/nodyx-core/src/services/octoguard/index.ts @@ -40,6 +40,7 @@ import { reloadRules, getRules } from './cache' import { hasRE2 } from './matchers' import { startMutePurgeWorker } from './mutes' import { startBanExpiryWorker } from './bans' +import { startHeldExpiryWorker } from './held' import { ensureBaseProfile } from './profile' import { ensureOctoGuardBotUser } from './welcome' import { migrateEnvPatternsToDB } from './envMigration' @@ -65,6 +66,8 @@ export async function initOctoGuard(): Promise { // non (depuis E3, une sourdine s'applique même OctoGuard éteint). startMutePurgeWorker() startBanExpiryWorker() + // Contenus retenus sans décision : refusés au bout de 72 h (D17), toujours + startHeldExpiryWorker() // État de la protection contre les expressions piégées, affiché dans tous les cas console.log(hasRE2() diff --git a/nodyx-core/src/services/octoguard/moderate.ts b/nodyx-core/src/services/octoguard/moderate.ts index a77b03e4..a4d536eb 100644 --- a/nodyx-core/src/services/octoguard/moderate.ts +++ b/nodyx-core/src/services/octoguard/moderate.ts @@ -9,6 +9,8 @@ * la publication est refusée, avec un code stable et une clé de traduction. * - Une règle au cran « signaler » : la publication passe, et l'appelant * ouvre le dossier une fois le contenu enregistré (fileAutoReport). + * - Une règle au cran « retenir » (forum) : l'appelant n'écrit RIEN, il + * confie le contenu à holdContent() et répond 202 (held.ts). * Ne lève jamais : en cas de panne, on laisse passer (fail-open, comme le chat). */ @@ -19,7 +21,7 @@ import { runPipeline } from './pipeline' import type { PipelineUserCtx, Surface } from './types' export type ModerationVerdict = - | { blocked: false; report?: { rule_name: string; excerpt: string } } + | { blocked: false; report?: { rule_name: string; excerpt: string }; hold?: { rule_id: string; rule_name: string; excerpt: string } } | { blocked: true; reason: string; i18n_key: string } /** Rôle et grade du membre dans la communauté de l'instance (immunités). */ @@ -41,6 +43,84 @@ export async function buildUserCtx(userId: string): Promise { } } +/** + * Le texte qu'OctoGuard examine, à partir du HTML de l'éditeur. Différent de + * l'aperçu des signalements (toPlainText) sur deux points qui comptaient : + * - l'adresse d'un lien est gardée, même cachée derrière un texte (« ici ») : + * avant, les règles de liens ne voyaient que « ici » et laissaient passer ; + * - rien n'est tronqué : l'aperçu s'arrête à 1000 caractères, un spam placé + * après passait. + */ +export function textForModeration(html: string | null | undefined): string { + return stripTags(htmlTextWithLinks(html ?? '') + .replace(/<(br|\/p|\/div|\/li|\/h\d)\s?\/?>/gi, '\n')) + .replace(/ /g, ' ') + .replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'") + .replace(/&/g, '&') + .replace(/[ \t]+/g, ' ') + .replace(/\n{3,}/g, '\n\n') + .trim() +} + +/** Retire les balises en un passage. « < » sans « > » derrière : le reste est du texte. */ +function stripTags(s: string): string { + let out = '' + let i = 0 + while (i < s.length) { + const lt = s.indexOf('<', i) + if (lt === -1) { out += s.slice(i); break } + const gt = s.indexOf('>', lt) + if (gt === -1) { out += s.slice(i); break } + out += s.slice(i, lt) + ' ' + i = gt + 1 + } + return out +} + +/** + * Remplace chaque lien par « texte adresse », en UN passage. Pas d'expression + * régulière sur le document entier : le texte est celui du membre, AVANT + * nettoyage, jusqu'à 500 Ko. Une version à base de regex mettait plus de deux + * minutes sur 40 000 « » jamais fermés, et bloquait tout le cœur + * (mesuré le 07/11, avant fusion). Ici chaque caractère est lu un nombre + * borné de fois : la position de « » est cherchée une seule fois par + * avancée, et une absence est retenue pour la suite. + */ +function htmlTextWithLinks(html: string): string { + const lower = html.toLowerCase() + let out = '' + let i = 0 + let noMoreClose = false + let close = -1 + while (i < html.length) { + const a = lower.indexOf(' » : une ancre (pas « ', a) + if (tagEnd === -1) { out += html.slice(i); break } + const tag = html.slice(a, tagEnd + 1) + const hrefM = tag.length <= 4096 ? /\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(tag) : null + const href = hrefM ? (hrefM[1] ?? hrefM[2] ?? hrefM[3] ?? '') : '' + if (!noMoreClose && close < tagEnd) { + close = lower.indexOf('', tagEnd) + if (close === -1) noMoreClose = true + } + out += html.slice(i, a) + if (noMoreClose) { + // Ancre jamais fermée : on garde l'adresse, le reste est lu comme du texte + out += ` ${href} ` + i = tagEnd + 1 + continue + } + const label = stripTags(html.slice(tagEnd + 1, close)).trim() + out += label && label !== href ? `${label} ${href}` : href + i = close + 4 + } + return out +} + /** Les textes d'une publication (titre, corps, bio…) sont examinés ensemble. */ export async function moderateText(userId: string, surface: Surface, texts: Array): Promise { try { @@ -50,6 +130,14 @@ export async function moderateText(userId: string, surface: Surface, texts: Arra const userCtx = await buildUserCtx(userId) const res = await runPipeline({ content, userCtx, surface }) if (res.blocked) return { blocked: true, reason: res.reason ?? 'octoguard', i18n_key: res.i18n_key ?? 'octoguard.blocked' } + // Retenir : seul le forum sait mettre un contenu de côté (avec le chat, + // qui appelle le moteur directement). Profil, sondage et wiki n'ont pas de + // « brouillon en attente » : la règle y agit comme « signaler » (le contenu + // passe, un dossier s'ouvre), ce que l'écran des règles annonce. + if (res.hold) { + if (surface === 'forum') return { blocked: false, hold: res.hold } + return { blocked: false, report: { rule_name: res.hold.rule_name, excerpt: res.hold.excerpt } } + } return res.report ? { blocked: false, report: { rule_name: res.report.rule_name, excerpt: res.report.excerpt } } : { blocked: false } } catch (err) { console.warn('[octoguard:moderate] failed, letting through:', err) @@ -57,6 +145,11 @@ export async function moderateText(userId: string, surface: Surface, texts: Arra } } +/** Corps de la réponse 202 quand un contenu est retenu pour relecture. */ +export function heldBody(h: { id: string }) { + return { held: true, held_id: h.id, code: 'OCTOGUARD_HELD', i18n_key: 'octoguard.held' } +} + /** Corps de la réponse 422 quand OctoGuard refuse une publication. */ export function blockedBody(v: { reason: string; i18n_key: string }) { return { error: v.reason, code: 'OCTOGUARD_BLOCKED', i18n_key: v.i18n_key } diff --git a/nodyx-core/src/services/octoguard/pipeline.ts b/nodyx-core/src/services/octoguard/pipeline.ts index 797a6975..5dd57333 100644 --- a/nodyx-core/src/services/octoguard/pipeline.ts +++ b/nodyx-core/src/services/octoguard/pipeline.ts @@ -104,6 +104,8 @@ interface ActionOutcome { i18nKey: string /** Cran « signaler » : un dossier à ouvrir dans la file (D13) */ report?: boolean + /** Cran « retenir » : le contenu attend la relecture (lot 2) */ + hold?: boolean } async function applyAction( @@ -148,6 +150,13 @@ async function applyAction( return { blocked: false, reason: 'report_only', i18nKey: 'octoguard.report_only', report: true } } + // Cran « retenir » : rien n'est publié. L'appelant confie le contenu à + // holdContent() (qui journalise « octoguard.message_held ») ; l'auteur le voit + // « en attente de validation », les autres ne le voient pas. + if (action === 'hold') { + return { blocked: false, reason: 'hold', i18nKey: 'octoguard.held', hold: true } + } + // notify_only : log + webhook (webhook Session D), pas de blocage if (action === 'notify_only') { logOctoGuardAction({ @@ -309,6 +318,7 @@ async function runPipelineInner(input: PipelineInput): Promise { i18n_key: outcome.i18nKey, event_id: eventId, ...(outcome.report ? { report: { rule_id: rule.id, rule_name: rule.name, excerpt: match.excerpt } } : {}), + ...(outcome.hold ? { hold: { rule_id: rule.id, rule_name: rule.name, excerpt: match.excerpt } } : {}), } } diff --git a/nodyx-core/src/services/octoguard/profile.ts b/nodyx-core/src/services/octoguard/profile.ts index 1bc35d69..80cd3b0e 100644 --- a/nodyx-core/src/services/octoguard/profile.ts +++ b/nodyx-core/src/services/octoguard/profile.ts @@ -142,7 +142,7 @@ export async function ensureBaseProfile(): Promise<{ seeded: number; updated: nu const SurfaceZ = z.enum(['chat', 'forum', 'profile', 'poll', 'wiki']) const DurationZ = z.object({ value: z.number().int().positive().max(10_000), unit: z.enum(['m', 'h', 'd', 'w', 'M']) }).nullable() -const ActionZ = z.enum(['delete', 'warn', 'mute', 'ban_temp', 'notify_only', 'report_only']) +const ActionZ = z.enum(['delete', 'warn', 'mute', 'ban_temp', 'notify_only', 'report_only', 'hold']) export const ProfileRuleZ = z.object({ name: z.string().min(1).max(100), diff --git a/nodyx-core/src/services/octoguard/sandbox.ts b/nodyx-core/src/services/octoguard/sandbox.ts index 34e02687..05229545 100644 --- a/nodyx-core/src/services/octoguard/sandbox.ts +++ b/nodyx-core/src/services/octoguard/sandbox.ts @@ -17,7 +17,7 @@ import { matchRule } from './matchers' import { conditionsMet } from './pipeline' import type { AutomodRuleRow, Surface } from './types' -export type SandboxOutcome = 'none' | 'observe' | 'report' | 'warn' | 'delete' | 'mute' | 'ban_temp' | 'notify' +export type SandboxOutcome = 'none' | 'observe' | 'report' | 'hold' | 'warn' | 'delete' | 'mute' | 'ban_temp' | 'notify' export interface SandboxRuleResult { id: string; name: string; preset_key: string | null; type: string @@ -35,7 +35,7 @@ export interface SandboxResult { } const ACTION_OUTCOME: Record = { - delete: 'delete', warn: 'warn', mute: 'mute', ban_temp: 'ban_temp', report_only: 'report', notify_only: 'notify', + delete: 'delete', warn: 'warn', mute: 'mute', ban_temp: 'ban_temp', report_only: 'report', notify_only: 'notify', hold: 'hold', } export async function runSandbox(input: { content: string; surface: Surface; accountAgeDays?: number; role?: string }): Promise { @@ -55,7 +55,9 @@ export async function runSandbox(input: { content: string; surface: Surface; acc const condOk = conditionsMet(rule, user) const testable = rule.type !== 'flood' const m = testable ? matchRule(input.content, rule, { userId: `sandbox-${Math.random()}` }) : null - const intended = ACTION_OUTCOME[rule.action] ?? 'none' + // Retenir n'existe qu'au chat et au forum ; ailleurs la règle signale (moderate.ts) + const mapped = ACTION_OUTCOME[rule.action] ?? 'none' + const intended: SandboxOutcome = mapped === 'hold' && input.surface !== 'chat' && input.surface !== 'forum' ? 'report' : mapped const acts = !!m && applies && !immune && condOk const outcome: SandboxOutcome = acts ? (rule.dry_run ? 'observe' : intended) : 'none' if (acts && rule.enabled && !rule.dry_run && first === null) first = rule.id diff --git a/nodyx-core/src/services/octoguard/types.ts b/nodyx-core/src/services/octoguard/types.ts index e2543ed8..d1cef34b 100644 --- a/nodyx-core/src/services/octoguard/types.ts +++ b/nodyx-core/src/services/octoguard/types.ts @@ -25,6 +25,7 @@ export type AutomodAction = | 'ban_temp' | 'notify_only' | 'report_only' + | 'hold' // Retenir : le contenu attend une relecture de l'équipe (lot 2, CDC E4h) export type DurationUnit = 'm' | 'h' | 'd' | 'w' | 'M' @@ -210,6 +211,9 @@ export interface PipelineResult { /** Cran « signaler » : l'appelant crée un dossier dans la file APRÈS avoir * enregistré le contenu (il en connaît alors l'identifiant). D13. */ report?: { rule_id: string; rule_name: string; excerpt: string } + /** Cran « retenir » : l'appelant N'ÉCRIT PAS le contenu, il le confie à + * holdContent() ; il ne sera publié qu'à l'approbation (CDC E4h, lot 2). */ + hold?: { rule_id: string; rule_name: string; excerpt: string } } /** diff --git a/nodyx-core/src/socket/index.ts b/nodyx-core/src/socket/index.ts index ba2f6bad..3f4e43a6 100644 --- a/nodyx-core/src/socket/index.ts +++ b/nodyx-core/src/socket/index.ts @@ -17,6 +17,7 @@ import { resolveServerLocale, pushStrings } from '../i18n/serverStrings' import { checkHtmlContent } from '../services/contentFilter' import { runPipeline, isOctoGuardEnabled, isUserMuted, tryHandleCommand } from '../services/octoguard' import { fileAutoReport } from '../services/octoguard/autoReport' +import { holdContent, forAuthor, holdRefusal } from '../services/octoguard/held' import { getInstanceCommunityId } from '../middleware/adminOnly' import { getAccess, outranks, rankOf } from '../services/permissions' import { isContainedUploadPath } from '../utils/uploadPath' @@ -545,6 +546,22 @@ export function registerSocketIO(server: Server): void { channelId, }) ogReport = og.report + // Cran « retenir » : le message n'est PAS écrit. Seul son auteur le + // voit, « en attente de validation » (CDC E4h, lot 2). + if (og.hold) { + const held = await holdContent({ + kind: 'chat', authorId: userId, channelId, + payload: { content: sanitized, reply_to_id: replyToId ?? null }, rule: og.hold, + }) + if (held.ok) { + io?.to(`user:${userId}`).emit('octoguard:held', forAuthor(held.row)) + } else { + const refusal = holdRefusal(held.code) + socket.emit('chat:blocked', { reason: refusal.code, octoguard: { i18n_key: refusal.i18n_key } }) + } + // Retenu ou refus : dans les deux cas, RIEN n'est publié + return + } if (og.blocked) { socket.emit('chat:blocked', { reason: og.reason ?? 'octoguard', diff --git a/nodyx-core/src/tests/octoguard-v2-held.test.ts b/nodyx-core/src/tests/octoguard-v2-held.test.ts new file mode 100644 index 00000000..9389bbb0 --- /dev/null +++ b/nodyx-core/src/tests/octoguard-v2-held.test.ts @@ -0,0 +1,322 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import jwt from 'jsonwebtoken' +import { buildApp } from './helpers/buildApp' + +// OctoGuard 2, cran « Retenir » (CDC E4h, lot 2, D17) : le moteur ne publie +// rien, le contenu attend dans octoguard_held, n'est publié qu'une fois à +// l'approbation (même sur double clic), jeté au refus ou après 72 h. + +const { st, calls, addMessageMock, postCreateMock, emitMock } = vi.hoisted(() => ({ + st: { held: null as null | Record, claimable: true, role: 'member' as string, full: false, insertFails: false, banned: false, locked: false }, + calls: [] as Array<{ sql: string; params: unknown[] }>, + addMessageMock: vi.fn(), + postCreateMock: vi.fn(), + emitMock: vi.fn(), +})) + +vi.mock('../config/database', () => ({ + db: { query: vi.fn() }, + redis: { get: vi.fn().mockResolvedValue(null), set: vi.fn().mockResolvedValue('OK'), setex: vi.fn().mockResolvedValue('OK'), exists: vi.fn().mockResolvedValue(1), incr: vi.fn().mockResolvedValue(1), expire: vi.fn().mockResolvedValue(1), keyPrefix: 'nodyx:' }, +})) +vi.mock('../services/octoguard/webhook', () => ({ triggerWebhook: vi.fn(), buildWebhookPayload: vi.fn(() => ({})), getWebhookConfig: vi.fn(), setWebhookConfig: vi.fn(), invalidateWebhookConfig: vi.fn() })) +vi.mock('../models/channel', () => ({ addMessage: addMessageMock })) +vi.mock('../models/post', () => ({ create: postCreateMock, updateContent: vi.fn(), getAuthorAndThread: vi.fn(), listByThread: vi.fn() })) +vi.mock('../models/thread', () => ({ findById: vi.fn(), create: vi.fn(), incrementViews: vi.fn(), update: vi.fn(), remove: vi.fn() })) +vi.mock('../models/tag', () => ({ setThreadTags: vi.fn(), getTagsForThread: vi.fn().mockResolvedValue([]) })) +vi.mock('../models/reputation', () => ({ awardPoints: vi.fn(), REPUTATION: { THREAD: 10, REPLY: 2 } })) +vi.mock('../socket/io', () => ({ io: { to: () => ({ emit: emitMock }) } })) + +import { db, redis } from '../config/database' +import * as ThreadModel from '../models/thread' +import { runPipeline } from '../services/octoguard/pipeline' +import { _setRulesForBench, clearRules } from '../services/octoguard/cache' +import { moderateText } from '../services/octoguard/moderate' +import { approveHeld, refuseHeld, expireHeld, holdContent, forAuthor, HELD_EXPIRY_HOURS } from '../services/octoguard/held' +import { octoguardAdminPlugin } from '../routes/octoguard' +import forumRoutes from '../routes/forums' +import type { AutomodRuleRow } from '../services/octoguard/types' + +const AUTHOR = '00000000-0000-4000-8000-0000000000aa' +const MOD = '22222222-2222-4222-8222-222222222222' +const CHAN = '33333333-3333-4333-8333-333333333333' +const THREAD = '550e8400-e29b-41d4-a716-446655440000' +const HELD = '44444444-4444-4444-8444-444444444444' +const token = (u: string) => `Bearer ${jwt.sign({ userId: u, username: 'mod' }, process.env.JWT_SECRET!, { expiresIn: '1h' })}` +const flush = () => new Promise(r => setTimeout(r, 20)) + +function rule(extra: Partial = {}): AutomodRuleRow { + return { + id: '00000000-0000-4000-8000-00000000rule', name: 'Liens', type: 'link_spam', + params: { max_links: 1 }, action: 'hold', action_duration: null, escalation: null, + immunized_role_types: [], immunized_grade_ids: [], dry_run: false, enabled: true, + scopes: ['chat', 'forum', 'profile'], preset_key: null, customized: false, created_at: '', updated_at: '', + ...extra, + } as AutomodRuleRow +} +const SPAM = 'voir https://a.example et https://b.example' + +function heldRow(extra: Record = {}) { + return { + id: HELD, kind: 'chat', author_id: AUTHOR, channel_id: CHAN, thread_id: null, post_id: null, category_id: null, + payload: { content: '

voir https://a.example

', reply_to_id: null }, rule_id: 'r1', rule_name: 'Liens', + excerpt: 'https://a.example', status: 'pending', decided_by: null, decided_at: null, published_id: null, + created_at: '2026-11-04T10:00:00Z', ...extra, + } +} + +beforeEach(() => { + vi.clearAllMocks() + calls.length = 0 + st.held = heldRow(); st.claimable = true; st.role = 'member'; st.full = false; st.insertFails = false; st.banned = false; st.locked = false + process.env.OCTOGUARD_ENABLED = 'true' + clearRules() + vi.mocked(redis.exists).mockImplementation((async (k: string) => (k.startsWith('banned:') ? 0 : 1)) as never) + addMessageMock.mockResolvedValue({ id: 'msg-new', channel_id: CHAN }) + postCreateMock.mockResolvedValue({ id: 'p-new' }) + vi.mocked(ThreadModel.findById).mockResolvedValue({ id: THREAD, author_id: MOD, is_locked: false, category_id: 'cat' } as never) + vi.mocked(db.query).mockImplementation((async (sql: string, params: unknown[] = []) => { + const s = String(sql).replace(/\s+/g, ' ').trim() + calls.push({ sql: s, params }) + if (s.includes('FROM communities')) return { rows: [{ id: 'c1' }], rowCount: 1 } + if (s.includes('community_grades cg ON cg.id = cm.grade_id AND')) { + return { rows: [{ role: params[1] === MOD ? 'admin' : st.role, permissions: null }], rowCount: 1 } + } + if (s.includes('LEFT JOIN community_members cm ON cm.user_id = u.id')) return { rows: [{ role: 'member', grade_id: null, account_age_days: 400 }] } + if (s.startsWith('INSERT INTO octoguard_held')) { + if (st.insertFails) throw new Error('db down') + // La condition « moins de N en attente » est dans la requête : file pleine = aucune ligne + if (st.full) return { rows: [], rowCount: 0 } + return { rows: [heldRow({ kind: params[0], payload: JSON.parse(String(params[6])) })], rowCount: 1 } + } + if (s.startsWith('SELECT EXISTS (SELECT 1 FROM community_bans')) return { rows: [{ banned: st.banned, locked: st.locked }], rowCount: 1 } + if (s.startsWith('DELETE FROM octoguard_held')) return { rows: [{ id: 'old' }], rowCount: 1 } + if (s.startsWith("UPDATE octoguard_held SET status = $2")) { + // La décision ne se prend qu'une fois : le second clic ne trouve plus de ligne « pending » + if (!st.claimable) return { rows: [], rowCount: 0 } + st.claimable = false + return { rows: [st.held], rowCount: 1 } + } + if (s.startsWith("UPDATE octoguard_held SET status = 'expired'")) return { rows: [heldRow()], rowCount: 1 } + if (s.startsWith('SELECT author_id, status FROM octoguard_held')) return { rows: st.held ? [st.held] : [], rowCount: st.held ? 1 : 0 } + if (s.startsWith('INSERT INTO notifications')) return { rows: [{ id: 'n1' }], rowCount: 1 } + return { rows: [], rowCount: 0 } + }) as never) +}) + +describe('le moteur', () => { + it('une règle au cran « retenir » ne bloque pas et ne publie pas : elle demande la mise de côté', async () => { + _setRulesForBench([rule()]) + const res = await runPipeline({ content: SPAM, userCtx: { userId: AUTHOR, role: 'member', gradeIds: [] } }) + expect(res.blocked).toBe(false) + expect(res.hold).toMatchObject({ rule_name: 'Liens' }) + expect(res.report).toBeUndefined() + }) + it('en observation, « retenir » ne retient rien (journal « would_hold » seulement)', async () => { + _setRulesForBench([rule({ dry_run: true })]) + const res = await runPipeline({ content: SPAM, userCtx: { userId: AUTHOR, role: 'member', gradeIds: [] } }) + expect(res.hold).toBeUndefined() + await flush() + expect(calls.some(c => c.sql.startsWith('INSERT INTO admin_audit_log') && c.params.includes('octoguard.would_hold'))).toBe(true) + }) + it('le forum garde « retenir » ; le profil, qui n’a pas d’attente, le transforme en « signaler »', async () => { + _setRulesForBench([rule()]) + expect(await moderateText(AUTHOR, 'forum', [SPAM])).toMatchObject({ blocked: false, hold: { rule_name: 'Liens' } }) + const profile = await moderateText(AUTHOR, 'profile', [SPAM]) + expect(profile).toMatchObject({ blocked: false, report: { rule_name: 'Liens' } }) + expect((profile as { hold?: unknown }).hold).toBeUndefined() + }) +}) + +describe('la mise de côté et les décisions', () => { + const who = { id: MOD, username: 'mod' } + + it('holdContent écrit dans octoguard_held, PAS dans channel_messages', async () => { + const h = await holdContent({ kind: 'chat', authorId: AUTHOR, channelId: CHAN, payload: { content: 'x' }, rule: { rule_id: 'r1', rule_name: 'Liens', excerpt: 'x' } }) + expect(h.ok && h.row.id).toBe(HELD) + expect(calls.some(c => c.sql.includes('channel_messages'))).toBe(false) + expect(addMessageMock).not.toHaveBeenCalled() + }) + it('ce que voit l’auteur ne contient jamais le nom de la règle (D20)', () => { + const v = forAuthor(heldRow() as never) + expect(JSON.stringify(v)).not.toContain('Liens') + expect(v.content).toContain('a.example') + }) + it('approuver publie le message une fois dans le salon, et prévient l’auteur', async () => { + const r = await approveHeld(HELD, who) + expect(r).toMatchObject({ ok: true, published_id: 'msg-new' }) + expect(addMessageMock).toHaveBeenCalledTimes(1) + expect(addMessageMock).toHaveBeenCalledWith({ channel_id: CHAN, author_id: AUTHOR, content: '

voir https://a.example

', reply_to_id: null }) + expect(emitMock).toHaveBeenCalledWith('chat:message', { id: 'msg-new', channel_id: CHAN }) + expect(emitMock).toHaveBeenCalledWith('octoguard:held_decided', expect.objectContaining({ outcome: 'approved' })) + }) + it('deux membres de l’équipe qui approuvent en même temps : publié UNE seule fois', async () => { + const [a, b] = await Promise.all([approveHeld(HELD, who), approveHeld(HELD, who)]) + expect([a.ok, b.ok].sort()).toEqual([false, true]) + expect(addMessageMock).toHaveBeenCalledTimes(1) + }) + it('si le salon a disparu, rien n’est publié et le contenu repasse en attente', async () => { + addMessageMock.mockRejectedValueOnce(new Error('fk')) + const r = await approveHeld(HELD, who) + expect(r).toEqual({ ok: false, code: 'PUBLISH_FAILED' }) + expect(calls.some(c => c.sql.startsWith("UPDATE octoguard_held SET status = 'pending'"))).toBe(true) + }) + it('approuver une réponse du forum la crée dans son fil', async () => { + st.held = heldRow({ kind: 'post', channel_id: null, thread_id: THREAD }) + const r = await approveHeld(HELD, who) + expect(r).toMatchObject({ ok: true, published_id: 'p-new' }) + expect(postCreateMock).toHaveBeenCalledWith({ thread_id: THREAD, author_id: AUTHOR, content: '

voir https://a.example

' }) + }) + it('refuser ne publie rien et prévient l’auteur', async () => { + const r = await refuseHeld(HELD, who) + expect(r.ok).toBe(true) + expect(addMessageMock).not.toHaveBeenCalled() + expect(emitMock).toHaveBeenCalledWith('octoguard:held_decided', expect.objectContaining({ outcome: 'refused' })) + }) + it('D17 : l’expiration vise ce qui attend depuis plus de 72 h', async () => { + expect(HELD_EXPIRY_HOURS).toBe(72) + expect(await expireHeld()).toBe(1) + const q = calls.find(c => c.sql.startsWith("UPDATE octoguard_held SET status = 'expired'"))! + expect(q.sql).toContain("status = 'pending'") + expect(q.params).toEqual(['72']) + expect(emitMock).toHaveBeenCalledWith('octoguard:held_decided', expect.objectContaining({ outcome: 'expired' })) + }) +}) + +describe('les routes', () => { + it('forum : une réponse retenue répond 202 et n’est PAS créée', async () => { + _setRulesForBench([rule()]) + const app = await buildApp(a => a.register(forumRoutes, { prefix: '/f' })) + const res = await app.inject({ method: 'POST', url: '/f/posts', headers: { authorization: token(AUTHOR) }, payload: { thread_id: THREAD, content: `

${SPAM}

` } }) + expect(res.statusCode).toBe(202) + expect(res.json()).toMatchObject({ held: true, held_id: HELD, code: 'OCTOGUARD_HELD' }) + expect(postCreateMock).not.toHaveBeenCalled() + const ins = calls.find(c => c.sql.startsWith('INSERT INTO octoguard_held'))! + expect(ins.params[0]).toBe('post') + expect(ins.params[3]).toBe(THREAD) + }) + + describe('la file de l’équipe', () => { + let app: Awaited> + beforeEach(async () => { app = await buildApp(a => a.register(octoguardAdminPlugin, { prefix: '/api/v1/admin/octoguard' })) }) + const decide = (u: string, d = 'approve') => app.inject({ method: 'POST', url: `/api/v1/admin/octoguard/held/${HELD}/${d}`, headers: { authorization: token(u) } }) + + it('approuver depuis la file publie', async () => { + const res = await decide(MOD) + expect(res.statusCode).toBe(200) + expect(res.json()).toMatchObject({ ok: true, published_id: 'msg-new' }) + }) + it('on ne relit pas son propre contenu', async () => { + st.held = heldRow({ author_id: MOD }) + const res = await decide(MOD) + expect(res.statusCode).toBe(403) + expect(res.json().code).toBe('CANNOT_REVIEW_SELF') + expect(addMessageMock).not.toHaveBeenCalled() + }) + it('déjà décidé : 409', async () => { + st.held = heldRow({ status: 'approved' }) + const res = await decide(MOD) + expect(res.statusCode).toBe(409) + expect(res.json().code).toBe('ALREADY_DECIDED') + }) + it('une décision inconnue est refusée', async () => { + expect((await decide(MOD, 'publish')).statusCode).toBe(400) + }) + it('un simple membre n’a pas accès à la file', async () => { + const res = await decide(AUTHOR) + expect(res.statusCode).toBe(403) + expect(addMessageMock).not.toHaveBeenCalled() + }) + }) +}) + +describe('ce que le moteur lit du forum (trous trouvés au banc d’essai du 04/11)', () => { + it('l’adresse d’un lien caché derrière un texte est lue', async () => { + const { textForModeration } = await import('../services/octoguard/moderate') + expect(textForModeration('

Lien : ici

')).toBe('Lien : ici https://bit.ly/x9k') + expect(textForModeration('

https://a.example

')).toBe('https://a.example') + }) + it('rien n’est tronqué : un lien après 1000 caractères est lu', async () => { + const { textForModeration } = await import('../services/octoguard/moderate') + const long = `

${'blabla '.repeat(300)}

https://spam.example

` + expect(textForModeration(long)).toContain('https://spam.example') + }) + it('bout en bout : une réponse avec un lien « ici » est retenue par la règle de liens', async () => { + _setRulesForBench([rule({ params: { max_links: 0 } })]) + const app = await buildApp(a => a.register(forumRoutes, { prefix: '/f' })) + const res = await app.inject({ method: 'POST', url: '/f/posts', headers: { authorization: token(AUTHOR) }, payload: { thread_id: THREAD, content: '

Lien : ici

' } }) + expect(res.statusCode).toBe(202) + }) +}) + +describe('sécurité de la mise en attente (revue du 07/11)', () => { + const who = { id: MOD, username: 'mod' } + const hold = () => holdContent({ kind: 'chat', authorId: AUTHOR, channelId: CHAN, payload: { content: 'x' }, rule: { rule_id: 'r1', rule_name: 'Liens', excerpt: 'x' } }) + + it('la limite par auteur est DANS la requête (5 en attente)', async () => { + await hold() + const q = calls.find(c => c.sql.startsWith('INSERT INTO octoguard_held'))! + expect(q.sql).toContain("WHERE author_id = $2 AND status = 'pending') < $11") + expect(q.params[10]).toBe(5) + }) + it('file pleine : refus, code stable', async () => { + st.full = true + expect(await hold()).toEqual({ ok: false, code: 'HELD_LIMIT' }) + }) + it('base en panne : refus, jamais d’exception, jamais de publication', async () => { + st.insertFails = true + expect(await hold()).toEqual({ ok: false, code: 'HOLD_FAILED' }) + expect(addMessageMock).not.toHaveBeenCalled() + }) + it('forum : file pleine ou base en panne = 422, la réponse N’EST PAS publiée', async () => { + _setRulesForBench([rule()]) + const app = await buildApp(a => a.register(forumRoutes, { prefix: '/f' })) + for (const k of ['full', 'insertFails'] as const) { + st.full = false; st.insertFails = false; st[k] = true + const res = await app.inject({ method: 'POST', url: '/f/posts', headers: { authorization: token(AUTHOR) }, payload: { thread_id: THREAD, content: `

${SPAM}

` } }) + expect(res.statusCode).toBe(422) + expect(postCreateMock).not.toHaveBeenCalled() + } + }) + it('auteur banni depuis : l’approbation ne publie pas, le contenu reste en attente', async () => { + st.banned = true + expect(await approveHeld(HELD, who)).toEqual({ ok: false, code: 'AUTHOR_BANNED' }) + expect(addMessageMock).not.toHaveBeenCalled() + expect(calls.some(c => c.sql.startsWith("UPDATE octoguard_held SET status = 'pending'"))).toBe(true) + }) + it('fil verrouillé depuis : une réponse retenue n’est pas publiée', async () => { + st.locked = true + st.held = heldRow({ kind: 'post', channel_id: null, thread_id: THREAD }) + expect(await approveHeld(HELD, who)).toEqual({ ok: false, code: 'THREAD_LOCKED' }) + expect(postCreateMock).not.toHaveBeenCalled() + }) + it('le texte des contenus décidés est effacé après 30 jours, jamais ce qui attend', async () => { + const { purgeDecidedHeld } = await import('../services/octoguard/held') + await purgeDecidedHeld() + const q = calls.find(c => c.sql.startsWith('DELETE FROM octoguard_held'))! + expect(q.sql).toContain("status <> 'pending'") + expect(q.params).toEqual(['30']) + }) +}) + +describe('approuver ne publie jamais deux fois (relecture du 07/11)', () => { + const who = { id: MOD, username: 'mod' } + it('les points de réputation échouent APRÈS la création : publié, acquis, pas de remise en attente', async () => { + const { awardPoints } = await import('../models/reputation') + vi.mocked(awardPoints).mockRejectedValueOnce(new Error('redis down')) + st.held = heldRow({ kind: 'post', channel_id: null, thread_id: THREAD }) + const r = await approveHeld(HELD, who) + expect(r).toMatchObject({ ok: true, published_id: 'p-new' }) + expect(calls.some(c => c.sql.startsWith("UPDATE octoguard_held SET status = 'pending'"))).toBe(false) + // un second clic ne trouve plus rien à publier + expect(await approveHeld(HELD, who)).toEqual({ ok: false, code: 'ALREADY_DECIDED' }) + expect(postCreateMock).toHaveBeenCalledTimes(1) + }) + it('nouveau fil dont le premier message échoue : le fil vide est retiré, rien n’est publié', async () => { + vi.mocked(ThreadModel.create).mockResolvedValueOnce({ id: 'th-new' } as never) + postCreateMock.mockRejectedValueOnce(new Error('fk')) + st.held = heldRow({ kind: 'thread', channel_id: null, category_id: 'cat', payload: { content: '

x

', title: 'Titre' } }) + const r = await approveHeld(HELD, who) + expect(r).toEqual({ ok: false, code: 'PUBLISH_FAILED' }) + expect(ThreadModel.remove).toHaveBeenCalledWith('th-new') + }) +}) diff --git a/nodyx-core/src/tests/octoguard-v2-routes.test.ts b/nodyx-core/src/tests/octoguard-v2-routes.test.ts index 416b833d..d6575d4c 100644 --- a/nodyx-core/src/tests/octoguard-v2-routes.test.ts +++ b/nodyx-core/src/tests/octoguard-v2-routes.test.ts @@ -19,9 +19,11 @@ vi.mock('../config/database', () => ({ db: { query: vi.fn() }, redis: { get: vi.fn().mockResolvedValue(null), setex: vi.fn().mockResolvedValue('OK'), exists: vi.fn(), incr: vi.fn().mockResolvedValue(1), expire: vi.fn().mockResolvedValue(1) }, })) -vi.mock('../services/octoguard/moderate', () => ({ +vi.mock('../services/octoguard/moderate', async (orig) => ({ + textForModeration: ((await orig()) as { textForModeration: (h: string) => string }).textForModeration, moderateText: moderateMock, blockedBody: (v: { reason: string; i18n_key: string }) => ({ error: v.reason, code: 'OCTOGUARD_BLOCKED', i18n_key: v.i18n_key }), + heldBody: (h: { id: string }) => ({ held: true, held_id: h.id, code: 'OCTOGUARD_HELD' }), })) vi.mock('../services/octoguard/autoReport', () => ({ fileAutoReport: autoReportMock })) vi.mock('../services/octoguard', () => ({ diff --git a/nodyx-frontend/src/app.css b/nodyx-frontend/src/app.css index 88849a03..5cd16bec 100644 --- a/nodyx-frontend/src/app.css +++ b/nodyx-frontend/src/app.css @@ -81,6 +81,7 @@ html, body { --nx-warn: #b45309; --nx-bad: #b91c1c; --nx-info: #1d4ed8; + --nx-hold: #6d28d9; /* OctoGuard, cran « Retenir » */ --nx-scheme: light; --nx-header-accent: #c67c00; --nx-header-accent-strong: #a66700; @@ -103,6 +104,7 @@ html, body { --nx-warn: #fbbf24; --nx-bad: #f87171; --nx-info: #60a5fa; + --nx-hold: #a78bfa; /* OctoGuard, cran « Retenir » */ --nx-scheme: dark; --nx-header-accent: #ffb020; --nx-header-accent-strong: #e89900; @@ -127,6 +129,7 @@ html, body { --nx-warn: #fbbf24; --nx-bad: #f87171; --nx-info: #60a5fa; + --nx-hold: #a78bfa; /* OctoGuard, cran « Retenir » */ --nx-scheme: dark; --nx-header-accent: #ffb020; --nx-header-accent-strong: #e89900; diff --git a/nodyx-frontend/src/lib/adminTheme.ts b/nodyx-frontend/src/lib/adminTheme.ts index 69d50c71..7dff9f17 100644 --- a/nodyx-frontend/src/lib/adminTheme.ts +++ b/nodyx-frontend/src/lib/adminTheme.ts @@ -7,8 +7,6 @@ export const ADMIN_THEMED_ROUTES: readonly string[] = [ '/admin', '/admin/members', // Lot 1 : gestion de la communauté '/admin/grades', '/admin/categories', '/admin/tags', '/admin/moderation', '/admin/audit-log', - // Modération unifiée (E2) - '/admin/reports', // OctoGuard 2, livraison 3a '/admin/octoguard', '/admin/octoguard/rules', '/admin/octoguard/sandbox', // OctoGuard 2, livraison 3b @@ -16,6 +14,8 @@ export const ADMIN_THEMED_ROUTES: readonly string[] = [ '/admin/octoguard/welcome', '/admin/octoguard/commands', '/admin/octoguard/webhook', // OctoGuard 2, livraison 3c '/admin/octoguard/guide', '/admin/octoguard/member-view', + // OctoGuard 2, mise en conformité (E4h) : Signalements dans OctoGuard + '/admin/octoguard/reports', ] // Sous-pages dynamiques déjà migrées (fiche d'un membre : /admin/members/) diff --git a/nodyx-frontend/src/lib/components/octoguard/HeldBlock.svelte b/nodyx-frontend/src/lib/components/octoguard/HeldBlock.svelte new file mode 100644 index 00000000..9d8e17f7 --- /dev/null +++ b/nodyx-frontend/src/lib/components/octoguard/HeldBlock.svelte @@ -0,0 +1,36 @@ + + +
+
+ + + {tFn('ogmsg.held_pill')} + + {tFn(`ogmsg.held_kind_${kind}`)} +
+ {#if title}

{title}

{/if} +

{htmlToText(content)}

+

{tFn(kind === 'post_edit' ? 'ogmsg.held_edit_help' : 'ogmsg.held_only_you')}

+
+ + diff --git a/nodyx-frontend/src/lib/components/octoguard/RuleDrawer.svelte b/nodyx-frontend/src/lib/components/octoguard/RuleDrawer.svelte index 8ea44b92..ba636a51 100644 --- a/nodyx-frontend/src/lib/components/octoguard/RuleDrawer.svelte +++ b/nodyx-frontend/src/lib/components/octoguard/RuleDrawer.svelte @@ -181,6 +181,9 @@ {/each} + {#if (level === 'hold' || (level === 'observe' && intendedOf(start) === 'hold')) && scopes.some((s) => s === 'profile' || s === 'poll' || s === 'wiki')} +

{tFn('og2.hold_scope_note')}

+ {/if}
diff --git a/nodyx-frontend/src/lib/locales/en.json b/nodyx-frontend/src/lib/locales/en.json index 37dbd924..b0590604 100644 --- a/nodyx-frontend/src/lib/locales/en.json +++ b/nodyx-frontend/src/lib/locales/en.json @@ -4526,6 +4526,9 @@ "notifications.mark_read": "Mark as read", "notifications.report_resolved_label": "The team has handled your report. Thanks for looking out for the community.", "notifications.moderation_warning_label": "The moderation team has sent you a warning:", + "notifications.held_approved_label": "Your message was approved by the team: it is published.", + "notifications.held_refused_label": "Your message was not published after review.", + "notifications.held_expired_hint": "Nobody could review it in time.", "overlay_alert.follow": "New follower", "overlay_alert.subscribe": "New subscriber", "overlay_alert.gift": "Gifted sub", @@ -5238,6 +5241,18 @@ "ogmsg.mute_applied": "OctoGuard muted you for a while: you can read, but not write. Take a breather.", "ogmsg.ban_temp_applied": "OctoGuard has temporarily removed you from the community. You can come back when the period ends.", "ogmsg.blocked": "OctoGuard held back this message. Rephrase it, or ask a moderator.", + "ogmsg.held_pill": "Awaiting approval", + "ogmsg.held_only_you": "Only you can see this message for now. A moderator is reviewing it, it won't take long.", + "ogmsg.held_edit_help": "Your edit is waiting for review. Meanwhile, the previous version stays online.", + "ogmsg.held_kind_chat": "Your message", + "ogmsg.held_kind_post": "Your reply", + "ogmsg.held_kind_thread": "Your new thread", + "ogmsg.held_kind_post_edit": "Your edit", + "ogmsg.held_refused": "Your message was not published after review.", + "ogmsg.held_expired": "Your message could not be reviewed in time: it was not published.", + "ogmsg.held_poll_dropped": "The attached poll was not created: you can add it once the thread is published.", + "ogmsg.held_back": "Back to the category", + "ogmsg.held_limit": "You already have several messages awaiting review. Wait until a moderator has seen them before sending more.", "og2.side_sub": "The community guardian", "og2.nav_overview": "Overview", "og2.nav_sec_rules": "Rules", @@ -5823,5 +5838,65 @@ "og2.list_more": " and {{n}} more", "og2.st_precision": "Accuracy", "og2.st_precision_sub": "{{u}} false positive(s) undone out of {{n}}", - "og2.st_precision_none": "No action this week yet" + "og2.st_precision_none": "No action this week yet", + "og2.lv_hold": "Hold", + "og2.lv_say_hold": "the message waits for a moderator to approve it. The author sees \"awaiting approval\", nobody else sees anything.", + "og2.lv_would_hold": "hold", + "og2.legend_message_hold": "Waiting, invisible to others", + "og2.legend_author_hold": "Sees \"awaiting approval\"", + "og2.legend_team_hold": "Approves or refuses it, in \"To review\"", + "og2.sb_do_hold": "would hold this message for review", + "og2.ev_held": "Message held for review by \"{{rule}}\"", + "og2.ev_held_approved": "{{who}} approved a held message", + "og2.ev_held_refused": "{{who}} refused a held message", + "og2.ev_held_expired": "Held message refused, not reviewed in time", + "og2.hold_scope_note": "Profiles, polls and the wiki have no waiting room: there, the rule reports the content to the team instead of holding it.", + "og2.rp_desc": "Member reports, OctoGuard reports and held messages, in a single queue. Oldest first.", + "og2.rp_tab_open": "To handle", + "og2.rp_tab_held": "To review", + "og2.rp_tab_done": "Handled", + "og2.rp_queue": "The queue", + "og2.rp_min": "{{n}} min", + "og2.rp_h": "{{n}} h", + "og2.rp_left_d": "{{d}} d {{h}} h", + "og2.rp_left_h": "{{h}} h", + "og2.rp_place_chat": "the chat", + "og2.rp_place_new_thread": "new thread", + "og2.rp_place_forum": "the forum", + "og2.rp_q_held": "To review · {{age}} · {{rule}}", + "og2.rp_held_title_chat": "Message from {{name}} in {{place}}", + "og2.rp_held_title_post": "Reply from {{name}} in \"{{place}}\"", + "og2.rp_held_title_thread": "New thread from {{name}}", + "og2.rp_held_title_post_edit": "Edit by {{name}} in \"{{place}}\"", + "og2.rp_held_by": "Held by the rule \"{{rule}}\" · {{at}}", + "og2.rp_pending": "Waiting", + "og2.rp_held_text": "The message, as it would be published", + "og2.rp_new_text": "The proposed new text (the old one stays online meanwhile)", + "og2.rp_only_author": "For now, only its author sees it, marked \"awaiting approval\".", + "og2.rp_expires": "Without a decision, it will be refused in {{left}}, and the author told.", + "og2.rp_held_how": "A held message is only published if someone on the team approves it. Without a decision after {{h}} h, it is refused and its author told.", + "og2.rp_empty_held": "Nothing to review.", + "og2.rp_empty_held_sub": "Messages held by a rule set to \"Hold\" arrive here.", + "og2.rp_brief": "{{name}}, at a glance", + "og2.rp_decide": "Decide", + "og2.rp_approve": "Approve and publish", + "og2.rp_approve_edit": "Approve the edit", + "og2.rp_refuse": "Refuse", + "og2.rp_decide_help": "The author is told either way. They never see the name of the rule.", + "og2.rp_ok_approved": "Approved: the message is published.", + "og2.rp_ok_refused": "Refused: the message will not be published, its author has been told.", + "og2.rp_ok_claimed": "You are on it: the whole team can see that.", + "og2.rp_ok_closed": "Case closed.", + "og2.rp_err_self": "You cannot review your own message: someone else on the team will.", + "og2.rp_err_decided": "Someone on the team already decided. The queue is up to date.", + "og2.rp_err_gone": "The channel or thread no longer exists: nothing was published. Refuse this message.", + "og2.rp_age_new": "account less than an hour old", + "og2.rp_age_h": "account {{n}} h old", + "og2.rp_age_d": "account {{n}} days old", + "og2.rp_age_m": "member for {{n}} months", + "og2.rp_err_banned": "Its author has been banned since: nothing was published. Refuse this message.", + "og2.rp_err_locked": "The thread has been locked since: nothing was published. Refuse this message.", + "og2.rp_held_waiting_1": "However, 1 held message is waiting in \"To review\".", + "og2.rp_held_waiting_n": "However, {{n}} held messages are waiting in \"To review\".", + "og2.rp_go_held": "Open \"To review\"" } diff --git a/nodyx-frontend/src/lib/locales/fr.json b/nodyx-frontend/src/lib/locales/fr.json index bcfce4b7..9d777a57 100644 --- a/nodyx-frontend/src/lib/locales/fr.json +++ b/nodyx-frontend/src/lib/locales/fr.json @@ -2077,6 +2077,9 @@ "notifications.mark_read": "Marquer comme lu", "notifications.report_resolved_label": "L'équipe a traité votre signalement. Merci de veiller sur la communauté.", "notifications.moderation_warning_label": "L'équipe de modération vous a adressé un avertissement :", + "notifications.held_approved_label": "Ton message a été validé par l'équipe : il est publié.", + "notifications.held_refused_label": "Ton message n'a pas été publié après relecture.", + "notifications.held_expired_hint": "Personne n'a pu le relire à temps.", "overlay_alert.follow": "Nouveau follower", "overlay_alert.subscribe": "Nouvel abonné", "overlay_alert.gift": "Sub offert", @@ -5238,6 +5241,18 @@ "ogmsg.mute_applied": "OctoGuard t'a mis en sourdine un moment : tu peux lire, mais pas écrire. Prends le temps de souffler.", "ogmsg.ban_temp_applied": "OctoGuard t'a temporairement exclu de la communauté. Tu pourras revenir à la fin de la période.", "ogmsg.blocked": "OctoGuard a retenu ce message. Reformule-le, ou demande à un modérateur.", + "ogmsg.held_pill": "En attente de validation", + "ogmsg.held_only_you": "Toi seul vois ce message pour l'instant. Un modérateur le relit, ça ne dure pas.", + "ogmsg.held_edit_help": "Ta modification attend une relecture. En attendant, l'ancienne version reste en ligne.", + "ogmsg.held_kind_chat": "Ton message", + "ogmsg.held_kind_post": "Ta réponse", + "ogmsg.held_kind_thread": "Ton nouveau fil", + "ogmsg.held_kind_post_edit": "Ta modification", + "ogmsg.held_refused": "Ton message n'a pas été publié après relecture.", + "ogmsg.held_expired": "Ton message n'a pas pu être relu à temps : il n'a pas été publié.", + "ogmsg.held_poll_dropped": "Le sondage joint n'a pas été créé : tu pourras l'ajouter une fois le fil publié.", + "ogmsg.held_back": "Revenir à la catégorie", + "ogmsg.held_limit": "Tu as déjà plusieurs messages en attente de relecture. Attends qu'un modérateur les ait vus avant d'en envoyer d'autres.", "og2.side_sub": "Le gardien de la communauté", "og2.nav_overview": "Vue d'ensemble", "og2.nav_sec_rules": "Règles", @@ -5823,5 +5838,65 @@ "og2.list_more": " et {{n}} autre(s)", "og2.st_precision": "Justesse", "og2.st_precision_sub": "{{u}} faux positif(s) annulé(s) sur {{n}}", - "og2.st_precision_none": "Pas encore d'action cette semaine" + "og2.st_precision_none": "Pas encore d'action cette semaine", + "og2.lv_hold": "Retenir", + "og2.lv_say_hold": "le message attend qu'un modérateur l'approuve. L'auteur voit « en attente de validation », les autres ne voient rien.", + "og2.lv_would_hold": "retenir", + "og2.legend_message_hold": "En attente, invisible pour les autres", + "og2.legend_author_hold": "Voit « en attente de validation »", + "og2.legend_team_hold": "Approuve ou refuse, dans « À relire »", + "og2.sb_do_hold": "retiendrait ce message pour relecture", + "og2.ev_held": "Message retenu pour relecture par « {{rule}} »", + "og2.ev_held_approved": "{{who}} a approuvé un message retenu", + "og2.ev_held_refused": "{{who}} a refusé un message retenu", + "og2.ev_held_expired": "Message retenu refusé faute de relecture à temps", + "og2.hold_scope_note": "Profils, sondages et wiki n'ont pas d'attente : à ces endroits, la règle signale le contenu à l'équipe au lieu de le retenir.", + "og2.rp_desc": "Ceux des membres, ceux d'OctoGuard et les messages retenus, dans une seule file. La plus ancienne demande en tête.", + "og2.rp_tab_open": "À traiter", + "og2.rp_tab_held": "À relire", + "og2.rp_tab_done": "Traités", + "og2.rp_queue": "La file", + "og2.rp_min": "{{n}} min", + "og2.rp_h": "{{n}} h", + "og2.rp_left_d": "{{d}} j {{h}} h", + "og2.rp_left_h": "{{h}} h", + "og2.rp_place_chat": "le chat", + "og2.rp_place_new_thread": "nouveau fil", + "og2.rp_place_forum": "le forum", + "og2.rp_q_held": "À relire · {{age}} · {{rule}}", + "og2.rp_held_title_chat": "Message de {{name}} dans {{place}}", + "og2.rp_held_title_post": "Réponse de {{name}} dans « {{place}} »", + "og2.rp_held_title_thread": "Nouveau fil de {{name}}", + "og2.rp_held_title_post_edit": "Modification de {{name}} dans « {{place}} »", + "og2.rp_held_by": "Retenu par la règle « {{rule}} » · {{at}}", + "og2.rp_pending": "En attente", + "og2.rp_held_text": "Le message, tel qu'il serait publié", + "og2.rp_new_text": "Le nouveau texte proposé (l'ancien reste en ligne en attendant)", + "og2.rp_only_author": "Pour l'instant, seul son auteur le voit, marqué « en attente de validation ».", + "og2.rp_expires": "Sans décision, il sera refusé dans {{left}}, et l'auteur prévenu.", + "og2.rp_held_how": "Un message retenu n'est publié que si quelqu'un de l'équipe l'approuve. Sans décision au bout de {{h}} h, il est refusé et son auteur prévenu.", + "og2.rp_empty_held": "Rien à relire.", + "og2.rp_empty_held_sub": "Les messages retenus par une règle au cran « Retenir » arrivent ici.", + "og2.rp_brief": "{{name}}, en bref", + "og2.rp_decide": "Décider", + "og2.rp_approve": "Approuver et publier", + "og2.rp_approve_edit": "Approuver la modification", + "og2.rp_refuse": "Refuser", + "og2.rp_decide_help": "L'auteur est prévenu dans les deux cas. Il ne voit jamais le nom de la règle.", + "og2.rp_ok_approved": "Approuvé : le message est publié.", + "og2.rp_ok_refused": "Refusé : le message ne sera pas publié, son auteur est prévenu.", + "og2.rp_ok_claimed": "Vous vous en occupez : toute l'équipe le voit.", + "og2.rp_ok_closed": "Dossier clos.", + "og2.rp_err_self": "On ne relit pas son propre message : quelqu'un d'autre de l'équipe s'en chargera.", + "og2.rp_err_decided": "Quelqu'un de l'équipe a déjà décidé. La file est à jour.", + "og2.rp_err_gone": "Le salon ou le fil n'existe plus : rien n'a été publié. Refusez ce message.", + "og2.rp_age_new": "compte de moins d'une heure", + "og2.rp_age_h": "compte de {{n}} h", + "og2.rp_age_d": "compte de {{n}} jours", + "og2.rp_age_m": "membre depuis {{n}} mois", + "og2.rp_err_banned": "Son auteur a été banni depuis : rien n'a été publié. Refusez ce message.", + "og2.rp_err_locked": "Le fil a été verrouillé depuis : rien n'a été publié. Refusez ce message.", + "og2.rp_held_waiting_1": "En revanche, 1 message retenu attend dans « À relire ».", + "og2.rp_held_waiting_n": "En revanche, {{n}} messages retenus attendent dans « À relire ».", + "og2.rp_go_held": "Ouvrir « À relire »" } diff --git a/nodyx-frontend/src/lib/octoguard/events.ts b/nodyx-frontend/src/lib/octoguard/events.ts index 953d25fb..bd21d20e 100644 --- a/nodyx-frontend/src/lib/octoguard/events.ts +++ b/nodyx-frontend/src/lib/octoguard/events.ts @@ -31,6 +31,10 @@ export function eventOf(l: Pick): { kind: EventKin case 'escalation_mute': case 'escalation_ban_temp': return { kind: 'delete', key: 'ev_escalation', source: 'octoguard' } case 'report_filed': case 'notify': return { kind: 'report', key: 'ev_report', source: 'octoguard' } case 'action_undone': return { kind: 'ok', key: 'ev_undone', source: 'team' } + case 'message_held': return { kind: 'hold', key: 'ev_held', source: 'octoguard' } + case 'held_approved': return { kind: 'ok', key: 'ev_held_approved', source: 'team' } + case 'held_refused': return { kind: 'delete', key: 'ev_held_refused', source: 'team' } + case 'held_expired': return { kind: 'observe', key: 'ev_held_expired', source: 'octoguard' } case 'hate_symbol_blocked': return { kind: 'delete', key: 'ev_hate', source: 'octoguard' } } if (/^(rule|profile|command|welcome|webhook)_/.test(a)) return { kind: 'acc', key: 'ev_config', source: 'team' } diff --git a/nodyx-frontend/src/lib/octoguard/nav.test.ts b/nodyx-frontend/src/lib/octoguard/nav.test.ts index a573d4f7..41fe95dd 100644 --- a/nodyx-frontend/src/lib/octoguard/nav.test.ts +++ b/nodyx-frontend/src/lib/octoguard/nav.test.ts @@ -12,7 +12,7 @@ describe('menu d\'OctoGuard selon les droits', () => { }) it('un modérateur des membres ne voit que signalements et sourdines', () => { - expect(visible({ role: 'moderator', permissions: ['moderate_members'] })).toEqual(['/admin/reports', '/admin/octoguard/mutes']) + expect(visible({ role: 'moderator', permissions: ['moderate_members'] })).toEqual(['/admin/octoguard/reports', '/admin/octoguard/mutes']) }) it('le droit « gérer OctoGuard » ouvre les règles, le bac à sable et le journal', () => { diff --git a/nodyx-frontend/src/lib/octoguard/nav.ts b/nodyx-frontend/src/lib/octoguard/nav.ts index 63605122..353762d3 100644 --- a/nodyx-frontend/src/lib/octoguard/nav.ts +++ b/nodyx-frontend/src/lib/octoguard/nav.ts @@ -14,7 +14,7 @@ export const OG_NAV: OgNavGroup[] = [ { href: '/admin/octoguard/automod', labelKey: 'og2.nav_advanced', iconPath: 'M4 21v-7 M4 10V3 M12 21v-9 M12 8V3 M20 21v-5 M20 12V3 M1 14h6 M9 8h6 M17 16h6' }, ] }, { sectionKey: 'og2.nav_sec_team', items: [ - { href: '/admin/reports', labelKey: 'og2.nav_reports', reports: true, iconPath: 'M5 21V4h11l-1.5 4L16 12H5' }, + { href: '/admin/octoguard/reports', labelKey: 'og2.nav_reports', reports: true, iconPath: 'M5 21V4h11l-1.5 4L16 12H5' }, { href: '/admin/octoguard/mutes', labelKey: 'og2.nav_mutes', iconPath: 'M11 5L6 9H3v6h3l5 4z M22 9l-6 6 M16 9l6 6' }, { href: '/admin/octoguard/logs', labelKey: 'og2.nav_log', iconPath: 'M9 6h11 M9 12h11 M9 18h11 M4.5 6h.01 M4.5 12h.01 M4.5 18h.01' }, ] }, diff --git a/nodyx-frontend/src/lib/octoguard/rules.test.ts b/nodyx-frontend/src/lib/octoguard/rules.test.ts index bc7547ce..008a7d32 100644 --- a/nodyx-frontend/src/lib/octoguard/rules.test.ts +++ b/nodyx-frontend/src/lib/octoguard/rules.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { levelOf, intendedOf, patchForLevel, categoryOf, sanctionOf } from './rules' +import { levelOf, intendedOf, patchForLevel, categoryOf, sanctionOf, LEVELS } from './rules' describe('les crans, traduits vers et depuis le moteur', () => { it('lit le cran d\'une règle', () => { @@ -38,3 +38,17 @@ describe('les catégories', () => { expect(categoryOf({ type: 'regex' })).toBe('custom') }) }) + +describe('le cran « Retenir » (lot 2)', () => { + it('cinq crans, Retenir entre Signaler et Avertir', () => { + expect(LEVELS).toEqual(['observe', 'report', 'hold', 'warn', 'delete']) + }) + it('une règle « hold » s’affiche au cran Retenir, et y revient en sortant de l’observation', () => { + expect(levelOf({ action: 'hold', dry_run: false })).toBe('hold') + expect(levelOf({ action: 'hold', dry_run: true })).toBe('observe') + expect(intendedOf({ action: 'hold' })).toBe('hold') + }) + it('mettre une règle au cran Retenir envoie action « hold »', () => { + expect(patchForLevel({ action: 'report_only' }, 'hold')).toEqual({ dry_run: false, action: 'hold' }) + }) +}) diff --git a/nodyx-frontend/src/lib/octoguard/rules.ts b/nodyx-frontend/src/lib/octoguard/rules.ts index ed262d6f..4e460467 100644 --- a/nodyx-frontend/src/lib/octoguard/rules.ts +++ b/nodyx-frontend/src/lib/octoguard/rules.ts @@ -25,14 +25,16 @@ export interface Rule { // ─── Les crans ─────────────────────────────────────────────────────────────── // Le moteur connaît : observation (dry_run), report_only / notify_only, warn, -// delete, et deux sanctions (mute, ban_temp). L'écran les range en 4 crans ; -// une règle qui sanctionne s'affiche au cran Supprimer, sa sanction à côté. -export type Level = 'observe' | 'report' | 'warn' | 'delete' -export const LEVELS: readonly Level[] = ['observe', 'report', 'warn', 'delete'] +// hold (retenir pour relecture), delete, et deux sanctions (mute, ban_temp). +// L'écran les range en 5 crans ; une règle qui sanctionne s'affiche au cran +// Supprimer, sa sanction à côté. +export type Level = 'observe' | 'report' | 'hold' | 'warn' | 'delete' +export const LEVELS: readonly Level[] = ['observe', 'report', 'hold', 'warn', 'delete'] export type Acting = Exclude export function intendedOf(r: Pick): Acting { if (r.action === 'report_only' || r.action === 'notify_only') return 'report' + if (r.action === 'hold') return 'hold' if (r.action === 'warn') return 'warn' return 'delete' } @@ -53,6 +55,7 @@ export const sanctionOf = (r: Pick) => (r.action === 'mute' || r export const LEVEL_ICON: Record = { observe: 'M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z M12 9a3 3 0 100 6 3 3 0 000-6z', report: 'M5 21V4h11l-1.5 4L16 12H5', + hold: 'M12 3a9 9 0 100 18 9 9 0 000-18z M10 9v6 M14 9v6', warn: 'M12 9v4 M12 17h.01 M10.3 3.9L1.8 18a2 2 0 001.7 3h17a2 2 0 001.7-3L13.7 3.9a2 2 0 00-3.4 0z', delete: 'M4 7h16 M10 11v6 M14 11v6 M5 7l1 13h12l1-13 M9 7V4h6v3', } diff --git a/nodyx-frontend/src/lib/octoguard/templates.json b/nodyx-frontend/src/lib/octoguard/templates.json index 817f336c..862143b3 100644 --- a/nodyx-frontend/src/lib/octoguard/templates.json +++ b/nodyx-frontend/src/lib/octoguard/templates.json @@ -33,7 +33,7 @@ "params": { "max_links": 0 }, - "action": "delete", + "action": "hold", "scopes": [ "chat", "forum", @@ -204,7 +204,7 @@ "chat.whatsapp.com" ] }, - "action": "delete", + "action": "hold", "scopes": [ "chat", "forum", @@ -559,4 +559,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/nodyx-frontend/src/lib/octoguard/text.test.ts b/nodyx-frontend/src/lib/octoguard/text.test.ts new file mode 100644 index 00000000..02628492 --- /dev/null +++ b/nodyx-frontend/src/lib/octoguard/text.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest' +import { htmlToText } from './text' + +describe('htmlToText : relire un contenu retenu tel quel', () => { + it('garde les liens en clair, même cachés derrière un texte', () => { + expect(htmlToText('

voir le tuto

')).toBe('voir le tuto (https://bit.ly/x9k)') + expect(htmlToText('

https://a.example

')).toBe('https://a.example') + }) + it('garde les sauts de ligne, n’interprète aucune balise', () => { + expect(htmlToText('

un

deux
trois

')).toBe('un\ndeux\ntrois') + expect(htmlToText('<script>x</script>')).toBe('') + }) +}) + +describe('htmlToText tient face à un texte hostile', () => { + it('500 Ko d’ancres jamais fermées ou de chevrons : instantané', () => { + for (const s of [''.repeat(40000), '<'.repeat(500000), ''.repeat(30000) + '', ''.repeat(30000) + ''.repeat(30000)]) { + const t = performance.now() + htmlToText(s) + expect(performance.now() - t).toBeLessThan(500) + } + }) +}) diff --git a/nodyx-frontend/src/lib/octoguard/text.ts b/nodyx-frontend/src/lib/octoguard/text.ts new file mode 100644 index 00000000..2f9b18a6 --- /dev/null +++ b/nodyx-frontend/src/lib/octoguard/text.ts @@ -0,0 +1,57 @@ +// Le texte d'un contenu HTML (déjà nettoyé par le cœur), pour le relire tel +// quel : liens en clair, sauts de ligne gardés, aucune balise interprétée. +// Ce texte vient d'un auteur peut-être malveillant et s'affiche chez un +// modérateur : tout est lu en un passage, sans expression régulière qui +// revient en arrière (une version à regex se bloquait sur des ancres jamais +// fermées, mesuré le 07/11 côté cœur). +const ENTITIES: Record = { amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'", apos: "'", nbsp: ' ' } +const BLOCK_END = /^<\/(p|div|li|h[1-6]|blockquote)>$|^$/i + +/** Retire les balises en un passage (« < » sans « > » : le reste est du texte). */ +function stripTags(s: string): string { + let out = '' + let i = 0 + while (i < s.length) { + const lt = s.indexOf('<', i) + if (lt === -1) { out += s.slice(i); break } + const gt = s.indexOf('>', lt) + if (gt === -1) { out += s.slice(i); break } + out += s.slice(i, lt) + i = gt + 1 + } + return out +} + +export function htmlToText(html: string): string { + const lower = html.toLowerCase() + let out = '' + let i = 0 + let close = -1 + let noMoreClose = false + while (i < html.length) { + const lt = html.indexOf('<', i) + if (lt === -1) { out += html.slice(i); break } + const gt = html.indexOf('>', lt) + if (gt === -1) { out += html.slice(i); break } + out += html.slice(i, lt) + const tag = html.slice(lt, gt + 1) + const isAnchor = /^]/i.test(tag) + if (isAnchor && tag.length <= 4096) { + const m = /\bhref\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(tag) + const href = m ? (m[1] ?? m[2] ?? '') : '' + if (!noMoreClose && close < gt) { close = lower.indexOf('', gt); if (close === -1) noMoreClose = true } + if (!noMoreClose) { + const label = stripTags(html.slice(gt + 1, close)).trim() + out += href && label !== href ? (label ? `${label} (${href})` : href) : label + i = close + 4 + continue + } + if (href) out += `${href} ` + } else if (BLOCK_END.test(tag)) out += '\n' + i = gt + 1 + } + return out + .replace(/&(amp|lt|gt|quot|#39|apos|nbsp);/g, (_m, e: string) => ENTITIES[e]) + .replace(/\n{3,}/g, '\n\n') + .trim() +} diff --git a/nodyx-frontend/src/lib/octoguardMessages.ts b/nodyx-frontend/src/lib/octoguardMessages.ts index 93679371..7294a54c 100644 --- a/nodyx-frontend/src/lib/octoguardMessages.ts +++ b/nodyx-frontend/src/lib/octoguardMessages.ts @@ -11,6 +11,7 @@ const BY_CORE_KEY: Record = { [OG + 'warn' + '.given']: 'ogmsg.warn_given', [OG + 'mute' + '.applied']: 'ogmsg.mute_applied', [OG + 'ban_temp' + '.applied']: 'ogmsg.ban_temp_applied', + [OG + 'held_limit']: 'ogmsg.held_limit', } export function octoguardMessage(tFn: (k: string) => string, coreKey: string | null | undefined): string { diff --git a/nodyx-frontend/src/routes/admin/+layout.svelte b/nodyx-frontend/src/routes/admin/+layout.svelte index be9060ff..201635ec 100644 --- a/nodyx-frontend/src/routes/admin/+layout.svelte +++ b/nodyx-frontend/src/routes/admin/+layout.svelte @@ -24,7 +24,7 @@ items: [ { href: '/admin', labelKey: 'anav.lbl_dashboard', exact: true, iconPath: 'M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z' }, { href: '/admin/members', labelKey: 'anav.lbl_members', iconPath: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z' }, - { href: '/admin/reports', labelKey: 'anav.lbl_reports', iconPath: 'M4 21V4m0 0h11l-1.5 4L15 12H4' }, + { href: '/admin/octoguard/reports', labelKey: 'anav.lbl_reports', iconPath: 'M4 21V4m0 0h11l-1.5 4L15 12H4' }, { href: '/admin/grades', labelKey: 'anav.lbl_grades', iconPath: 'M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z' }, { href: '/admin/categories', labelKey: 'anav.lbl_categories', iconPath: 'M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z' }, { href: '/admin/moderation', labelKey: 'anav.lbl_moderation', iconPath: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z' }, @@ -243,7 +243,7 @@ {#if item.href === '/admin/octoguard'} {/if} - {#if item.href === '/admin/reports' && openReports > 0} + {#if item.href === '/admin/octoguard/reports' && openReports > 0} {openReports} {/if} {#if item.soon} diff --git a/nodyx-frontend/src/routes/admin/members/[userId]/+page.svelte b/nodyx-frontend/src/routes/admin/members/[userId]/+page.svelte index 682f6b6e..3d155f39 100644 --- a/nodyx-frontend/src/routes/admin/members/[userId]/+page.svelte +++ b/nodyx-frontend/src/routes/admin/members/[userId]/+page.svelte @@ -120,7 +120,7 @@ {/each} {#if st.reports_open > 0} - {tFn('afile.state_reports', { n: st.reports_open })} + {tFn('afile.state_reports', { n: st.reports_open })} {/if} {#if !st.banned && st.mutes.length === 0 && st.reports_open === 0} {tFn('afile.state_clean')} diff --git a/nodyx-frontend/src/routes/admin/octoguard/+page.svelte b/nodyx-frontend/src/routes/admin/octoguard/+page.svelte index f31d9c57..3f623dab 100644 --- a/nodyx-frontend/src/routes/admin/octoguard/+page.svelte +++ b/nodyx-frontend/src/routes/admin/octoguard/+page.svelte @@ -40,7 +40,7 @@ const kw = rules.find((r) => r.type === 'keywords') if (kw && !(kw.params?.words ?? []).length) out.push({ key: 'kw-empty', kind: 'warn', icon: LEVEL_ICON.warn, text: tFn('og2.tip_words_empty'), meta: tFn('og2.tip_words_empty_meta'), href: `/admin/octoguard/rules#rule-${kw.id}`, hrefLabel: tFn('og2.tip_words_fill') }) else if (kw && !kw.enabled) out.push({ key: 'kw-off', kind: 'acc', icon: LEVEL_ICON.report, text: tFn('og2.tip_words_off'), href: `/admin/octoguard/rules#rule-${kw.id}`, hrefLabel: tFn('og2.see') }) - if (openReports > 0) out.push({ key: 'reports', kind: 'report', icon: LEVEL_ICON.report, text: tFn('og2.tip_reports', { n: openReports }), href: '/admin/reports', hrefLabel: tFn('og2.open') }) + if (openReports > 0) out.push({ key: 'reports', kind: 'report', icon: LEVEL_ICON.report, text: tFn('og2.tip_reports', { n: openReports }), href: '/admin/octoguard/reports', hrefLabel: tFn('og2.open') }) return out.slice(0, 5) }) @@ -120,7 +120,7 @@
{tFn('og2.st_acted')}
{stats.totals?.acted ?? 0}
{tFn('og2.st_acted_sub')}
{tFn('og2.st_observed')}
{stats.totals?.observed ?? 0}
{tFn('og2.st_observed_sub')}
{tFn('og2.st_precision')}
{precision === null ? '·' : `${precision} %`}
{precision === null ? tFn('og2.st_precision_none') : tFn('og2.st_precision_sub', { u: stats.totals?.undone ?? 0, n: stats.totals?.acted ?? 0 })}
-
{tFn('og2.nav_reports')}
{openReports}
+
{tFn('og2.nav_reports')}
{openReports}
@@ -191,7 +191,7 @@
{tFn('og2.nav_sandbox')}{tFn('og2.tile_sandbox')}{tFn('og2.tile_sandbox_go')}
- +
{tFn('og2.nav_reports')}{tFn('og2.tile_reports')}{tFn('og2.tile_reports_go')}
diff --git a/nodyx-frontend/src/routes/admin/octoguard/guide/+page.svelte b/nodyx-frontend/src/routes/admin/octoguard/guide/+page.svelte index 39590c38..e6f0ae65 100644 --- a/nodyx-frontend/src/routes/admin/octoguard/guide/+page.svelte +++ b/nodyx-frontend/src/routes/admin/octoguard/guide/+page.svelte @@ -28,7 +28,7 @@ { id: 'words', go: '/admin/octoguard/rules', points: 4, demo: 'words' }, { id: 'sandbox', go: '/admin/octoguard/sandbox', points: 2, demo: 'sandbox' }, { id: 'log', go: '/admin/octoguard/logs', points: 3, demo: 'journal' }, - { id: 'team', go: '/admin/reports', points: 3 }, + { id: 'team', go: '/admin/octoguard/reports', points: 3 }, { id: 'share', go: '/admin/octoguard/templates', points: 3 }, { id: 'launch', go: '/admin/octoguard/rules', points: 0, demo: 'checklist' }, ] diff --git a/nodyx-frontend/src/routes/admin/octoguard/og.css b/nodyx-frontend/src/routes/admin/octoguard/og.css index d88a78cb..14a2fb5c 100644 --- a/nodyx-frontend/src/routes/admin/octoguard/og.css +++ b/nodyx-frontend/src/routes/admin/octoguard/og.css @@ -8,13 +8,14 @@ .og-page { --og-observe: var(--nx-text-muted); --og-report: var(--nx-info); + --og-hold: var(--nx-hold); --og-warn: var(--nx-warn); --og-delete: var(--nx-bad); --og-soft: color-mix(in srgb, var(--nx-text) 2.5%, var(--nx-surface)); --og-line: color-mix(in srgb, var(--nx-border) 65%, transparent); gap: 1.375rem; } -.og-k-observe { --k: var(--og-observe); } .og-k-report { --k: var(--og-report); } +.og-k-observe { --k: var(--og-observe); } .og-k-report { --k: var(--og-report); } .og-k-hold { --k: var(--og-hold); } .og-k-warn { --k: var(--og-warn); } .og-k-delete { --k: var(--og-delete); } .og-k-ok { --k: var(--nx-ok); } .og-k-acc { --k: var(--nx-header-accent); } .og-tone, .adm-pill.og-tone { color: var(--k); } @@ -51,7 +52,7 @@ .og-switch input:disabled + .og-track { opacity: .5; } /* Les crans : sélecteur segmenté */ -.og-seg { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 3px; padding: 3px; border: 1px solid var(--nx-border); border-radius: 10px; background: var(--og-soft); } +.og-seg { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 3px; padding: 3px; border: 1px solid var(--nx-border); border-radius: 10px; background: var(--og-soft); } .og-seg label { position: relative; padding: 0.375rem 0.125rem 0.3125rem; text-align: center; font-size: 0.75rem; font-weight: 600; color: var(--nx-text-muted); cursor: pointer; border-radius: 7px; display: flex; flex-direction: column; align-items: center; gap: 2px; min-width: 0; } .og-seg label span { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .og-seg label svg { width: 0.95rem; height: 0.95rem; } @@ -71,7 +72,7 @@ .og-legend .og-chev { margin-left: auto; color: var(--nx-text-muted); transition: transform .15s; } .og-legend[open] .og-chev { transform: rotate(90deg); } .og-lgwrap { overflow-x: auto; margin-top: 1rem; } -.og-lg { min-width: 40rem; display: grid; grid-template-columns: 7.5rem repeat(4, minmax(0, 1fr)); column-gap: 0.5rem; } +.og-lg { min-width: 46rem; display: grid; grid-template-columns: 7.5rem repeat(5, minmax(0, 1fr)); column-gap: 0.5rem; } .og-lg > span { padding: 0.625rem 0.75rem; font-size: 0.78rem; line-height: 1.45; } .og-lg .og-lg-h { display: flex; align-items: center; gap: 0.4rem; font-weight: 650; font-size: 0.8125rem; border-radius: 8px; color: var(--k); background: color-mix(in srgb, var(--k) 9%, transparent); box-shadow: inset 0 -2px 0 var(--k); } .og-lg .og-lg-h svg { width: 1rem; height: 1rem; } @@ -80,7 +81,7 @@ .og-lg .og-lg-c b { font-weight: 650; } .og-lg .og-lg-tip { grid-column: 2 / 3; font-size: 0.6875rem; color: var(--nx-header-accent); font-weight: 650; padding-top: 0.25rem; } .og-lg .og-lg-scale { grid-column: 2 / -1; display: flex; align-items: center; gap: 0.625rem; font-size: 0.6875rem; color: var(--nx-text-muted); padding-top: 0.5rem; } -.og-lg .og-lg-scale i { flex: 1; height: 4px; border-radius: 99px; background: linear-gradient(90deg, var(--og-observe), var(--og-report), var(--og-warn), var(--og-delete)); } +.og-lg .og-lg-scale i { flex: 1; height: 4px; border-radius: 99px; background: linear-gradient(90deg, var(--og-observe), var(--og-report), var(--og-hold), var(--og-warn), var(--og-delete)); } /* Navigation par catégorie */ .og-catnav { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 12.5rem), 1fr)); gap: 0.625rem; } @@ -206,3 +207,46 @@ .adm-stats.og-stats4 { grid-template-columns: repeat(auto-fit, minmax(min(100%, 10.5rem), 1fr)); } .og-grid4 { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 11rem), 1fr)); gap: 0.875rem; } .og-drawer-scrim { background: rgb(10 9 7 / .45) !important; } + +/* ─── Signalements : la file, le contenu, la décision (maquette, CDC E4h) ─── + Mesuré sur la largeur réelle de la page (la colonne des membres peut la + réduire) : trois colonnes au large, la décision passe dessous sinon. */ +.og-split-wrap { container-type: inline-size; } +.og-split { display: grid; grid-template-columns: minmax(15rem, 18rem) minmax(0, 1fr) minmax(15rem, 17rem); gap: 1rem; align-items: start; } +@container (max-width: 66rem) { + .og-split { grid-template-columns: minmax(14rem, 17rem) minmax(0, 1fr); } + .og-rp-side { grid-column: 1 / -1; display: grid !important; grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr)); align-items: start; } +} +@container (max-width: 40rem) { .og-split { grid-template-columns: minmax(0, 1fr); } } +.adm-card.og-queue { padding: 0; overflow: hidden; display: flex; flex-direction: column; } +.og-q { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 0.125rem 0.625rem; padding: 0.75rem 0.875rem; border: 0; border-radius: 0; border-bottom: 1px solid var(--og-line); background: transparent; color: var(--nx-text); text-align: left; font: inherit; font-size: 0.8125rem; cursor: pointer; align-items: start; width: 100%; } +.og-q:last-child { border-bottom: 0; } +.og-q > .og-ic { grid-row: span 2; } +.og-q > b { font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.og-q:hover { background: color-mix(in srgb, var(--nx-text) 3%, transparent); } +.og-q[aria-selected='true'] { background: color-mix(in srgb, var(--nx-header-accent) 9%, transparent); box-shadow: inset 2px 0 0 var(--nx-header-accent); } +.og-q:focus-visible { outline: 2px solid var(--nx-header-accent); outline-offset: -2px; } +.og-q-time { font-size: 0.72rem; color: var(--nx-text-muted); font-variant-numeric: tabular-nums; white-space: nowrap; } +.og-q-sub { grid-column: 2 / -1; font-size: 0.75rem; color: var(--nx-text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.og-rp-main { display: flex; flex-direction: column; gap: 1rem; min-width: 0; } +.og-rp-top { display: flex; justify-content: space-between; align-items: flex-start; gap: 0.75rem; flex-wrap: wrap; } +.og-rp-title { font-weight: 600; } +.og-rp-pills { display: flex; flex-wrap: wrap; gap: 0.25rem; } +.og-rp-subject { margin: 0; font-weight: 600; font-size: 0.875rem; } +.og-rp-text { white-space: pre-wrap; font-size: 0.8125rem; line-height: 1.5; max-height: 22rem; overflow: auto; } +.og-rp-who { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.5rem; font-size: 0.8125rem; } +.og-rp-who li { display: flex; flex-wrap: wrap; gap: 0.25rem 0.5rem; align-items: baseline; } +.og-rp-who .og-meta { margin: 0; } +.og-rp-reason { color: var(--nx-text-muted); font-style: italic; font-size: 0.8125rem; margin: 0; } +.og-rp-open { align-self: flex-start; } +.og-rp-side { display: flex; flex-direction: column; gap: 1rem; min-width: 0; } +.og-rp-card { display: flex; flex-direction: column; gap: 0.625rem; } +.og-rp-card > .adm-btn { justify-content: center; } +.og-rp-h { margin: 0; font-size: 0.875rem; font-weight: 600; } +.og-rp-row { display: flex; flex-wrap: wrap; gap: 0.375rem; } +.og-rp-row > .adm-btn { flex: 1; justify-content: center; } +.og-rp-facts { display: flex; flex-wrap: wrap; gap: 0.375rem; } +.og-rp-empty { display: flex; gap: 0.75rem; align-items: center; } +.og-rp-empty p { margin: 0.125rem 0 0; } +p.og-meta { display: flex; align-items: center; gap: 0.375rem; margin: 0; } +p.og-meta svg { width: 0.9rem; height: 0.9rem; flex: none; } diff --git a/nodyx-frontend/src/routes/admin/octoguard/reports/+page.server.ts b/nodyx-frontend/src/routes/admin/octoguard/reports/+page.server.ts index 20bc1097..258434d4 100644 --- a/nodyx-frontend/src/routes/admin/octoguard/reports/+page.server.ts +++ b/nodyx-frontend/src/routes/admin/octoguard/reports/+page.server.ts @@ -1,9 +1,33 @@ -import { redirect } from '@sveltejs/kit' import type { PageServerLoad } from './$types' +import { apiFetch } from '$lib/api' -// La file de signalements a déménagé dans l'administration (Communauté > -// Signalements), ouverte aux modérateurs. Les anciens liens y mènent. -export const load: PageServerLoad = async ({ url }) => { - const status = url.searchParams.get('status') - redirect(301, status ? `/admin/reports?status=${encodeURIComponent(status)}` : '/admin/reports') +// Signalements dans OctoGuard : une seule file pour ceux des membres, ceux +// d'OctoGuard et les contenus retenus à relire (CDC E4h, lots 2 et 3). +const TABS = ['open', 'held', 'done'] as const +export type Tab = (typeof TABS)[number] + +export const load: PageServerLoad = async ({ fetch, cookies, url }) => { + const token = cookies.get('token') + const headers: Record = token ? { Authorization: `Bearer ${token}` } : {} + const asked = url.searchParams.get('tab') ?? 'open' + const tab: Tab = (TABS as readonly string[]).includes(asked) ? (asked as Tab) : 'open' + const get = async (path: string) => { + try { const r = await apiFetch(fetch, path, { headers }); return r.ok ? await r.json() : null } catch { return null } + } + // Les compteurs des onglets viennent toujours, la liste seulement pour l'onglet ouvert + const [reports, held] = await Promise.all([ + get(tab === 'done' ? '/admin/octoguard/reports?limit=200' : '/admin/octoguard/reports?status=open&limit=200'), + get('/admin/octoguard/held'), + ]) + const counts = (reports?.counts ?? {}) as Record + return { + tab, + failed: !reports || !held, + reports: ((reports?.reports ?? []) as Array<{ status: string }>).filter((r) => (tab === 'done' ? r.status !== 'open' : tab === 'open')), + held: tab === 'held' ? (held?.held ?? []) : [], + heldCount: (held?.count as number) ?? 0, + expiryHours: (held?.expiry_hours as number) ?? 72, + openCount: counts.open ?? 0, + doneCount: (counts.actioned ?? 0) + (counts.dismissed ?? 0) + (counts.reviewed ?? 0), + } } diff --git a/nodyx-frontend/src/routes/admin/octoguard/reports/+page.svelte b/nodyx-frontend/src/routes/admin/octoguard/reports/+page.svelte index 09da506a..dd198c13 100644 --- a/nodyx-frontend/src/routes/admin/octoguard/reports/+page.svelte +++ b/nodyx-frontend/src/routes/admin/octoguard/reports/+page.svelte @@ -1 +1,328 @@ - + + +{tFn('og2.nav_reports')} · OctoGuard + +
+
+
+ +
+

{tFn('og2.nav_reports')}

+

{tFn('og2.rp_desc')}

+
+
+ +
+ + {#if data.tab === 'held'} +
{tFn('og2.rp_held_how', { h: data.expiryHours })}
+ {/if} + {#if data.failed}{/if} + {#if error}{/if} + {#if done}

{done}

{/if} + + {#if items.length === 0} +
+ +
+ {tFn(data.tab === 'held' ? 'og2.rp_empty_held' : data.tab === 'open' ? 'areports.empty_open' : 'areports.empty_other')} + {#if data.tab === 'held'}

{tFn('og2.rp_empty_held_sub')}

{/if} + {#if data.tab === 'open' && data.heldCount > 0} +

{tFn(data.heldCount > 1 ? 'og2.rp_held_waiting_n' : 'og2.rp_held_waiting_1', { n: data.heldCount })}

+ {tFn('og2.rp_go_held')} + {/if} +
+
+ {:else} +
+ +
+ {#if data.tab === 'held'} + {#each held as h (h.id)} + + {/each} + {:else} + {#each cases as c (c.key)} + {@const r = c.first} + + {/each} + {/if} +
+ + +
+ {#if selHeld} + {@const h = selHeld} +
+
+
{tFn(`og2.rp_held_title_${h.kind}`, { name: h.author_username, place: heldPlace(h) })}
+
{tFn('og2.rp_held_by', { rule: h.rule_name, at: clock(h.created_at) })}
+
+ {tFn('og2.rp_pending')} +
+
+ {tFn(h.kind === 'post_edit' ? 'og2.rp_new_text' : 'og2.rp_held_text')} + {#if h.payload.title}

{h.payload.title}

{/if} +
{htmlToText(h.payload.content)}
+

{tFn('og2.rp_only_author')}

+
+

{tFn('og2.rp_expires', { left: expiresIn(h.created_at) })}

+ {:else if selCase} + {@const r = selCase.first} +
+
+
{tFn(`areports.type_${r.target_type}`)}{#if r.target_author_username} · {r.target_author_username}{/if}
+
{r.target_label ?? ''} · {clock(r.created_at)}
+
+
+ {#each Object.entries(selCase.categories) as [cat, n] (cat)} + {tFn(`report.cat_${cat}`)}{n > 1 ? ` ×${n}` : ''} + {/each} +
+
+ {#if r.snapshot} +
+ {tFn('areports.snapshot')} +
{r.snapshot}
+
+ {/if} +
+ {tFn('areports.reporters')} +
    + {#each selCase.reports as rep (rep.id)} +
  • + {rep.source === 'octoguard' ? tFn('areports.by_octoguard') : (rep.reporter_username ?? tFn('areports.deleted_account'))} + {tFn(`report.cat_${rep.category ?? 'other'}`)} · {ago(rep.created_at)} + {#if rep.reason}« {rep.reason} »{/if} +
  • + {/each} +
+
+ {#if r.target_link} + {tFn('areports.open_content')} + {/if} + {/if} +
+ + + +
+ {/if} +
diff --git a/nodyx-frontend/src/routes/admin/octoguard/sandbox/+page.svelte b/nodyx-frontend/src/routes/admin/octoguard/sandbox/+page.svelte index 0fe968ac..8178ac6e 100644 --- a/nodyx-frontend/src/routes/admin/octoguard/sandbox/+page.svelte +++ b/nodyx-frontend/src/routes/admin/octoguard/sandbox/+page.svelte @@ -14,11 +14,11 @@ const token = $derived(((page.data as any).token as string | null) ?? null) const focusRule = page.url.searchParams.get('rule') - type Outcome = 'none' | 'observe' | 'report' | 'warn' | 'delete' | 'mute' | 'ban_temp' | 'notify' + type Outcome = 'none' | 'observe' | 'report' | 'hold' | 'warn' | 'delete' | 'mute' | 'ban_temp' | 'notify' type R = { id: string; name: string; type: string; enabled: boolean; applies_here: boolean; immune: boolean; conditions_ok: boolean; testable: boolean; matched: boolean; excerpt: string | null; outcome: Outcome; intended: Outcome } type Result = { system: { blocked: boolean; rule: string | null }; rules: R[]; first: string | null } - const LV: Record = { none: null, observe: 'observe', report: 'report', notify: 'report', warn: 'warn', delete: 'delete', mute: 'delete', ban_temp: 'delete' } + const LV: Record = { none: null, observe: 'observe', report: 'report', notify: 'report', hold: 'hold', warn: 'warn', delete: 'delete', mute: 'delete', ban_temp: 'delete' } let content = $state('') let surface = $state('chat') diff --git a/nodyx-frontend/src/routes/admin/reports/+page.server.ts b/nodyx-frontend/src/routes/admin/reports/+page.server.ts deleted file mode 100644 index d59ed75d..00000000 --- a/nodyx-frontend/src/routes/admin/reports/+page.server.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { fail } from '@sveltejs/kit' -import type { PageServerLoad, Actions } from './$types' -import { apiFetch } from '$lib/api' - -const STATUSES = ['open', 'actioned', 'dismissed', 'reviewed'] as const - -export const load: PageServerLoad = async ({ fetch, parent, url }) => { - const { token } = await parent() - const asked = url.searchParams.get('status') ?? 'open' - const status = (STATUSES as readonly string[]).includes(asked) ? asked : 'open' - const res = await apiFetch(fetch, `/admin/octoguard/reports?status=${status}&limit=200`, { - headers: { Authorization: `Bearer ${token}` }, - }) - const json = res.ok ? await res.json() : { reports: [], counts: {} } - return { reports: json.reports ?? [], counts: json.counts ?? {}, status } -} - -async function patch(fetch: typeof globalThis.fetch, token: string, id: string, body: Record) { - const res = await apiFetch(fetch, `/admin/octoguard/reports/${encodeURIComponent(id)}`, { - method: 'PATCH', - headers: { Authorization: `Bearer ${token}` }, - body: JSON.stringify(body), - }) - if (res.ok) return null - const j = await res.json().catch(() => ({})) - return fail(res.status, { errorKey: j.code === 'RANK_TOO_LOW' ? 'areports.err_rank' : j.code === 'FORBIDDEN' ? 'areports.err_forbidden' : 'areports.err_generic' }) -} - -export const actions: Actions = { - // « Je prends » : toute l'équipe voit qui regarde ce dossier - claim: async ({ fetch, request, cookies }) => { - const form = await request.formData() - return (await patch(fetch, cookies.get('token')!, String(form.get('id')), { claim: true })) ?? { ok: true } - }, - // Clore : sanctionné (avec ou sans suppression du contenu), classé, examiné - resolve: async ({ fetch, request, cookies }) => { - const form = await request.formData() - const status = String(form.get('status')) - if (!['actioned', 'dismissed', 'reviewed'].includes(status)) return fail(400, { errorKey: 'areports.err_generic' }) - const resolution = String(form.get('resolution') ?? '').trim() || undefined - const deleteContent = form.get('delete_content') === '1' - return (await patch(fetch, cookies.get('token')!, String(form.get('id')), { status, resolution, delete_content: deleteContent || undefined })) ?? { ok: true } - }, -} diff --git a/nodyx-frontend/src/routes/admin/reports/+page.svelte b/nodyx-frontend/src/routes/admin/reports/+page.svelte deleted file mode 100644 index 64118569..00000000 --- a/nodyx-frontend/src/routes/admin/reports/+page.svelte +++ /dev/null @@ -1,208 +0,0 @@ - - -{tFn('areports.page_title')} - -
-
-
-

{tFn('areports.title')}

-

{tFn('areports.desc')}

-
-
- - - - {#if errorText} - - {/if} - - {#if cases.length === 0} -
- {data.status === 'open' ? tFn('areports.empty_open') : tFn('areports.empty_other')} -
- {/if} - - {#each cases as c (c.key)} - {@const r = c.first} -
-
-
- -
-
- {tFn(`areports.type_${r.target_type}`)} - {#if r.target_author_username} - · {r.target_author_username} - {/if} - {#if r.target_author_role && r.target_author_role !== 'member'} - {tFn(`amem.role_${r.target_author_role}`)} - {/if} -
-
- {r.target_label ?? ''} - · {c.reports.length > 1 ? tFn('areports.count_many', { n: c.reports.length }) : tFn('areports.count_one')} - · {ago(r.created_at)} -
-
-
-
- {#each Object.entries(c.categories) as [cat, n] (cat)} - - {tFn(`report.cat_${cat}`)}{n > 1 ? ` ×${n}` : ''} - - {/each} -
-
- - {#if r.snapshot} -
-
{tFn('areports.snapshot')}
-
{r.snapshot}
-
- {/if} - -
-

{tFn('areports.reporters')}

-
    - {#each c.reports as rep (rep.id)} -
  • - {(rep as any).source === 'octoguard' ? tFn('areports.by_octoguard') : (rep.reporter_username ?? tFn('areports.deleted_account'))} - {tFn(`report.cat_${rep.category ?? 'other'}`)} · {ago(rep.created_at)} - {#if rep.reason}« {rep.reason} »{/if} -
  • - {/each} -
-
- - {#if data.status === 'open'} -
- {#if r.target_link} - - - {tFn('areports.open_content')} - - {/if} - {#if r.claimed_by_username} - - - {tFn('areports.claimed_by', { name: r.claimed_by_username })} - - {:else} -
- - -
- {/if} -
- -
- - - {#if canDeleteContent && r.target_type !== 'user'} - - {/if} - - - -
- {:else} -

- {tFn(`areports.tab_${data.status}`)} - {#if r.reviewed_by_username}· {tFn('areports.by', { name: r.reviewed_by_username })}{/if} - {#if r.reviewed_at}· {ago(r.reviewed_at)}{/if} - {#if r.resolution}· « {r.resolution} »{/if} -

- {/if} -
- {/each} - -

{tFn('areports.footer')}

-
diff --git a/nodyx-frontend/src/routes/admin/reports/+server.ts b/nodyx-frontend/src/routes/admin/reports/+server.ts new file mode 100644 index 00000000..40e5ac12 --- /dev/null +++ b/nodyx-frontend/src/routes/admin/reports/+server.ts @@ -0,0 +1,9 @@ +import { redirect } from '@sveltejs/kit' +import type { RequestHandler } from './$types' + +// Les signalements vivent désormais dans OctoGuard, avec les contenus retenus +// (une seule file, CDC E4h). L'ancienne adresse y mène, onglet compris. +export const GET: RequestHandler = ({ url }) => { + const status = url.searchParams.get('status') + redirect(307, status && status !== 'open' ? '/admin/octoguard/reports?tab=done' : '/admin/octoguard/reports') +} diff --git a/nodyx-frontend/src/routes/chat/+page.svelte b/nodyx-frontend/src/routes/chat/+page.svelte index 880906d3..160c2ab2 100644 --- a/nodyx-frontend/src/routes/chat/+page.svelte +++ b/nodyx-frontend/src/routes/chat/+page.svelte @@ -26,6 +26,7 @@ import { can } from '$lib/permissions'; import { openReport } from '$lib/report'; import { octoguardMessage } from '$lib/octoguardMessages'; + import { htmlToText as heldText } from '$lib/octoguard/text'; import { isDarkTheme } from '$lib/theme'; import { messagesVisibles, marquerSupprime } from '$lib/chatFil'; import { unreadCountsStore, flashChannelIdStore } from '$lib/unreadStore'; @@ -149,6 +150,21 @@ // Content filter feedback let blockedNotice = $state(null); + // Mes messages retenus par OctoGuard (cran « Retenir ») dans ce salon : moi + // seul les vois, « en attente de validation », jusqu'à la décision. + type HeldMine = { id: string; channel_id: string | null; content: string; created_at: string }; + let heldMine = $state([]); + async function loadHeldMine(channelId: string) { + try { + const { PUBLIC_API_URL } = await import('$app/env/public'); + const res = await fetch(`${PUBLIC_API_URL}/api/v1/reports/held/mine?channel_id=${encodeURIComponent(channelId)}`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (!res.ok) return; + const { held } = await res.json(); + if (selectedChannel?.id === channelId) heldMine = held as HeldMine[]; + } catch { /* rien à montrer */ } + } let blockedTimer: ReturnType | null = null; // Emoji picker @@ -383,7 +399,7 @@ // onDestroy ne retirait pas ces handlers -> à chaque remontage ils s'accumulaient // -> events (messages, notifs vocales join/leave…) déclenchés en double. // On retire d'abord les handlers chat-exclusifs avant de rebrancher. - for (const ev of ['chat:history','chat:message','chat:typing','chat:message_edited','chat:message_deleted','chat:pinned','chat:reaction_update','chat:blocked','chat:rate_limited','voice:channel_update']) sock.off(ev); + for (const ev of ['chat:history','chat:message','chat:typing','chat:message_edited','chat:message_deleted','chat:pinned','chat:reaction_update','chat:blocked','chat:rate_limited','voice:channel_update','octoguard:held','octoguard:held_decided']) sock.off(ev); // 'connect' est PARTAGÉ (voice, store socket) -> on ne retire QUE notre handler, par référence. const sk = sock as unknown as { __chatOnConnect?: () => void }; @@ -395,9 +411,28 @@ sock.on('chat:history', ({ channelId, messages: hist }: { channelId: string; messages: Message[] }) => { if (channelId !== selectedChannel?.id) return; messages = hist.map(normalizeMsg); + heldMine = []; + void loadHeldMine(channelId); scrollToBottom({ force: true }); }); + sock.on('octoguard:held', (h: HeldMine) => { + if (h.channel_id !== selectedChannel?.id) return; + heldMine = [...heldMine.filter((x) => x.id !== h.id), h]; + scrollToBottom({ force: true }); + }); + + sock.on('octoguard:held_decided', ({ id, outcome }: { id: string; outcome: 'approved' | 'refused' | 'expired' }) => { + const was = heldMine.some((x) => x.id === id); + heldMine = heldMine.filter((x) => x.id !== id); + // Approuvé : le vrai message arrive par « chat:message ». Sinon on le dit. + if (was && outcome !== 'approved') { + blockedNotice = tFn(outcome === 'expired' ? 'ogmsg.held_expired' : 'ogmsg.held_refused'); + if (blockedTimer) clearTimeout(blockedTimer); + blockedTimer = setTimeout(() => { blockedNotice = null; }, 8000); + } + }); + sock.on('chat:message', (msg: Message) => { if (msg.channel_id !== selectedChannel?.id) { // Message in a background channel — trigger glow + increment badge @@ -1465,7 +1500,25 @@ {/each} {/each} - {#if messages.length === 0} + {#each heldMine as h (h.id)} +
+ +
+
+ {currentUsername} + + + {tFn('ogmsg.held_pill')} + + {formatTime(h.created_at)} +
+

{heldText(h.content)}

+

{tFn('ogmsg.held_only_you')}

+
+
+ {/each} + + {#if messages.length === 0 && heldMine.length === 0}
@@ -1823,6 +1876,16 @@ {/if}