From cdac0b769dd1b26bea94470eed7146e421d2516f Mon Sep 17 00:00:00 2001 From: Maria <145002050+marikolafs@users.noreply.github.com> Date: Sat, 2 May 2026 16:27:49 +0200 Subject: [PATCH 1/5] Add profanity filter --- package-lock.json | 31 ++++++++++++ package.json | 1 + src/shared/composables/useProfanityFilter.ts | 51 ++++++++++++++++++++ 3 files changed, 83 insertions(+) create mode 100644 src/shared/composables/useProfanityFilter.ts diff --git a/package-lock.json b/package-lock.json index 84a4c3a2..330c0ecf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@tailwindcss/vite": "^4.2.2", "canvas-confetti": "^1.9.4", + "leo-profanity": "^1.9.0", "lucide-vue-next": "^1.0.0", "pinia": "^3.0.4", "tailwindcss": "^4.2.2", @@ -3509,6 +3510,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/french-badwords-list": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/french-badwords-list/-/french-badwords-list-1.0.7.tgz", + "integrity": "sha512-H1ziKs2PJh2+UXZ9oCGJ/rRQpsI9NBykGf2Sc7WaKaj1OnWFuBXfsvANTdRcfVmOghGQaUmRyZ1hJOPbDpy04Q==", + "license": "MIT", + "optional": true + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -3919,6 +3927,19 @@ "json-buffer": "3.0.1" } }, + "node_modules/leo-profanity": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/leo-profanity/-/leo-profanity-1.9.0.tgz", + "integrity": "sha512-vMrrrjsbT+fA5I/1rlBEVT5YjJsw1ASIVF8/xBEdZ6ylsg5AEIBrEvHxwe5XAmfVuTBi7aw2KcO2L3s9ddpKzw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "french-badwords-list": "^1.0.7", + "russian-bad-words": "^0.5.0" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -5033,6 +5054,16 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/russian-bad-words": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/russian-bad-words/-/russian-bad-words-0.5.0.tgz", + "integrity": "sha512-euNvEYki6iYYpkNbeudW+lEMMYGEmN7EBwVF8ezlbv0bZoQpVYB7W10cCeUIGV7Ed50sJynLQ0c559q5iI0ejQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + } + }, "node_modules/sax": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", diff --git a/package.json b/package.json index 4debe6d3..a0f74bbe 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "dependencies": { "@tailwindcss/vite": "^4.2.2", "canvas-confetti": "^1.9.4", + "leo-profanity": "^1.9.0", "lucide-vue-next": "^1.0.0", "pinia": "^3.0.4", "tailwindcss": "^4.2.2", diff --git a/src/shared/composables/useProfanityFilter.ts b/src/shared/composables/useProfanityFilter.ts new file mode 100644 index 00000000..fa02e89b --- /dev/null +++ b/src/shared/composables/useProfanityFilter.ts @@ -0,0 +1,51 @@ +import leoProfanity from "leo-profanity" + +let initialized = false + +function init() { + if (initialized) return + + leoProfanity.clearList() + + leoProfanity.loadDictionary("en") + + leoProfanity.add([ + "faen", + "jævlig", + "dritt", + "helvete", + "idiot", + ]) + + initialized = true +} + +function normalize(text: string) { + return text + .toLowerCase() + .replace(/[@4]/g, 'a') + .replace(/[1!l|]/g, 'i') + .replace(/3/g, 'e') + .replace(/0/g, 'o') + .replace(/\s+/g, '') + .replace(/[^a-zæøå]/g, '') +} +export function useProfanityFilter() { + init() + + function hasProfanity(text: string): boolean { + return ( + leoProfanity.check(text) || + leoProfanity.check(normalize(text)) + ) + } + + function clean(text: string): string { + return leoProfanity.clean(text) + } + + return { + hasProfanity, + clean, + } +} \ No newline at end of file From ccb9383460d9431c02e6e408cd7d083b49a614ff Mon Sep 17 00:00:00 2001 From: Maria <145002050+marikolafs@users.noreply.github.com> Date: Sat, 2 May 2026 16:28:09 +0200 Subject: [PATCH 2/5] Make normal profanities already banned --- .../pages/ClassroomModerationPage.vue | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/src/classrooms/pages/ClassroomModerationPage.vue b/src/classrooms/pages/ClassroomModerationPage.vue index e4a5c843..96935b9b 100644 --- a/src/classrooms/pages/ClassroomModerationPage.vue +++ b/src/classrooms/pages/ClassroomModerationPage.vue @@ -10,6 +10,7 @@ import { requestUpdateTeacherClassroomBannedWord, type ClassroomBannedWordResponse, } from '@/shared/moderation/moderation.api' +import { useProfanityFilter } from '@/shared/composables/useProfanityFilter' const route = useRoute() const { t } = useI18n() @@ -22,6 +23,7 @@ const errorMessage = ref(null) const newWord = ref('') const editingId = ref(null) const editingWord = ref('') +const { hasProfanity } = useProfanityFilter() const authorizationHeader = computed(() => authStore.authorizationHeader) @@ -50,6 +52,10 @@ async function loadBannedWords() { } } +function normalizeWord(word: string) { + return word.trim().toLowerCase() +} + function startEditing(entry: ClassroomBannedWordResponse) { editingId.value = entry.id editingWord.value = entry.word @@ -66,12 +72,24 @@ async function createWord() { return } - const normalizedWord = newWord.value.trim() + const normalizedWord = normalizeWord(newWord.value) + if (!normalizedWord) { errorMessage.value = t('classroomModeration.errors.missingWord') return } + if (hasProfanity(normalizedWord)) { + errorMessage.value = t('classroomModeration.errors.alreadyBlockedGlobally') + return + } + + // avoid duplicates + if (bannedWords.value.some(w => normalizeWord(w.word) === normalizedWord)) { + errorMessage.value = t('classroomModeration.errors.duplicateWord') + return + } + status.value = 'saving' errorMessage.value = null @@ -106,6 +124,19 @@ async function saveWord(entryId: number) { return } + if (hasProfanity(normalizedWord)) { + errorMessage.value = t('classroomModeration.errors.alreadyBlockedGlobally') + return + } + if ( + bannedWords.value.some( + w => w.id !== entryId && normalizeWord(w.word) === normalizedWord + ) + ) { + errorMessage.value = t('classroomModeration.errors.duplicateWord') + return + } + status.value = 'saving' errorMessage.value = null @@ -167,7 +198,7 @@ onMounted(() => {