diff --git a/backend/package.json b/backend/package.json index 88bfcdc..136221f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/middleware/contentFilter.ts b/backend/src/middleware/contentFilter.ts new file mode 100644 index 0000000..fb13de6 --- /dev/null +++ b/backend/src/middleware/contentFilter.ts @@ -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, + }; +} diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index 8891571..76311ea 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -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, }); diff --git a/backend/src/types.ts b/backend/src/types.ts index a22a100..a287cdd 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -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'] diff --git a/bun.lock b/bun.lock index 98fd114..d0b337a 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "backend": { "name": "personal-support-ticket-system-backend", "dependencies": { + "@2toad/profanity": "^3.2.0", "@fastify/cookie": "^11.0.2", "@fastify/cors": "latest", "@fastify/csrf-protection": "^7.1.0", @@ -59,6 +60,8 @@ }, }, "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/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index f1a7133..cc5e702 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -1,85 +1,115 @@ -import type { Ticket, TicketType } from './types' -import { env } from '../env' +import type { Ticket, TicketType } from "./types"; +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 ───────────────────────────────────────────────────────────── async function apiFetch(path: string, init: RequestInit = {}): Promise { const res = await fetch(`${API}${path}`, { ...init, - credentials: 'include', - headers: { 'Content-Type': 'application/json', ...(init.headers ?? {}) }, - }) - if (res.status === 401) throw new Error('unauthenticated') - if (!res.ok) throw new Error(`API error ${res.status}`) - return res.json() + credentials: "include", + headers: { "Content-Type": "application/json", ...(init.headers ?? {}) }, + }); + if (!res.ok) { + // Try to parse a structured error body; fall back to a generic message + 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 ──────────────────────────────────────────── -const LOCAL_KEY = 'support_tickets' +const LOCAL_KEY = "support_tickets"; function localGet(): Ticket[] { try { - return JSON.parse(localStorage.getItem(LOCAL_KEY) ?? '[]') + return JSON.parse(localStorage.getItem(LOCAL_KEY) ?? "[]"); } catch { - return [] + return []; } } function localSet(tickets: Ticket[]) { - localStorage.setItem(LOCAL_KEY, JSON.stringify(tickets)) + localStorage.setItem(LOCAL_KEY, JSON.stringify(tickets)); } export const localAdapter = { getTickets: (): Ticket[] => localGet(), - createTicket: (data: { subject: string; description: string; type: TicketType }): Ticket => { + createTicket: (data: { + subject: string; + description: string; + type: TicketType; + }): Ticket => { const ticket: Ticket = { id: crypto.randomUUID(), userId: null, + username: null, subject: data.subject, description: data.description, type: data.type, - status: 'open', + status: "open", createdAt: new Date().toISOString(), - } - localSet([ticket, ...localGet()]) - return ticket + }; + localSet([ticket, ...localGet()]); + return ticket; }, updateTicket: (id: string, patch: Partial): Ticket | null => { - const tickets = localGet() - const idx = tickets.findIndex(t => t.id === id) - if (idx === -1) return null - tickets[idx] = { ...tickets[idx], ...patch } - localSet(tickets) - return tickets[idx] + const tickets = localGet(); + const idx = tickets.findIndex((t) => t.id === id); + if (idx === -1) return null; + tickets[idx] = { ...tickets[idx], ...patch }; + localSet(tickets); + return tickets[idx]; }, deleteTicket: (id: string): boolean => { - const before = localGet() - const after = before.filter(t => t.id !== id) - localSet(after) - return after.length < before.length + const before = localGet(); + const after = before.filter((t) => t.id !== id); + localSet(after); + return after.length < before.length; }, -} +}; // ─── Paginated response envelope ───────────────────────────────────────────── export interface PaginatedResponse { - data: T[] - total: number - page: number - pageSize: number - totalPages: number + data: T[]; + total: number; + page: number; + pageSize: number; + totalPages: number; } export interface TicketFilters { - status?: Ticket['status'] - type?: TicketType - mine?: boolean // restrict to the current user's tickets + status?: Ticket["status"]; + type?: TicketType; + mine?: boolean; // restrict to the current user's tickets } // ─── Storage API ────────────────────────────────────────────────────────────── @@ -88,9 +118,9 @@ export const storage = { // User's own tickets — API when authenticated, localStorage when guest async getTickets(): Promise { try { - return await apiFetch('/api/tickets') + return await apiFetch("/api/tickets"); } catch { - return localAdapter.getTickets() + return localAdapter.getTickets(); } }, @@ -102,67 +132,79 @@ export const storage = { filters: TicketFilters = {}, ): Promise> { if (!isAuthenticated) { - let all = localAdapter.getTickets() - if (filters.status) all = all.filter(t => t.status === filters.status) - if (filters.type) all = all.filter(t => t.type === filters.type) - const start = (page - 1) * pageSize + let all = localAdapter.getTickets(); + if (filters.status) all = all.filter((t) => t.status === filters.status); + if (filters.type) all = all.filter((t) => t.type === filters.type); + const start = (page - 1) * pageSize; return { data: all.slice(start, start + pageSize), total: all.length, page, pageSize, totalPages: Math.max(1, Math.ceil(all.length / pageSize)), - } + }; } - const params = new URLSearchParams({ page: String(page) }) - if (filters.status) params.set('status', filters.status) - if (filters.type) params.set('type', filters.type) - if (filters.mine) params.set('mine', 'true') + const params = new URLSearchParams({ page: String(page) }); + if (filters.status) params.set("status", filters.status); + if (filters.type) params.set("type", filters.type); + if (filters.mine) params.set("mine", "true"); try { - return await apiFetch>(`/api/tickets/all?${params}`) + return await apiFetch>( + `/api/tickets/all?${params}`, + ); } catch { - const all = localAdapter.getTickets() - const start = (page - 1) * pageSize + const all = localAdapter.getTickets(); + const start = (page - 1) * pageSize; return { data: all.slice(start, start + pageSize), total: all.length, page, pageSize, totalPages: Math.max(1, Math.ceil(all.length / pageSize)), - } + }; } }, - async createTicket(data: { subject: string; description: string; type: TicketType }): Promise { + async createTicket(data: { + subject: string; + description: string; + type: TicketType; + }): Promise { try { - return await apiFetch('/api/tickets', { - method: 'POST', + return await apiFetch("/api/tickets", { + method: "POST", body: JSON.stringify(data), - }) - } catch { - return localAdapter.createTicket(data) + }); + } catch (err) { + // 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): Promise { + async updateTicket( + id: string, + patch: Partial, + ): Promise { try { return await apiFetch(`/api/tickets/${id}`, { - method: 'PATCH', + method: "PATCH", body: JSON.stringify(patch), - }) + }); } catch { - return localAdapter.updateTicket(id, patch) + return localAdapter.updateTicket(id, patch); } }, async deleteTicket(id: string): Promise { try { - await apiFetch(`/api/tickets/${id}`, { method: 'DELETE' }) - return true + await apiFetch(`/api/tickets/${id}`, { method: "DELETE" }); + return true; } catch { - return localAdapter.deleteTicket(id) + return localAdapter.deleteTicket(id); } }, -} +}; diff --git a/frontend/src/pages/UserPage.tsx b/frontend/src/pages/UserPage.tsx index 9670b83..abd9d31 100644 --- a/frontend/src/pages/UserPage.tsx +++ b/frontend/src/pages/UserPage.tsx @@ -5,7 +5,7 @@ import { TicketTable } from '../components/tickets/TicketTable.tsx' import { TicketDetail } from '../components/tickets/TicketDetail.tsx' import { NewTicketForm } from '../components/tickets/NewTicketForm.tsx' 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 { PlusIcon } from '../components/icons/plus.tsx' @@ -66,6 +66,7 @@ function TicketLimitReached({ onClose, fromServer }: { onClose: () => void; from export function UserPage({ isAuthenticated }: UserPageProps) { const [tickets, setTickets] = useState([]) const [serverLimitHit, setServerLimitHit] = useState(false) + const [contentError, setContentError] = useState(null) const [selectedTicket, setSelectedTicket] = useState(null) const newTicketModal = useModal() @@ -81,6 +82,7 @@ export function UserPage({ isAuthenticated }: UserPageProps) { const handleNewClose = () => { newTicketModal.close() setServerLimitHit(false) + setContentError(null) } const handleOpen = (ticket: Ticket) => { @@ -103,14 +105,20 @@ export function UserPage({ isAuthenticated }: UserPageProps) { const handleCreate = async (form: Pick) => { if (atLimit) return + setContentError(null) try { const ticket = await storage.createTicket(form) setTickets(prev => [ticket, ...prev]) newTicketModal.close() - } catch (err: any) { - if (err?.code === 'ticket_limit_reached') { - setServerLimitHit(true) - storage.getTickets().then(setTickets) + } catch (err) { + if (err instanceof ApiError) { + if (err.code === 'ticket_limit_reached') { + 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 ? - : + : ( + <> + {contentError && ( +
+ 🚫 +

{contentError}

+
+ )} + + + ) }