add:content filter

This commit is contained in:
2026-03-09 17:38:42 +09:00
parent 63fea501a1
commit 40448571f0
7 changed files with 256 additions and 76 deletions

View File

@@ -10,6 +10,7 @@
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@2toad/profanity": "^3.2.0",
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "latest",
"@fastify/csrf-protection": "^7.1.0",

View File

@@ -0,0 +1,106 @@
import { Profanity } from "@2toad/profanity";
// ─── Profanity checker ────────────────────────────────────────────────────────
// Whole-word mode avoids false positives like "assassin", "classic", "scunthorpe"
const profanity = new Profanity({ wholeWord: true });
// ─── PII redaction patterns ───────────────────────────────────────────────────
// Inlined from source inspection of @redactpii/node — pure regex, no dependency needed.
// Each entry defines what to match and what label to replace it with.
const PII_PATTERNS: Array<{ pattern: RegExp; label: string }> = [
{
// Standard email addresses
label: "[REDACTED_EMAIL]",
pattern: /\b[a-z0-9][a-z0-9._-]*@[a-z0-9][\w.-]*\.[a-z]{2,}/gi,
},
{
// Obfuscated emails: "user [at] example [dot] com"
label: "[REDACTED_EMAIL]",
pattern:
/\b[a-z0-9][a-z0-9._-]*\s*(?:@|\[at\]|\(at\))\s*[a-z0-9][\w.-]*\s*(?:\.|\[dot\]|\(dot\))\s*[a-z]{2,}\b/gi,
},
{
// US phone numbers: (555) 555-5555, 555-555-5555, +1 555 555 5555, etc.
label: "[REDACTED_PHONE]",
pattern: /\b(?:\+?1[.\-\s]?)?(?:\(?\d{3}\)?[.\-\s]?)?\d{3}[.\-\s]?\d{4}\b/g,
},
{
// Credit card numbers: 16-digit groups with optional separators
label: "[REDACTED_CARD]",
pattern:
/\b\d{4}[ -]?\d{4}[ -]?\d{4}[ -]?\d{4}\b|\b\d{4}[ -]?\d{6}[ -]?\d{4,5}\b/g,
},
{
// US Social Security Numbers: 123-45-6789 / 123.45.6789 / 123 45 6789
label: "[REDACTED_SSN]",
pattern: /\b\d{3}[ -.]\d{2}[ -.]\d{4}\b/g,
},
];
/**
* Replaces all recognised PII patterns in text with their label.
* Patterns are applied in order — obfuscated email runs after standard email
* so partial matches from the first pass don't interfere.
*/
function redactPII(text: string): string {
return PII_PATTERNS.reduce(
(result, { pattern, label }) => result.replace(pattern, label),
text,
);
}
// ─── Public API ───────────────────────────────────────────────────────────────
export interface ContentFilterResult {
ok: true;
subject: string;
description: string;
}
export interface ContentFilterRejection {
ok: false;
reason: "profanity";
message: string;
}
export type ContentFilterOutcome = ContentFilterResult | ContentFilterRejection;
/**
* Runs subject and description through the content filter.
*
* - Profanity in either field → rejected, ticket is not saved
* - PII in description → silently redacted before saving
*
* Subject is not PII-redacted because it is short, user-facing in table views,
* and unlikely to contain structured PII like card numbers or SSNs.
*/
export function filterContent(
subject: string,
description: string,
): ContentFilterOutcome {
if (profanity.exists(subject)) {
return {
ok: false,
reason: "profanity",
message:
"Your ticket subject contains language that is not allowed. Please revise it before submitting.",
};
}
if (profanity.exists(description)) {
return {
ok: false,
reason: "profanity",
message:
"Your ticket description contains language that is not allowed. Please revise it before submitting.",
};
}
return {
ok: true,
subject,
description: description ? redactPII(description) : description,
};
}

View File

@@ -1,8 +1,9 @@
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify";
import type { Ticket, TicketType } from "../types.ts";
import { TICKET_LIMIT } from "../types.ts";
import { filterContent } from "../middleware/contentFilter.ts";
const PAGE_SIZE = 20;
const PAGE_SIZE = 10;
async function requireAuth(req: FastifyRequest, reply: FastifyReply) {
if (!req.isAuthenticated) {
@@ -82,9 +83,18 @@ export const ticketsRouter: FastifyPluginAsync = async (app) => {
}
}
// Run content filter for authenticated users only
const filtered = filterContent(subject.trim(), description);
if (!filtered.ok) {
return reply.status(400).send({
error: filtered.reason,
message: filtered.message,
});
}
const ticket = await req.storage.createTicket({
subject: subject.trim(),
description,
subject: filtered.subject,
description: filtered.description,
type,
userId: req.user?.id,
});

View File

@@ -25,7 +25,7 @@ export interface Ticket {
createdAt: string
}
export const TICKET_LIMIT = 3
export const TICKET_LIMIT = 10
export interface TicketFilters {
status?: Ticket['status']