From b21efcc6d2c6698c87770b39e05cbb38d4c5bf69 Mon Sep 17 00:00:00 2001 From: Maria <145002050+marikolafs@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:26:52 +0200 Subject: [PATCH 01/10] add profanity filter --- package-lock.json | 31 +++++++++++++++ package.json | 1 + src/shared/lib/useProfanityFilter.ts | 57 ++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 src/shared/lib/useProfanityFilter.ts diff --git a/package-lock.json b/package-lock.json index 310211de..40ec922d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@tailwindcss/vite": "^4.2.2", + "leo-profanity": "^1.9.0", "lucide-vue-next": "^1.0.0", "pinia": "^3.0.4", "tailwindcss": "^4.2.2", @@ -3490,6 +3491,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", @@ -3900,6 +3908,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", @@ -5014,6 +5035,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 da2c8941..14cc371c 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ }, "dependencies": { "@tailwindcss/vite": "^4.2.2", + "leo-profanity": "^1.9.0", "lucide-vue-next": "^1.0.0", "pinia": "^3.0.4", "tailwindcss": "^4.2.2", diff --git a/src/shared/lib/useProfanityFilter.ts b/src/shared/lib/useProfanityFilter.ts new file mode 100644 index 00000000..4e1a499e --- /dev/null +++ b/src/shared/lib/useProfanityFilter.ts @@ -0,0 +1,57 @@ +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 +} + +// normalization +function normalize(text: string) { + return text + .toLowerCase() + .replace(/[@4]/g, "a") + .replace(/1/g, "i") + .replace(/3/g, "e") + .replace(/\s+/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) + } + + function sanitize(text: string): string { + if (!hasProfanity(text)) return text + return clean(text) + } + + return { + hasProfanity, + clean, + sanitize, + } +} \ No newline at end of file From 6f06dedcd4e778e2c0ca5c14020464a6a4015683 Mon Sep 17 00:00:00 2001 From: Maria <145002050+marikolafs@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:45:27 +0200 Subject: [PATCH 02/10] change folder name --- src/shared/{lib => composables}/useProfanityFilter.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/shared/{lib => composables}/useProfanityFilter.ts (100%) diff --git a/src/shared/lib/useProfanityFilter.ts b/src/shared/composables/useProfanityFilter.ts similarity index 100% rename from src/shared/lib/useProfanityFilter.ts rename to src/shared/composables/useProfanityFilter.ts From 09c53b6c6e8de052b70d4e91cc9a2aa02eddd12f Mon Sep 17 00:00:00 2001 From: Maria <145002050+marikolafs@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:45:42 +0200 Subject: [PATCH 03/10] censor notebook input --- src/notebook/components/NotebookComponent.vue | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/notebook/components/NotebookComponent.vue b/src/notebook/components/NotebookComponent.vue index b8e5c309..589feb52 100644 --- a/src/notebook/components/NotebookComponent.vue +++ b/src/notebook/components/NotebookComponent.vue @@ -18,9 +18,11 @@ import type { NotebookEntryResponse, NotebookStatus, } from '@/notebook/model/notebook.types' +import { useProfanityFilter } from "@/shared/composables/useProfanityFilter.ts"; const authStore = useAuthStore() const { t } = useI18n() +const { hasProfanity, sanitize } = useProfanityFilter() const props = withDefaults( defineProps<{ @@ -120,8 +122,11 @@ async function createEntry() { return } - const title = newEntryTitle.value.trim() - const content = newEntryContent.value.trim() + let title = newEntryTitle.value.trim() + let content = newEntryContent.value.trim() + + title = sanitize(title) + content = sanitize(content) if (!title || !content) { status.value = 'error' From 207c6c3aedcde19b5ff71fc0860fc12c77349be8 Mon Sep 17 00:00:00 2001 From: Maria <145002050+marikolafs@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:24:11 +0200 Subject: [PATCH 04/10] make filter stricter --- src/shared/composables/useProfanityFilter.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/shared/composables/useProfanityFilter.ts b/src/shared/composables/useProfanityFilter.ts index 4e1a499e..c3c7c021 100644 --- a/src/shared/composables/useProfanityFilter.ts +++ b/src/shared/composables/useProfanityFilter.ts @@ -27,16 +27,25 @@ function normalize(text: string) { .replace(/[@4]/g, "a") .replace(/1/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)) + const normalized = normalize(text) + + if (leoProfanity.check(text) || leoProfanity.check(normalized)) { + return true + } + + const badWords = leoProfanity.list() + + return badWords.some(word => + normalized.includes(word) ) } From bc4570bb0b3255516dd0dbadf7da961097b910c5 Mon Sep 17 00:00:00 2001 From: Maria <145002050+marikolafs@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:24:28 +0200 Subject: [PATCH 05/10] remove unused import --- src/notebook/components/NotebookComponent.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/notebook/components/NotebookComponent.vue b/src/notebook/components/NotebookComponent.vue index 589feb52..5b93fd53 100644 --- a/src/notebook/components/NotebookComponent.vue +++ b/src/notebook/components/NotebookComponent.vue @@ -22,7 +22,7 @@ import { useProfanityFilter } from "@/shared/composables/useProfanityFilter.ts"; const authStore = useAuthStore() const { t } = useI18n() -const { hasProfanity, sanitize } = useProfanityFilter() +const { sanitize } = useProfanityFilter() const props = withDefaults( defineProps<{ From 050b067048cabec90c507fe128b5f0b419ed680d Mon Sep 17 00:00:00 2001 From: Maria <145002050+marikolafs@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:25:50 +0200 Subject: [PATCH 06/10] disallow inappropriate displaynames --- src/locales/en.ts | 1 + src/locales/nb.ts | 1 + src/profile/pages/PupilProfilePage.vue | 14 +++++++++++++- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/locales/en.ts b/src/locales/en.ts index 2c6a254e..41383da4 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -330,6 +330,7 @@ export const en = { namePlaceholder: "Detective name", nameUpdateSuccess: "Display name updated.", nameUpdateError: "Could not update the display name.", + nameProfanityError: "Name cannot contain profanities.", avatarTitle: "Avatar", avatarPlaceholder: "The avatar builder is not fully implemented yet, but this is where avatar editing belongs.", diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 1a1a5b32..7e41d0ec 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -235,6 +235,7 @@ export const nb = { namePlaceholder: "Detektivnavn", nameUpdateSuccess: "Visningsnavnet er oppdatert.", nameUpdateError: "Kunne ikke oppdatere visningsnavnet.", + nameProfanityError: "Navnet kan ikke inneholde banneord.", avatarTitle: "Avatar", avatarPlaceholder: "Avatarbyggeren er ikke fullt implementert ennå, men dette er stedet for avatarredigering.", diff --git a/src/profile/pages/PupilProfilePage.vue b/src/profile/pages/PupilProfilePage.vue index cd46ebf3..1eca826a 100644 --- a/src/profile/pages/PupilProfilePage.vue +++ b/src/profile/pages/PupilProfilePage.vue @@ -5,6 +5,7 @@ import { RouterLink, useRouter } from 'vue-router' import { useAuthStore } from '@/auth/model/auth.store' import AvatarComponent from '@/avatar/components/AvatarComponent.vue' import { requestUpdateCurrentUserProfile } from '@/profile/api/profile.api' +import { useProfanityFilter } from "@/shared/composables/useProfanityFilter.ts"; const { t } = useI18n() const authStore = useAuthStore() @@ -26,6 +27,8 @@ const normalizedDraftDisplayName = computed(() => trimmedDraftDisplayName.value.length > 0 ? trimmedDraftDisplayName.value : defaultDisplayName.value, ) const isNameDirty = computed(() => normalizedDraftDisplayName.value !== savedDisplayName.value) +const { hasProfanity } = useProfanityFilter() +const hasBadWords = computed(() => hasProfanity(normalizedDraftDisplayName.value)) watch( pupilProfile, @@ -90,6 +93,12 @@ async function saveDisplayName() { try { const updatedDisplayName = normalizedDraftDisplayName.value + if (hasProfanity(updatedDisplayName)) { + saveErrorMessage.value = t('pupilProfile.nameProfanityError') + isSavingName.value = false + return + } + await requestUpdateCurrentUserProfile(authStore.authorizationHeader, { displayName: normalizedDraftDisplayName.value, }) @@ -189,7 +198,7 @@ onMounted(() => {
+ {{ t('pupilProfile.nameProfanityError') }} +
{{ saveSuccessMessage }}
From a79be9fca10e2c588a1a851daf8bfcdadcc8b3b4 Mon Sep 17 00:00:00 2001 From: Maria <145002050+marikolafs@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:23:44 +0200 Subject: [PATCH 07/10] add normal profanities to be checked by default --- .../pages/ClassroomModerationPage.vue | 37 ++++++++++++++- .../components/MysterySubmissionForm.vue | 39 +++++++++++---- src/notebook/components/NotebookComponent.vue | 47 ++++++++++++++----- src/profile/pages/PupilProfilePage.vue | 35 +++++++++++++- 4 files changed, 134 insertions(+), 24 deletions(-) diff --git a/src/classrooms/pages/ClassroomModerationPage.vue b/src/classrooms/pages/ClassroomModerationPage.vue index 6be37a44..58fab27d 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() @@ -23,6 +24,8 @@ const newWord = ref('') const editingId = ref