From fb55c3b7882a199b112af4ab3b8fed80b0118cdd Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 29 Jun 2026 14:10:37 +0000 Subject: [PATCH] Add subject moderation: sanitize, allowlist, blocklist, review queue --- src/lib/moderation.ts | 64 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 src/lib/moderation.ts diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts new file mode 100644 index 0000000..7b2dce1 --- /dev/null +++ b/src/lib/moderation.ts @@ -0,0 +1,64 @@ +/** + * Subject moderation for the "Create" feature. This is a children's app and the subject text feeds an + * image generator, so we are deliberately conservative: sanitize → allowlist (auto-approve) → + * blocklist (reject) → otherwise hold for admin review. + */ + +export type ModerationDecision = + | { decision: "allowlist"; subject: string } + | { decision: "review"; subject: string } + | { decision: "blocked"; subject: string; reason: string }; + +/** Clean raw input to a single short, plain phrase. Returns null if nothing usable remains. */ +export function sanitizeSubject(raw: string): string | null { + if (typeof raw !== "string") return null; + const cleaned = raw + .replace(/[\r\n\t]+/g, " ") + .replace(/[^a-zA-Z\- ]+/g, "") // letters, spaces, hyphens only — strips digits/punctuation/prompt tokens + .replace(/\s+/g, " ") + .trim() + .slice(0, 30); + if (cleaned.length < 2) return null; + // Title-case for display ("zebra" -> "Zebra"). + return cleaned.replace(/\b\w/g, (c) => c.toUpperCase()); +} + +// Curated safe subjects (lowercase). Auto-approved. Extend freely. +const ALLOWLIST = new Set([ + // animals + "cat", "dog", "puppy", "kitten", "rabbit", "bunny", "fox", "bear", "panda", "lion", "tiger", + "elephant", "giraffe", "zebra", "monkey", "deer", "horse", "pony", "cow", "pig", "sheep", "goat", + "duck", "chicken", "owl", "bird", "penguin", "fish", "shark", "whale", "dolphin", "octopus", "crab", + "turtle", "frog", "snail", "bee", "butterfly", "ladybug", "snake", "dinosaur", "dragon", "unicorn", + "hedgehog", "squirrel", "mouse", "koala", "kangaroo", "crocodile", "leopard", "snail", "starfish", + // nature + "tree", "flower", "rose", "sunflower", "leaf", "mushroom", "cactus", "cloud", "sun", "moon", "star", + "rainbow", "mountain", "apple", "banana", "strawberry", "cherry", "grapes", "carrot", "pumpkin", + // objects / vehicles / fun + "house", "castle", "boat", "sailboat", "car", "truck", "train", "bus", "plane", "rocket", "robot", + "balloon", "kite", "umbrella", "cup", "mug", "hat", "shirt", "dress", "sock", "shoe", "bowtie", + "bow tie", "gift", "present", "ball", "football", "basketball", "trophy", "crown", "heart", "star", + "square", "triangle", "circle", "cake", "cupcake", "cookie", "ice cream", "lollipop", "snowman", + "teddy bear", "ghost", "pumpkin", "guitar", "drum", "book", "pencil", "key", "clock", "lamp", +]); + +// Obvious unsafe terms (substring match, after sanitize). Backstop, not exhaustive. +const BLOCKLIST: string[] = [ + "gun", "knife", "weapon", "sword", "blood", "gore", "kill", "dead", "death", "corpse", "drug", + "beer", "wine", "alcohol", "cigarette", "smoke", "nude", "naked", "sex", "sexy", "porn", "kiss", + "hate", "nazi", "racist", "suicide", "hang", "noose", "bomb", "grenade", "war", "shoot", "knive", +]; + +export function moderateSubject(raw: string): ModerationDecision { + const subject = sanitizeSubject(raw); + if (!subject) return { decision: "blocked", subject: "", reason: "empty" }; + const lower = subject.toLowerCase(); + + const bad = BLOCKLIST.find((w) => lower.includes(w)); + if (bad) return { decision: "blocked", subject, reason: `term "${bad}"` }; + + if (ALLOWLIST.has(lower)) return { decision: "allowlist", subject }; + + // Not obviously safe and not obviously bad → let an admin decide. + return { decision: "review", subject }; +}