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" "db:studio": "drizzle-kit studio"
}, },
"dependencies": { "dependencies": {
"@2toad/profanity": "^3.2.0",
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/cors": "latest", "@fastify/cors": "latest",
"@fastify/csrf-protection": "^7.1.0", "@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 { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify";
import type { Ticket, TicketType } from "../types.ts"; import type { Ticket, TicketType } from "../types.ts";
import { TICKET_LIMIT } 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) { async function requireAuth(req: FastifyRequest, reply: FastifyReply) {
if (!req.isAuthenticated) { 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({ const ticket = await req.storage.createTicket({
subject: subject.trim(), subject: filtered.subject,
description, description: filtered.description,
type, type,
userId: req.user?.id, userId: req.user?.id,
}); });

View File

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

View File

@@ -14,6 +14,7 @@
"backend": { "backend": {
"name": "personal-support-ticket-system-backend", "name": "personal-support-ticket-system-backend",
"dependencies": { "dependencies": {
"@2toad/profanity": "^3.2.0",
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/cors": "latest", "@fastify/cors": "latest",
"@fastify/csrf-protection": "^7.1.0", "@fastify/csrf-protection": "^7.1.0",
@@ -59,6 +60,8 @@
}, },
}, },
"packages": { "packages": {
"@2toad/profanity": ["@2toad/profanity@3.2.0", "", {}, "sha512-1wF0F9SXBS8zsn74ZyDKhClSH+5Tupycq2F1x34+/1YKhrq49ejs+imH8/YIS/JwXoj2oUY+KTpoyOxJeNA2mQ=="],
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],

View File

@@ -1,85 +1,115 @@
import type { Ticket, TicketType } from './types' import type { Ticket, TicketType } from "./types";
import { env } from '../env' import { env } from "../env";
const API = env.apiUrl const API = env.apiUrl;
// ─── API error with structured body ──────────────────────────────────────────
export class ApiError extends Error {
readonly status: number;
readonly code: string;
constructor(status: number, code: string, message: string) {
super(message);
this.name = "ApiError";
this.status = status;
this.code = code;
}
}
// ─── Fetch helper ───────────────────────────────────────────────────────────── // ─── Fetch helper ─────────────────────────────────────────────────────────────
async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> { async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(`${API}${path}`, { const res = await fetch(`${API}${path}`, {
...init, ...init,
credentials: 'include', credentials: "include",
headers: { 'Content-Type': 'application/json', ...(init.headers ?? {}) }, headers: { "Content-Type": "application/json", ...(init.headers ?? {}) },
}) });
if (res.status === 401) throw new Error('unauthenticated') if (!res.ok) {
if (!res.ok) throw new Error(`API error ${res.status}`) // Try to parse a structured error body; fall back to a generic message
return res.json() let code = `http_${res.status}`;
let message = `API error ${res.status}`;
try {
const body = await res.json();
if (body?.error) code = body.error;
if (body?.message) message = body.message;
} catch {
/* non-JSON body — keep defaults */
}
throw new ApiError(res.status, code, message);
}
return res.json();
} }
// ─── Local (localStorage) adapter ──────────────────────────────────────────── // ─── Local (localStorage) adapter ────────────────────────────────────────────
const LOCAL_KEY = 'support_tickets' const LOCAL_KEY = "support_tickets";
function localGet(): Ticket[] { function localGet(): Ticket[] {
try { try {
return JSON.parse(localStorage.getItem(LOCAL_KEY) ?? '[]') return JSON.parse(localStorage.getItem(LOCAL_KEY) ?? "[]");
} catch { } catch {
return [] return [];
} }
} }
function localSet(tickets: Ticket[]) { function localSet(tickets: Ticket[]) {
localStorage.setItem(LOCAL_KEY, JSON.stringify(tickets)) localStorage.setItem(LOCAL_KEY, JSON.stringify(tickets));
} }
export const localAdapter = { export const localAdapter = {
getTickets: (): Ticket[] => localGet(), getTickets: (): Ticket[] => localGet(),
createTicket: (data: { subject: string; description: string; type: TicketType }): Ticket => { createTicket: (data: {
subject: string;
description: string;
type: TicketType;
}): Ticket => {
const ticket: Ticket = { const ticket: Ticket = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
userId: null, userId: null,
username: null,
subject: data.subject, subject: data.subject,
description: data.description, description: data.description,
type: data.type, type: data.type,
status: 'open', status: "open",
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
} };
localSet([ticket, ...localGet()]) localSet([ticket, ...localGet()]);
return ticket return ticket;
}, },
updateTicket: (id: string, patch: Partial<Ticket>): Ticket | null => { updateTicket: (id: string, patch: Partial<Ticket>): Ticket | null => {
const tickets = localGet() const tickets = localGet();
const idx = tickets.findIndex(t => t.id === id) const idx = tickets.findIndex((t) => t.id === id);
if (idx === -1) return null if (idx === -1) return null;
tickets[idx] = { ...tickets[idx], ...patch } tickets[idx] = { ...tickets[idx], ...patch };
localSet(tickets) localSet(tickets);
return tickets[idx] return tickets[idx];
}, },
deleteTicket: (id: string): boolean => { deleteTicket: (id: string): boolean => {
const before = localGet() const before = localGet();
const after = before.filter(t => t.id !== id) const after = before.filter((t) => t.id !== id);
localSet(after) localSet(after);
return after.length < before.length return after.length < before.length;
}, },
} };
// ─── Paginated response envelope ───────────────────────────────────────────── // ─── Paginated response envelope ─────────────────────────────────────────────
export interface PaginatedResponse<T> { export interface PaginatedResponse<T> {
data: T[] data: T[];
total: number total: number;
page: number page: number;
pageSize: number pageSize: number;
totalPages: number totalPages: number;
} }
export interface TicketFilters { export interface TicketFilters {
status?: Ticket['status'] status?: Ticket["status"];
type?: TicketType type?: TicketType;
mine?: boolean // restrict to the current user's tickets mine?: boolean; // restrict to the current user's tickets
} }
// ─── Storage API ────────────────────────────────────────────────────────────── // ─── Storage API ──────────────────────────────────────────────────────────────
@@ -88,9 +118,9 @@ export const storage = {
// User's own tickets — API when authenticated, localStorage when guest // User's own tickets — API when authenticated, localStorage when guest
async getTickets(): Promise<Ticket[]> { async getTickets(): Promise<Ticket[]> {
try { try {
return await apiFetch<Ticket[]>('/api/tickets') return await apiFetch<Ticket[]>("/api/tickets");
} catch { } catch {
return localAdapter.getTickets() return localAdapter.getTickets();
} }
}, },
@@ -102,67 +132,79 @@ export const storage = {
filters: TicketFilters = {}, filters: TicketFilters = {},
): Promise<PaginatedResponse<Ticket>> { ): Promise<PaginatedResponse<Ticket>> {
if (!isAuthenticated) { if (!isAuthenticated) {
let all = localAdapter.getTickets() let all = localAdapter.getTickets();
if (filters.status) all = all.filter(t => t.status === filters.status) if (filters.status) all = all.filter((t) => t.status === filters.status);
if (filters.type) all = all.filter(t => t.type === filters.type) if (filters.type) all = all.filter((t) => t.type === filters.type);
const start = (page - 1) * pageSize const start = (page - 1) * pageSize;
return { return {
data: all.slice(start, start + pageSize), data: all.slice(start, start + pageSize),
total: all.length, total: all.length,
page, page,
pageSize, pageSize,
totalPages: Math.max(1, Math.ceil(all.length / pageSize)), totalPages: Math.max(1, Math.ceil(all.length / pageSize)),
} };
} }
const params = new URLSearchParams({ page: String(page) }) const params = new URLSearchParams({ page: String(page) });
if (filters.status) params.set('status', filters.status) if (filters.status) params.set("status", filters.status);
if (filters.type) params.set('type', filters.type) if (filters.type) params.set("type", filters.type);
if (filters.mine) params.set('mine', 'true') if (filters.mine) params.set("mine", "true");
try { try {
return await apiFetch<PaginatedResponse<Ticket>>(`/api/tickets/all?${params}`) return await apiFetch<PaginatedResponse<Ticket>>(
`/api/tickets/all?${params}`,
);
} catch { } catch {
const all = localAdapter.getTickets() const all = localAdapter.getTickets();
const start = (page - 1) * pageSize const start = (page - 1) * pageSize;
return { return {
data: all.slice(start, start + pageSize), data: all.slice(start, start + pageSize),
total: all.length, total: all.length,
page, page,
pageSize, pageSize,
totalPages: Math.max(1, Math.ceil(all.length / pageSize)), totalPages: Math.max(1, Math.ceil(all.length / pageSize)),
} };
} }
}, },
async createTicket(data: { subject: string; description: string; type: TicketType }): Promise<Ticket> { async createTicket(data: {
subject: string;
description: string;
type: TicketType;
}): Promise<Ticket> {
try { try {
return await apiFetch<Ticket>('/api/tickets', { return await apiFetch<Ticket>("/api/tickets", {
method: 'POST', method: "POST",
body: JSON.stringify(data), body: JSON.stringify(data),
}) });
} catch { } catch (err) {
return localAdapter.createTicket(data) // Re-throw structured API errors (e.g. profanity, ticket limit) — don't silently
// fall back to localStorage, as these are intentional rejections from the server.
if (err instanceof ApiError) throw err;
return localAdapter.createTicket(data);
} }
}, },
async updateTicket(id: string, patch: Partial<Ticket>): Promise<Ticket | null> { async updateTicket(
id: string,
patch: Partial<Ticket>,
): Promise<Ticket | null> {
try { try {
return await apiFetch<Ticket>(`/api/tickets/${id}`, { return await apiFetch<Ticket>(`/api/tickets/${id}`, {
method: 'PATCH', method: "PATCH",
body: JSON.stringify(patch), body: JSON.stringify(patch),
}) });
} catch { } catch {
return localAdapter.updateTicket(id, patch) return localAdapter.updateTicket(id, patch);
} }
}, },
async deleteTicket(id: string): Promise<boolean> { async deleteTicket(id: string): Promise<boolean> {
try { try {
await apiFetch(`/api/tickets/${id}`, { method: 'DELETE' }) await apiFetch(`/api/tickets/${id}`, { method: "DELETE" });
return true return true;
} catch { } catch {
return localAdapter.deleteTicket(id) return localAdapter.deleteTicket(id);
} }
}, },
} };

View File

@@ -5,7 +5,7 @@ import { TicketTable } from '../components/tickets/TicketTable.tsx'
import { TicketDetail } from '../components/tickets/TicketDetail.tsx' import { TicketDetail } from '../components/tickets/TicketDetail.tsx'
import { NewTicketForm } from '../components/tickets/NewTicketForm.tsx' import { NewTicketForm } from '../components/tickets/NewTicketForm.tsx'
import { useModal } from '../hooks/useModal.ts' import { useModal } from '../hooks/useModal.ts'
import { storage } from '../lib/storage.ts' import { storage, ApiError } from '../lib/storage.ts'
import type { Ticket } from '../lib/types.ts' import type { Ticket } from '../lib/types.ts'
import { PlusIcon } from '../components/icons/plus.tsx' import { PlusIcon } from '../components/icons/plus.tsx'
@@ -66,6 +66,7 @@ function TicketLimitReached({ onClose, fromServer }: { onClose: () => void; from
export function UserPage({ isAuthenticated }: UserPageProps) { export function UserPage({ isAuthenticated }: UserPageProps) {
const [tickets, setTickets] = useState<Ticket[]>([]) const [tickets, setTickets] = useState<Ticket[]>([])
const [serverLimitHit, setServerLimitHit] = useState(false) const [serverLimitHit, setServerLimitHit] = useState(false)
const [contentError, setContentError] = useState<string | null>(null)
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null) const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null)
const newTicketModal = useModal() const newTicketModal = useModal()
@@ -81,6 +82,7 @@ export function UserPage({ isAuthenticated }: UserPageProps) {
const handleNewClose = () => { const handleNewClose = () => {
newTicketModal.close() newTicketModal.close()
setServerLimitHit(false) setServerLimitHit(false)
setContentError(null)
} }
const handleOpen = (ticket: Ticket) => { const handleOpen = (ticket: Ticket) => {
@@ -103,14 +105,20 @@ export function UserPage({ isAuthenticated }: UserPageProps) {
const handleCreate = async (form: Pick<Ticket, 'subject' | 'description' | 'type'>) => { const handleCreate = async (form: Pick<Ticket, 'subject' | 'description' | 'type'>) => {
if (atLimit) return if (atLimit) return
setContentError(null)
try { try {
const ticket = await storage.createTicket(form) const ticket = await storage.createTicket(form)
setTickets(prev => [ticket, ...prev]) setTickets(prev => [ticket, ...prev])
newTicketModal.close() newTicketModal.close()
} catch (err: any) { } catch (err) {
if (err?.code === 'ticket_limit_reached') { if (err instanceof ApiError) {
setServerLimitHit(true) if (err.code === 'ticket_limit_reached') {
storage.getTickets().then(setTickets) setServerLimitHit(true)
storage.getTickets().then(setTickets)
} else if (err.code === 'profanity') {
// Surface the server's message directly — it says which field was flagged
setContentError(err.message)
}
} }
} }
} }
@@ -150,7 +158,17 @@ export function UserPage({ isAuthenticated }: UserPageProps) {
> >
{showLimitScreen {showLimitScreen
? <TicketLimitReached onClose={handleNewClose} fromServer={serverLimitHit && !atLimit} /> ? <TicketLimitReached onClose={handleNewClose} fromServer={serverLimitHit && !atLimit} />
: <NewTicketForm onSubmit={handleCreate} /> : (
<>
{contentError && (
<div className="mb-4 flex items-start gap-2.5 rounded-lg border border-red-500/30 bg-red-500/10 px-3.5 py-3">
<span className="mt-0.5 text-sm">🚫</span>
<p className="text-xs leading-relaxed text-red-400">{contentError}</p>
</div>
)}
<NewTicketForm onSubmit={handleCreate} />
</>
)
} }
</Modal> </Modal>