From dda54f00a14e4b4fe92a90da6dfda892e32a4da0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20Zatorski?= Date: Mon, 28 Sep 2026 09:48:39 +0200 Subject: [PATCH] Moderacja wyrazow: slownik i wstrzymywanie wiadomosci do akceptacji Wiadomosc czatu i pytanie uczestnika przechodza przez slownik przed publikacja. Trafienie wstrzymuje je (status NEW, moderation_hit), widzi je tylko panel prowadzenia z przyciskami Opublikuj / Odrzuc, a autor dostaje informacje, ze wiadomosc czeka. Wiadomosci prowadzacych nie sa filtrowane. Normalizacja rozpoznaje ogonki, wielkie litery, cyfry zamiast liter, powtorzenia i rozstrzelenia. Slownik startowy w V31: wulgaryzmy, obelgi, tresci zwiazane z pedofilia. WordFilterTest czyta slownik z migracji i sprawdza obie strony, takze slownictwo wodociagowe. Ekran "Moderacja wyrazow" w menu: lista z wyszukiwaniem i kategoriami, dodawanie, edycja, wylaczanie, usuwanie z potwierdzeniem, sprawdzanie tekstu. Dostep ADMIN i MODERATOR. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 26 ++ .../softspm/webinar/domain/Interaction.java | 8 + .../webinar/domain/ModerationCategory.java | 11 + .../webinar/domain/ModerationMatchMode.java | 16 + .../webinar/domain/ModerationWord.java | 72 +++ .../InteractionAdminController.java | 11 +- .../interaction/InteractionService.java | 31 +- .../InteractionStompController.java | 9 + .../interaction/ModeratorInteractionView.java | 7 +- .../moderation/ModerationWordController.java | 95 ++++ .../moderation/ModerationWordService.java | 200 ++++++++ .../webinar/moderation/WordFilter.java | 183 ++++++++ .../repository/ModerationWordRepository.java | 34 ++ .../db/migration/V31__moderation_words.sql | 129 ++++++ .../webinar/moderation/WordFilterTest.java | 124 +++++ frontend/src/App.tsx | 7 + frontend/src/components/Icon.tsx | 2 + frontend/src/lib/navigation.ts | 2 + frontend/src/lib/useInteractions.ts | 2 + .../pages/moderation/ModerationWordsPage.tsx | 436 ++++++++++++++++++ .../src/pages/webinars/HostConsolePage.tsx | 62 ++- .../src/pages/webinars/WebinarPreviewPage.tsx | 6 + 22 files changed, 1457 insertions(+), 16 deletions(-) create mode 100644 backend/src/main/java/com/softspm/webinar/domain/ModerationCategory.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/ModerationMatchMode.java create mode 100644 backend/src/main/java/com/softspm/webinar/domain/ModerationWord.java create mode 100644 backend/src/main/java/com/softspm/webinar/moderation/ModerationWordController.java create mode 100644 backend/src/main/java/com/softspm/webinar/moderation/ModerationWordService.java create mode 100644 backend/src/main/java/com/softspm/webinar/moderation/WordFilter.java create mode 100644 backend/src/main/java/com/softspm/webinar/repository/ModerationWordRepository.java create mode 100644 backend/src/main/resources/db/migration/V31__moderation_words.sql create mode 100644 backend/src/test/java/com/softspm/webinar/moderation/WordFilterTest.java create mode 100644 frontend/src/pages/moderation/ModerationWordsPage.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 1d94701..39e81e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1683,6 +1683,32 @@ Tabela `speak_requests` (V17), kolejka w panelu prowadzenia, temat WebSocket czekajacy piec minut widzi, jak mowi ktos zgloszony przed chwila. - Pilnuje tego `SpeakRequestTest`, w tym dziesiec rownoczesnych zgloszen. +### Moderacja wyrazow + +Tabela `moderation_words` (V31), ekran `/moderacja-wyrazow`, endpointy +`/admin/moderation-words/**`, filtr `WordFilter`. + +- **Trafienie WSTRZYMUJE, nigdy nie kasuje.** Wiadomosc czatu albo pytanie + uczestnika z trafieniem dostaje status NEW i `moderation_hit`, widzi je tylko + panel prowadzenia, a prowadzacy publikuje albo odrzuca. Lista slow nie rozumie + kontekstu, wiec falszywe trafienie ma kosztowac jedno klikniecie. +- **Filtr dziala na serwerze, przed publikacja** (`InteractionService.postChat` + i `postQuestion`). Wiadomosci prowadzacych nie sa filtrowane. +- **Wiadomosc i wpis przechodza przez TA SAMA normalizacje**: bez ogonkow i + wielkich liter, cyfry na litery, powtorzenia zwiniete, rozstrzelenia sklejone. + Wpis podaje sie w zwyklej pisowni. Nie dopisywac wariantow typu "kurw4". +- **Tryb CONTAINS tylko dla rdzeni, ktore nie wystepuja w zwyklych slowach.** + "dup" jako fragment trafialby w "duplikat", "ciot" w "ciotka": takie wpisy sa + WORD. `WordFilterTest` czyta slownik wprost z migracji i sprawdza obie strony, + takze slownictwo wodociagowe; sprawdzony w obie strony. +- **Slownik w pamieci, uniewazniany PO zatwierdzeniu zmiany.** Uniewaznienie + w trakcie transakcji pozwalaloby rownoleglej wiadomosci wczytac stara wersje + na stale. +- **Awaria filtru przepuszcza wiadomosc** i loguje blad: czat, ktory przestaje + dzialac przez filtr, jest gorszy niz chwila bez filtru. +- `fullChatHistory` zawiera NEW, bo tam leza wiadomosci wstrzymane. Publiczne + `chatHistory` nadal zwraca wylacznie APPROVED. + ### Obecni na sali w panelu prowadzenia Karta "Na sali" (`RoomParticipants`), endpointy `/admin/webinars/{id}/room/participants`. diff --git a/backend/src/main/java/com/softspm/webinar/domain/Interaction.java b/backend/src/main/java/com/softspm/webinar/domain/Interaction.java index a388166..cd2bc9a 100644 --- a/backend/src/main/java/com/softspm/webinar/domain/Interaction.java +++ b/backend/src/main/java/com/softspm/webinar/domain/Interaction.java @@ -112,6 +112,14 @@ public class Interaction { @JoinColumn(name = "source_banner_id") private Banner sourceBanner; + /** + * Wpis slownika moderacji, ktory wstrzymal wiadomosc. Null, gdy przeszla + * bez trafienia. Tekst, nie klucz obcy: wpis moze zniknac ze slownika, + * a panel ma dalej pokazac, dlaczego wiadomosc zatrzymano. + */ + @Column(name = "moderation_hit", length = 100) + private String moderationHit; + @Column(name = "created_at", nullable = false) private OffsetDateTime createdAt; diff --git a/backend/src/main/java/com/softspm/webinar/domain/ModerationCategory.java b/backend/src/main/java/com/softspm/webinar/domain/ModerationCategory.java new file mode 100644 index 0000000..310c928 --- /dev/null +++ b/backend/src/main/java/com/softspm/webinar/domain/ModerationCategory.java @@ -0,0 +1,11 @@ +package com.softspm.webinar.domain; + +/** Kategoria wpisu w slowniku moderacji. Sluzy do opisu w panelu i filtrowania listy. */ +public enum ModerationCategory { + /** Wulgaryzm. */ + VULGAR, + /** Obelga, takze na tle pochodzenia czy orientacji. */ + INSULT, + /** Tresci zwiazane z pedofilia i wykorzystywaniem dzieci. */ + CHILD_ABUSE +} diff --git a/backend/src/main/java/com/softspm/webinar/domain/ModerationMatchMode.java b/backend/src/main/java/com/softspm/webinar/domain/ModerationMatchMode.java new file mode 100644 index 0000000..f6bbc7b --- /dev/null +++ b/backend/src/main/java/com/softspm/webinar/domain/ModerationMatchMode.java @@ -0,0 +1,16 @@ +package com.softspm.webinar.domain; + +/** + * Sposob dopasowania wpisu slownika do slowa w wiadomosci. + * + * Wpis z odstepem jest fraza i szukany jest w calym tekscie, niezaleznie od + * wybranego trybu. + */ +public enum ModerationMatchMode { + /** Cale slowo: "dupa" trafia w "dupa", ale nie w "duplikat". */ + WORD, + /** Poczatek slowa, czyli wszystkie odmiany: "debil" trafia w "debilu". */ + PREFIX, + /** Fragment slowa, takze w zlozeniach: "jeb" trafia w "zajebisty". */ + CONTAINS +} diff --git a/backend/src/main/java/com/softspm/webinar/domain/ModerationWord.java b/backend/src/main/java/com/softspm/webinar/domain/ModerationWord.java new file mode 100644 index 0000000..425a0c5 --- /dev/null +++ b/backend/src/main/java/com/softspm/webinar/domain/ModerationWord.java @@ -0,0 +1,72 @@ +package com.softspm.webinar.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.OffsetDateTime; + +/** + * Wpis slownika moderacji czatu i pytan. + * + * `phrase` jest w zwyklej pisowni, tak jak wpisal ja administrator. Postac + * znormalizowana liczy `WordFilter` przy wczytaniu slownika, a nie baza: ta + * sama funkcja normalizuje wiadomosc, wiec obie strony nie moga sie rozjechac. + */ +@Entity +@Table(name = "moderation_words") +@Getter +@Setter +@NoArgsConstructor +public class ModerationWord { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "phrase", nullable = false, length = 100) + private String phrase; + + @Enumerated(EnumType.STRING) + @Column(name = "category", nullable = false, length = 20) + private ModerationCategory category; + + @Enumerated(EnumType.STRING) + @Column(name = "match_mode", nullable = false, length = 20) + private ModerationMatchMode matchMode; + + @Column(name = "active", nullable = false) + private boolean active = true; + + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + @PrePersist + void onCreate() { + OffsetDateTime now = OffsetDateTime.now(); + if (this.createdAt == null) { + this.createdAt = now; + } + if (this.updatedAt == null) { + this.updatedAt = now; + } + } + + @PreUpdate + void onUpdate() { + this.updatedAt = OffsetDateTime.now(); + } +} diff --git a/backend/src/main/java/com/softspm/webinar/interaction/InteractionAdminController.java b/backend/src/main/java/com/softspm/webinar/interaction/InteractionAdminController.java index 283594a..d00473a 100644 --- a/backend/src/main/java/com/softspm/webinar/interaction/InteractionAdminController.java +++ b/backend/src/main/java/com/softspm/webinar/interaction/InteractionAdminController.java @@ -108,12 +108,17 @@ public class InteractionAdminController { } @PostMapping("/{interactionId}/approve") - @Operation(summary = "Zatwierdzenie pytania, po ktorym staje sie publiczne") + @Operation(summary = "Zatwierdzenie pytania albo wstrzymanej wiadomosci, po ktorym staje sie publiczne") public InteractionView approve(@PathVariable Long id, @PathVariable Long interactionId) { - interactions.setStatus(interactionId, InteractionStatus.APPROVED); + Interaction interaction = interactions.setStatus(interactionId, InteractionStatus.APPROVED); InteractionView view = interactions.view(interactionId); - broadcaster.questionPublished(id, view); + // wiadomosc wstrzymana przez slownik idzie na czat, pytanie do kolejki + if (interaction.getType() == InteractionType.CHAT) { + broadcaster.chatPosted(id, view); + } else { + broadcaster.questionPublished(id, view); + } broadcaster.moderation(id, "APPROVED", interactions.moderatorView(interactionId)); return view; } diff --git a/backend/src/main/java/com/softspm/webinar/interaction/InteractionService.java b/backend/src/main/java/com/softspm/webinar/interaction/InteractionService.java index 44db458..acc5b5f 100644 --- a/backend/src/main/java/com/softspm/webinar/interaction/InteractionService.java +++ b/backend/src/main/java/com/softspm/webinar/interaction/InteractionService.java @@ -1,5 +1,7 @@ package com.softspm.webinar.interaction; +import com.softspm.webinar.moderation.ModerationWordService; +import com.softspm.webinar.moderation.WordFilter; import com.softspm.webinar.content.PlainTextSanitizer; import com.softspm.webinar.domain.ChatMode; import com.softspm.webinar.domain.Interaction; @@ -48,6 +50,7 @@ public class InteractionService { private final UserRepository userRepository; private final PlainTextSanitizer sanitizer; private final SettingsService settings; + private final ModerationWordService moderation; public InteractionService(InteractionRepository interactionRepository, InteractionVoteRepository voteRepository, @@ -56,7 +59,9 @@ public class InteractionService { WebinarSpeakerRepository speakerRepository, UserRepository userRepository, PlainTextSanitizer sanitizer, - SettingsService settings) { + SettingsService settings, + ModerationWordService moderation) { + this.moderation = moderation; this.interactionRepository = interactionRepository; this.voteRepository = voteRepository; this.webinarRepository = webinarRepository; @@ -70,8 +75,10 @@ public class InteractionService { /** * Nowa wiadomosc na czacie. * - * Czat nie ma moderacji wstepnej, wiec wiadomosc od razu dostaje status - * APPROVED. Moderator moze ja pozniej usunac, co zmienia status na REJECTED. + * Wiadomosc uczestnika przechodzi przez slownik moderacji PRZED publikacja. + * Bez trafienia dostaje od razu APPROVED; z trafieniem czeka jako NEW, + * widoczna wylacznie w panelu prowadzenia, az prowadzacy ja opublikuje albo + * odrzuci. Wiadomosci prowadzacych nie sa filtrowane. */ @Transactional public Interaction postChat(InteractionPrincipal author, String rawContent) { @@ -107,7 +114,13 @@ public class InteractionService { } Interaction chat = newInteraction(webinar, author, InteractionType.CHAT, content); - chat.setStatus(InteractionStatus.APPROVED); + WordFilter.Hit hit = author.speaker() ? null : moderation.check(content); + if (hit != null) { + chat.setStatus(InteractionStatus.NEW); + chat.setModerationHit(hit.phrase()); + } else { + chat.setStatus(InteractionStatus.APPROVED); + } return interactionRepository.save(chat); } @@ -128,7 +141,12 @@ public class InteractionService { } Interaction question = newInteraction(webinar, author, InteractionType.QUESTION, content); - question.setStatus(webinar.isQuestionsRequireApproval() + // trafienie w slowniku wstrzymuje pytanie takze przy wylaczonej moderacji + WordFilter.Hit hit = author.speaker() ? null : moderation.check(content); + if (hit != null) { + question.setModerationHit(hit.phrase()); + } + question.setStatus(webinar.isQuestionsRequireApproval() || hit != null ? InteractionStatus.NEW : InteractionStatus.APPROVED); return interactionRepository.save(question); @@ -211,7 +229,8 @@ public class InteractionService { public List fullChatHistory(Long webinarId) { return interactionRepository.findByWebinarIdAndTypeAndStatusInOrderByCreatedAtAsc( webinarId, InteractionType.CHAT, - List.of(InteractionStatus.APPROVED, InteractionStatus.REJECTED)); + // NEW to wiadomosci wstrzymane przez slownik, czekajace na decyzje + List.of(InteractionStatus.NEW, InteractionStatus.APPROVED, InteractionStatus.REJECTED)); } /** diff --git a/backend/src/main/java/com/softspm/webinar/interaction/InteractionStompController.java b/backend/src/main/java/com/softspm/webinar/interaction/InteractionStompController.java index fa7e46c..9b9d1ee 100644 --- a/backend/src/main/java/com/softspm/webinar/interaction/InteractionStompController.java +++ b/backend/src/main/java/com/softspm/webinar/interaction/InteractionStompController.java @@ -60,6 +60,15 @@ public class InteractionStompController { public void chat(@Payload MessagePayload payload, Principal principal) { InteractionPrincipal author = require(principal); Interaction chat = interactions.postChat(author, payload.content()); + + if (chat.getStatus() == InteractionStatus.NEW) { + // wstrzymana przez slownik: widzi ja tylko prowadzacy, a autor + // dostaje informacje, ze wiadomosc nie zniknela, tylko czeka + broadcaster.moderation(author.webinarId(), "CHAT_HELD", + interactions.moderatorView(chat.getId())); + throw new ValidationException("Twoja wiadomość czeka na akceptację prowadzącego."); + } + InteractionView view = interactions.view(chat.getId()); broadcaster.chatPosted(author.webinarId(), view); // panel dostaje widok z identyfikatorem autora, zeby dalo sie go wyciszyc diff --git a/backend/src/main/java/com/softspm/webinar/interaction/ModeratorInteractionView.java b/backend/src/main/java/com/softspm/webinar/interaction/ModeratorInteractionView.java index b8f2a6f..8188bd9 100644 --- a/backend/src/main/java/com/softspm/webinar/interaction/ModeratorInteractionView.java +++ b/backend/src/main/java/com/softspm/webinar/interaction/ModeratorInteractionView.java @@ -35,7 +35,9 @@ public record ModeratorInteractionView( Long authorRegistrationId, boolean authorChatMuted, boolean authorRoomBanned, - OffsetDateTime createdAt) { + OffsetDateTime createdAt, + /** Slowo ze slownika moderacji, ktore wstrzymalo wiadomosc; null bez trafienia. */ + String moderationHit) { /** * @param muted czy autor jest wyciszony na czacie @@ -59,6 +61,7 @@ public record ModeratorInteractionView( regId, muted, banned, - interaction.getCreatedAt()); + interaction.getCreatedAt(), + interaction.getModerationHit()); } } diff --git a/backend/src/main/java/com/softspm/webinar/moderation/ModerationWordController.java b/backend/src/main/java/com/softspm/webinar/moderation/ModerationWordController.java new file mode 100644 index 0000000..aa173c2 --- /dev/null +++ b/backend/src/main/java/com/softspm/webinar/moderation/ModerationWordController.java @@ -0,0 +1,95 @@ +package com.softspm.webinar.moderation; + +import com.softspm.webinar.domain.ModerationCategory; +import com.softspm.webinar.domain.ModerationMatchMode; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import org.springframework.data.domain.Page; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** + * Slownik moderacji w panelu. + * + * Pod /admin, wiec dostep maja ADMIN i MODERATOR: prowadzacy, ktory w trakcie + * spotkania zauwazy nowe okreslenie, ma je dopisac od razu, bez szukania + * administratora. + */ +@RestController +@RequestMapping("/admin/moderation-words") +@Tag(name = "Moderacja wyrazow", description = "Slownik slow wstrzymujacych wiadomosci do akceptacji") +public class ModerationWordController { + + private final ModerationWordService service; + + public ModerationWordController(ModerationWordService service) { + this.service = service; + } + + @GetMapping + @Operation(summary = "Lista wpisow, stronicowana, z wyszukiwaniem i filtrem kategorii") + public Page list(@RequestParam(required = false) String q, + @RequestParam(required = false) ModerationCategory category, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "25") int size) { + return service.list(q, category, page, Math.min(size, 100)); + } + + @PostMapping + @Operation(summary = "Dodanie wpisu") + public ModerationWordService.WordView create(@Valid @RequestBody WordBody body) { + return service.create(body.toRequest()); + } + + @PutMapping("/{id}") + @Operation(summary = "Zmiana wpisu") + public ModerationWordService.WordView update(@PathVariable Long id, @Valid @RequestBody WordBody body) { + return service.update(id, body.toRequest()); + } + + @DeleteMapping("/{id}") + @Operation(summary = "Usuniecie wpisu") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@PathVariable Long id) { + service.delete(id); + } + + @PostMapping("/check") + @Operation(summary = "Sprawdzenie, czy tekst zostalby wstrzymany") + public ModerationWordService.CheckResult check(@Valid @RequestBody CheckBody body) { + return service.test(body.text()); + } + + public record WordBody( + @NotBlank(message = "Wpisz słowo albo frazę") + @Size(max = 100, message = "Wpis może mieć najwyżej 100 znaków") + String phrase, + @NotNull(message = "Wybierz kategorię") + ModerationCategory category, + @NotNull(message = "Wybierz sposób dopasowania") + ModerationMatchMode matchMode, + Boolean active) { + + ModerationWordService.WordRequest toRequest() { + return new ModerationWordService.WordRequest(phrase, category, matchMode, active); + } + } + + public record CheckBody( + @Size(max = 2000, message = "Tekst do sprawdzenia może mieć najwyżej 2000 znaków") + String text) { + } +} diff --git a/backend/src/main/java/com/softspm/webinar/moderation/ModerationWordService.java b/backend/src/main/java/com/softspm/webinar/moderation/ModerationWordService.java new file mode 100644 index 0000000..c830d00 --- /dev/null +++ b/backend/src/main/java/com/softspm/webinar/moderation/ModerationWordService.java @@ -0,0 +1,200 @@ +package com.softspm.webinar.moderation; + +import com.softspm.webinar.domain.ModerationCategory; +import com.softspm.webinar.domain.ModerationMatchMode; +import com.softspm.webinar.domain.ModerationWord; +import com.softspm.webinar.repository.ModerationWordRepository; +import com.softspm.webinar.web.NotFoundException; +import com.softspm.webinar.web.ValidationException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.time.OffsetDateTime; +import java.util.List; + +/** + * Slownik moderacji i sprawdzanie tresci przed publikacja. + * + * SLOWNIK W PAMIECI + * --------------------------------------------------------------------------- + * Filtr sprawdza kazda wiadomosc na czacie, wiec nie moze pytac bazy za kazdym + * razem: przy dwustu piszacych osobach to setki zapytan na minute o dane, + * ktore zmieniaja sie raz na tydzien. Slownik jest wczytywany raz, znormalizowany + * i trzymany w pamieci, a kazda zmiana w panelu go uniewaznia. + * + * BLAD FILTRU NIE BLOKUJE CZATU + * --------------------------------------------------------------------------- + * Gdyby wczytanie slownika sie nie powiodlo, wiadomosc przechodzi bez + * sprawdzenia, a blad trafia do logu. Czat, ktory przestaje dzialac przez + * awarie filtru, jest gorszy niz czat bez filtru przez chwile. + */ +@Service +public class ModerationWordService { + + private static final Logger log = LoggerFactory.getLogger(ModerationWordService.class); + + private final ModerationWordRepository repository; + + /** Slownik po normalizacji. Null oznacza "do wczytania przy najblizszym uzyciu". */ + private volatile List entries; + + public ModerationWordService(ModerationWordRepository repository) { + this.repository = repository; + } + + /** + * Sprawdzenie tresci przed publikacja. + * + * @return trafienie albo null, gdy tresc moze isc od razu + */ + public WordFilter.Hit check(String text) { + try { + return WordFilter.find(text, entries()); + } catch (Exception e) { + log.warn("Moderacja: sprawdzenie tresci nieudane, wiadomosc przechodzi bez filtru: {}", + e.getMessage()); + return null; + } + } + + private List entries() { + List current = entries; + if (current == null) { + current = repository.findByActiveTrue().stream() + .map(w -> WordFilter.Entry.of(w.getPhrase(), w.getCategory(), w.getMatchMode())) + .toList(); + entries = current; + } + return current; + } + + /** + * Uniewaznienie slownika PO zatwierdzeniu zmiany. + * + * Uniewaznienie w trakcie transakcji pozwalaloby rownoleglej wiadomosci + * wczytac jeszcze stara wersje i zostawic ja w pamieci az do nastepnej + * zmiany w panelu, czyli potencjalnie na tygodnie. + */ + private void invalidate() { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + entries = null; + } + }); + } else { + entries = null; + } + } + + @Transactional(readOnly = true) + public Page list(String query, ModerationCategory category, int page, int size) { + String q = query == null || query.isBlank() ? null : query.trim(); + return repository.search(q, category, + PageRequest.of(Math.max(0, page), Math.max(1, Math.min(size, 100)), + Sort.by("phrase").ascending())) + .map(WordView::of); + } + + @Transactional + public WordView create(WordRequest request) { + ModerationWord word = new ModerationWord(); + apply(word, request); + WordView view = save(word); + invalidate(); + log.info("Moderacja: dodano wpis {}", view.id()); + return view; + } + + @Transactional + public WordView update(Long id, WordRequest request) { + ModerationWord word = repository.findById(id) + .orElseThrow(() -> new NotFoundException("Nie znaleziono wpisu")); + apply(word, request); + WordView view = save(word); + invalidate(); + return view; + } + + @Transactional + public void delete(Long id) { + ModerationWord word = repository.findById(id) + .orElseThrow(() -> new NotFoundException("Nie znaleziono wpisu")); + repository.delete(word); + invalidate(); + log.info("Moderacja: usunieto wpis {}", id); + } + + /** Podglad dzialania filtru na dowolnym tekscie, dla panelu. */ + public CheckResult test(String text) { + WordFilter.Hit hit = WordFilter.find(text, entries()); + return hit == null + ? new CheckResult(false, null, null) + : new CheckResult(true, hit.phrase(), hit.category()); + } + + private void apply(ModerationWord word, WordRequest request) { + String phrase = request.phrase() == null ? "" : request.phrase().trim().replaceAll("\\s+", " "); + String normalized = WordFilter.normalize(phrase); + if (normalized.isEmpty()) { + throw new ValidationException("Wpis musi zawierać co najmniej jedną literę."); + } + if (request.matchMode() == ModerationMatchMode.CONTAINS + && normalized.indexOf(' ') < 0 + && normalized.length() < WordFilter.MIN_CONTAINS_LENGTH) { + throw new ValidationException("Fragment słowa musi mieć co najmniej " + + WordFilter.MIN_CONTAINS_LENGTH + + " litery. Krótszy zatrzymywałby zwykłe wiadomości."); + } + + // ten sam wpis w innej pisowni ("Kurwa" i "kurwa") dzialalby tak samo + for (ModerationWord other : repository.findAll()) { + if (!other.getId().equals(word.getId()) + && WordFilter.normalize(other.getPhrase()).equals(normalized) + && other.getMatchMode() == request.matchMode()) { + throw new ValidationException("Ten wpis już jest na liście: „" + other.getPhrase() + "”."); + } + } + + word.setPhrase(phrase); + word.setCategory(request.category()); + word.setMatchMode(request.matchMode()); + word.setActive(request.active() == null || request.active()); + } + + private WordView save(ModerationWord word) { + try { + return WordView.of(repository.saveAndFlush(word)); + } catch (DataIntegrityViolationException e) { + throw new ValidationException("Ten wpis już jest na liście."); + } + } + + // --- widoki --- + + public record WordRequest(String phrase, ModerationCategory category, + ModerationMatchMode matchMode, Boolean active) { + } + + public record WordView(Long id, String phrase, ModerationCategory category, + ModerationMatchMode matchMode, boolean active, + OffsetDateTime createdAt) { + + static WordView of(ModerationWord w) { + return new WordView(w.getId(), w.getPhrase(), w.getCategory(), w.getMatchMode(), + w.isActive(), w.getCreatedAt()); + } + } + + public record CheckResult(boolean held, String phrase, ModerationCategory category) { + } +} diff --git a/backend/src/main/java/com/softspm/webinar/moderation/WordFilter.java b/backend/src/main/java/com/softspm/webinar/moderation/WordFilter.java new file mode 100644 index 0000000..bcf6d77 --- /dev/null +++ b/backend/src/main/java/com/softspm/webinar/moderation/WordFilter.java @@ -0,0 +1,183 @@ +package com.softspm.webinar.moderation; + +import com.softspm.webinar.domain.ModerationCategory; +import com.softspm.webinar.domain.ModerationMatchMode; + +import java.text.Normalizer; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** + * Dopasowanie tresci do slownika moderacji. + * + * Czysta logika, bez bazy i bez Springa: wiadomosc i wpisy slownika przechodza + * przez TA SAMA normalizacje, wiec administrator wpisuje slowo w zwyklej + * pisowni, a nie we wszystkich wariantach, jakie wymysli uczestnik. + * + * NORMALIZACJA + * --------------------------------------------------------------------------- + * - male litery, bez ogonkow ("Kurwą" i "kurwa" to to samo), + * - cyfry i znaki udajace litery zamienione na litery ("kurw4", "p1zda"), + * - powtorzenia zwiniete ("kuuurwa"); to samo dzieje sie z wpisem slownika, + * wiec wpis z podwojna litera nadal trafia, + * - znaki rozdzielajace wewnatrz slowa usuniete ("k.u.r.w.a", "ch_uj"), + * - pojedyncze litery rozdzielone spacjami sklejone ("k u r w a"). + * + * Kazdy z tych wariantow jest sprawdzany osobno, bo kazdy usuwa cos innego: + * sklejanie liter po kropkach zlaczyloby tez dwa zdania bez spacji po kropce. + * + * CZEGO TO NIE ZALATWI + * --------------------------------------------------------------------------- + * Obelgi bez slow ze slownika, aluzji ani kontekstu. Dlatego trafienie + * wstrzymuje wiadomosc do decyzji prowadzacego, a nie kasuje jej: filtr ma + * zatrzymac to, czego nikt nie powinien zobaczyc, zanim ktos to przeczyta, + * a nie zastapic moderatora. + */ +public final class WordFilter { + + /** Minimalna dlugosc wpisu w trybie fragmentu: krotszy trafialby w zwykle slowa. */ + public static final int MIN_CONTAINS_LENGTH = 3; + + private WordFilter() { + } + + /** Wpis slownika po normalizacji. */ + public record Entry(String phrase, ModerationCategory category, ModerationMatchMode mode, + String normalized) { + + public static Entry of(String phrase, ModerationCategory category, ModerationMatchMode mode) { + return new Entry(phrase, category, mode, normalize(phrase)); + } + } + + /** Trafienie: wpis w pierwotnej pisowni i jego kategoria. */ + public record Hit(String phrase, ModerationCategory category) { + } + + /** + * Pierwsze trafienie w tresci albo null. + */ + public static Hit find(String text, List entries) { + if (text == null || text.isBlank() || entries.isEmpty()) { + return null; + } + + String spaced = normalize(text); + String removed = collapse(mapChars(text, true)); + String joined = joinSingleLetters(spaced); + + Set tokens = new LinkedHashSet<>(); + for (String variant : List.of(spaced, removed, joined)) { + for (String token : variant.split(" ")) { + if (!token.isEmpty()) tokens.add(token); + } + } + List texts = List.of(" " + spaced + " ", " " + removed + " ", " " + joined + " "); + + for (Entry entry : entries) { + String word = entry.normalized(); + if (word == null || word.isBlank()) continue; + + if (word.indexOf(' ') >= 0) { + // fraza: szukana w calym tekscie, od poczatku slowa + for (String t : texts) { + if (t.contains(" " + word)) return new Hit(entry.phrase(), entry.category()); + } + continue; + } + + for (String token : tokens) { + boolean match = switch (entry.mode()) { + case WORD -> token.equals(word); + case PREFIX -> token.startsWith(word); + case CONTAINS -> token.contains(word); + }; + if (match) return new Hit(entry.phrase(), entry.category()); + } + } + return null; + } + + /** + * Postac porownywana: male litery a-z, pojedyncze spacje, bez powtorzen. + */ + public static String normalize(String text) { + if (text == null) return ""; + return collapse(mapChars(text, false)); + } + + /** + * Zamiana znakow. + * + * @param removeJoiners czy znaki laczace wewnatrz slowa (kropka, myslnik, + * podkreslnik, gwiazdka, apostrof) usuwac, zamiast + * zamieniac na spacje + */ + private static String mapChars(String text, boolean removeJoiners) { + String lower = text.toLowerCase(Locale.ROOT).replace('ł', 'l'); + // rozklad znakow i usuniecie znakow diakrytycznych: ą -> a, ś -> s, ż -> z + String plain = Normalizer.normalize(lower, Normalizer.Form.NFD).replaceAll("\\p{M}", ""); + + StringBuilder out = new StringBuilder(plain.length()); + for (char c : plain.toCharArray()) { + char mapped = switch (c) { + case '0' -> 'o'; + case '1', '!' -> 'i'; + case '3' -> 'e'; + case '4', '@' -> 'a'; + case '5', '$' -> 's'; + case '7' -> 't'; + default -> c; + }; + if (mapped >= 'a' && mapped <= 'z') { + out.append(mapped); + } else if (removeJoiners && (mapped == '.' || mapped == '-' || mapped == '_' + || mapped == '*' || mapped == '\'' || mapped == '`')) { + // pominiete: "k.u.r.w.a" daje "kurwa" + } else { + out.append(' '); + } + } + return out.toString(); + } + + /** Zwija powtorzenia liter i spacji, obcina brzegi. */ + private static String collapse(String text) { + StringBuilder out = new StringBuilder(text.length()); + char previous = 0; + for (char c : text.toCharArray()) { + if (c == previous) continue; + out.append(c); + previous = c; + } + return out.toString().trim(); + } + + /** + * Skleja ciagi pojedynczych liter rozdzielonych spacjami: "k u r w a" daje + * "kurwa". Pojedyncza litera miedzy zwyklymi slowami ("a", "i", "w") zostaje. + */ + private static String joinSingleLetters(String normalized) { + List result = new ArrayList<>(); + StringBuilder run = new StringBuilder(); + for (String token : normalized.split(" ")) { + if (token.length() == 1) { + run.append(token); + continue; + } + flush(run, result); + if (!token.isEmpty()) result.add(token); + } + flush(run, result); + return collapse(String.join(" ", result)); + } + + private static void flush(StringBuilder run, List result) { + if (run.isEmpty()) return; + result.add(run.toString()); + run.setLength(0); + } +} diff --git a/backend/src/main/java/com/softspm/webinar/repository/ModerationWordRepository.java b/backend/src/main/java/com/softspm/webinar/repository/ModerationWordRepository.java new file mode 100644 index 0000000..076f333 --- /dev/null +++ b/backend/src/main/java/com/softspm/webinar/repository/ModerationWordRepository.java @@ -0,0 +1,34 @@ +package com.softspm.webinar.repository; + +import com.softspm.webinar.domain.ModerationCategory; +import com.softspm.webinar.domain.ModerationWord; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface ModerationWordRepository extends JpaRepository { + + /** Wpisy uzywane przez filtr. Wylaczone zostaja w bazie, ale nie filtruja. */ + List findByActiveTrue(); + + /** + * Lista w panelu, z wyszukiwaniem i filtrem kategorii. + * + * `cast(:x as ...)` przy KAZDYM parametrze opcjonalnym jest obowiazkowe: + * sterownik wysyla null bez typu i bez rzutowania lista przestaje dzialac + * dokladnie wtedy, gdy filtr nie jest uzywany. + */ + @Query(""" + SELECT w FROM ModerationWord w + WHERE (cast(:q as string) IS NULL + OR lower(w.phrase) LIKE lower(concat('%', cast(:q as string), '%'))) + AND (cast(:category as string) IS NULL OR w.category = :category) + """) + Page search(@Param("q") String q, + @Param("category") ModerationCategory category, + Pageable pageable); +} diff --git a/backend/src/main/resources/db/migration/V31__moderation_words.sql b/backend/src/main/resources/db/migration/V31__moderation_words.sql new file mode 100644 index 0000000..9ed8757 --- /dev/null +++ b/backend/src/main/resources/db/migration/V31__moderation_words.sql @@ -0,0 +1,129 @@ +-- Slownik moderacji czatu i pytan. +-- +-- PO CO +-- --------------------------------------------------------------------------- +-- Wiadomosc na czacie trafia do wszystkich na sali w chwili wyslania. Przy +-- dwustu osobach reczne usuniecie wulgaryzmu przychodzi za pozno: wszyscy go +-- juz przeczytali. Filtr sprawdza tresc PRZED publikacja, a trafienie +-- wstrzymuje wiadomosc do decyzji prowadzacego. +-- +-- WSTRZYMANIE, NIE KASOWANIE +-- --------------------------------------------------------------------------- +-- Lista slow nie rozumie kontekstu i bedzie sie mylic w obie strony. Dlatego +-- trafienie niczego nie kasuje ani nie odrzuca samo: wiadomosc czeka jako +-- NEW, a prowadzacy ja publikuje albo odrzuca. Falszywe trafienie kosztuje +-- wtedy jedno klikniecie, a nie utracona wypowiedz uczestnika. +-- +-- DOPASOWANIE +-- --------------------------------------------------------------------------- +-- WORD - cale slowo ("dupa", ale nie "duplikat"), +-- PREFIX - poczatek slowa, czyli wszystkie odmiany ("debil", "debilu"), +-- CONTAINS - fragment slowa, takze w zlozeniach ("zajebisty", "spierdalaj"). +-- Wpis z odstepem (np. "pornografia dziecieca") jest fraza i szukany jest +-- w calym tekscie. Tekst i wpisy sa normalizowane tak samo (bez ogonkow, +-- bez wielkich liter, cyfry zamienione na litery, powtorzenia zwiniete), +-- wiec wpis podaje sie w zwyklej pisowni, a nie we wszystkich wariantach. + +CREATE TABLE moderation_words ( + id bigserial PRIMARY KEY, + phrase varchar(100) NOT NULL, + category varchar(20) NOT NULL, + match_mode varchar(20) NOT NULL, + active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT chk_moderation_words_category + CHECK (category IN ('VULGAR', 'INSULT', 'CHILD_ABUSE')), + CONSTRAINT chk_moderation_words_match_mode + CHECK (match_mode IN ('WORD', 'PREFIX', 'CONTAINS')) +); + +-- ten sam wpis dwa razy dawalby dwa wiersze do edycji i jeden efekt +CREATE UNIQUE INDEX uq_moderation_words_phrase ON moderation_words (lower(phrase)); + +-- Ktore slowo wstrzymalo wiadomosc. Null, gdy wiadomosc przeszla bez trafienia. +-- Tekst, a nie klucz obcy: wpis moze zostac zmieniony albo skasowany, a panel +-- ma dalej pokazac, dlaczego wiadomosc zatrzymano. +ALTER TABLE interactions ADD COLUMN moderation_hit varchar(100); + +-- Slownik startowy. +-- +-- Punkt wyjscia, nie zamknieta lista: administrator dopisuje i usuwa wpisy +-- w panelu. Wpisy w CONTAINS dobrane tak, zeby nie trafialy w zwykle slowa +-- (np. "dup" jako fragment trafialby w "duplikat", wiec "dupa" jest WORD). +INSERT INTO moderation_words (phrase, category, match_mode) VALUES + -- wulgaryzmy + ('kurw', 'VULGAR', 'CONTAINS'), + ('kurew', 'VULGAR', 'CONTAINS'), + ('chuj', 'VULGAR', 'CONTAINS'), + ('huj', 'VULGAR', 'CONTAINS'), + ('jeb', 'VULGAR', 'CONTAINS'), + ('pierdol', 'VULGAR', 'CONTAINS'), + ('pierdal', 'VULGAR', 'CONTAINS'), + ('pizd', 'VULGAR', 'CONTAINS'), + ('cip', 'VULGAR', 'PREFIX'), + ('fiut', 'VULGAR', 'PREFIX'), + ('kutas', 'VULGAR', 'PREFIX'), + ('dupa', 'VULGAR', 'WORD'), + ('dupy', 'VULGAR', 'WORD'), + ('dupę', 'VULGAR', 'WORD'), + ('dupie', 'VULGAR', 'WORD'), + ('dupek', 'VULGAR', 'PREFIX'), + ('srać', 'VULGAR', 'PREFIX'), + ('sraj', 'VULGAR', 'PREFIX'), + ('zesrać', 'VULGAR', 'PREFIX'), + ('gówn', 'VULGAR', 'PREFIX'), + ('chwdp', 'VULGAR', 'WORD'), + ('fuck', 'VULGAR', 'CONTAINS'), + ('shit', 'VULGAR', 'WORD'), + ('bitch', 'VULGAR', 'PREFIX'), + ('cunt', 'VULGAR', 'PREFIX'), + ('asshole', 'VULGAR', 'CONTAINS'), + -- obelgi + ('debil', 'INSULT', 'PREFIX'), + ('idiot', 'INSULT', 'PREFIX'), + ('kretyn', 'INSULT', 'PREFIX'), + ('matoł', 'INSULT', 'PREFIX'), + ('palant', 'INSULT', 'PREFIX'), + ('cwel', 'INSULT', 'PREFIX'), + ('ciul', 'INSULT', 'PREFIX'), + ('złamas', 'INSULT', 'PREFIX'), + ('frajer', 'INSULT', 'PREFIX'), + ('szmat', 'INSULT', 'PREFIX'), + ('dziwk', 'INSULT', 'PREFIX'), + ('suka', 'INSULT', 'WORD'), + ('suko', 'INSULT', 'WORD'), + -- cale slowa, bo poczatek "ciot" trafialby w "ciotka" + ('ciota', 'INSULT', 'WORD'), + ('cioty', 'INSULT', 'WORD'), + ('cioto', 'INSULT', 'WORD'), + ('pedał', 'INSULT', 'WORD'), + ('pedały', 'INSULT', 'WORD'), + ('czarnuch', 'INSULT', 'PREFIX'), + ('ciapat', 'INSULT', 'PREFIX'), + ('żydek', 'INSULT', 'WORD'), + ('nigger', 'INSULT', 'PREFIX'), + ('nigga', 'INSULT', 'PREFIX'), + ('faggot', 'INSULT', 'PREFIX'), + ('retard', 'INSULT', 'PREFIX'), + -- pedofilia i wykorzystywanie dzieci + ('pedofil', 'CHILD_ABUSE', 'PREFIX'), + ('pedo', 'CHILD_ABUSE', 'WORD'), + ('pornografia dziecięca', 'CHILD_ABUSE', 'CONTAINS'), + ('pornografii dziecięcej', 'CHILD_ABUSE', 'CONTAINS'), + ('dziecięca pornografia', 'CHILD_ABUSE', 'CONTAINS'), + ('dziecięce porno', 'CHILD_ABUSE', 'CONTAINS'), + ('porno z dzie', 'CHILD_ABUSE', 'CONTAINS'), + ('seks z dzie', 'CHILD_ABUSE', 'CONTAINS'), + ('sex z dzie', 'CHILD_ABUSE', 'CONTAINS'), + ('seks z nieletni', 'CHILD_ABUSE', 'CONTAINS'), + ('molestowanie dzieci', 'CHILD_ABUSE', 'CONTAINS'), + ('molestowania dzieci', 'CHILD_ABUSE', 'CONTAINS'), + ('nagie dzieci', 'CHILD_ABUSE', 'CONTAINS'), + ('nagich dzieci', 'CHILD_ABUSE', 'CONTAINS'), + ('child porn', 'CHILD_ABUSE', 'CONTAINS'), + ('lolicon', 'CHILD_ABUSE', 'PREFIX'), + ('shotacon', 'CHILD_ABUSE', 'PREFIX'), + ('jailbait', 'CHILD_ABUSE', 'PREFIX'), + ('preteen', 'CHILD_ABUSE', 'PREFIX'), + ('pthc', 'CHILD_ABUSE', 'WORD'); diff --git a/backend/src/test/java/com/softspm/webinar/moderation/WordFilterTest.java b/backend/src/test/java/com/softspm/webinar/moderation/WordFilterTest.java new file mode 100644 index 0000000..07aab58 --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/moderation/WordFilterTest.java @@ -0,0 +1,124 @@ +package com.softspm.webinar.moderation; + +import com.softspm.webinar.domain.ModerationCategory; +import com.softspm.webinar.domain.ModerationMatchMode; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Filtr slownika moderacji, sprawdzany na slowniku startowym z migracji. + * + * Slownik czytany jest wprost z pliku V31, a nie przepisany do testu: test + * sprawdza dokladnie to, co trafi na kazda nowa instancje. + * + * Obie strony sa rownie wazne. Filtr, ktory przepuszcza "k.u.r.w.a", nie + * chroni sali; filtr, ktory wstrzymuje "duplikat faktury" albo "ciotka", + * zasypuje prowadzacego falszywymi alarmami, az przestanie je czytac. + */ +class WordFilterTest { + + private static final List SEED = loadSeed(); + + private static List loadSeed() { + try { + String sql = Files.readString( + Path.of("src/main/resources/db/migration/V31__moderation_words.sql"), + StandardCharsets.UTF_8); + // tylko czesc INSERT: wyzej sa listy wartosci ograniczen CHECK + String inserts = sql.substring(sql.indexOf("INSERT INTO moderation_words")); + Matcher m = Pattern.compile("\\('([^']+)', '(\\w+)', '(\\w+)'\\)").matcher(inserts); + List entries = new ArrayList<>(); + while (m.find()) { + entries.add(WordFilter.Entry.of(m.group(1), + ModerationCategory.valueOf(m.group(2)), + ModerationMatchMode.valueOf(m.group(3)))); + } + return entries; + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + @Test + void seedIsLoaded() { + assertThat(SEED).hasSizeGreaterThan(50); + } + + @ParameterizedTest + @ValueSource(strings = { + "ty kurwo", + "KURWA", + "Kurwą jesteś", + "kuuuurwa", + "k.u.r.w.a", + "k u r w a", + "kurw4", + "to jest zajebiste", + "spierdalaj stąd", + "wypierdalaj", + "ty debilu", + "chuj z tym", + "ch_uj", + "sp13rd0l", + "pizda", + }) + void holdsVulgarAndInsults(String text) { + assertThat(WordFilter.find(text, SEED)).as(text).isNotNull(); + } + + @ParameterizedTest + @ValueSource(strings = { + "pedofil", + "Pedofilia to przestępstwo", + "pornografia dziecięca", + "szukam pornografii dziecięcej", + "seks z dziećmi", + }) + void holdsChildAbuse(String text) { + WordFilter.Hit hit = WordFilter.find(text, SEED); + assertThat(hit).as(text).isNotNull(); + assertThat(hit.category()).isEqualTo(ModerationCategory.CHILD_ABUSE); + } + + @ParameterizedTest + @ValueSource(strings = { + "Dzień dobry, mam pytanie o przyłącze wodociągowe", + "Proszę o duplikat faktury", + "Moja ciotka też pracuje w wodociągach", + "Chodzi głównie o przepompownię ścieków", + "Gnojowica i osady ściekowe", + "Jaki jest dopuszczalny poziom chloru?", + "Hydrant przy ulicy Kurkowej", + "principal i sprawozdanie finansowe", + "Szkolenie z kanalizacji deszczowej", + "Czy będzie nagranie?", + "Dziękuję, świetny webinar!", + }) + void letsOrdinaryMessagesThrough(String text) { + assertThat(WordFilter.find(text, SEED)).as(text).isNull(); + } + + @Test + void speakerWordsAreNotSpecialHere() { + // filtr nie wie, kto pisze: pomijanie prowadzacych robi InteractionService + assertThat(WordFilter.find("kurwa", SEED)).isNotNull(); + } + + @Test + void emptyTextPasses() { + assertThat(WordFilter.find("", SEED)).isNull(); + assertThat(WordFilter.find(" ", SEED)).isNull(); + assertThat(WordFilter.find(null, SEED)).isNull(); + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e2476f5..796ab24 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -52,6 +52,7 @@ import { HomePageSettingsPage } from './pages/settings/HomePageSettingsPage' import { UserListPage } from './pages/users/UserListPage' import { ApiKeyListPage } from './pages/apikeys/ApiKeyListPage' import { ResourcesPage } from './pages/resources/ResourcesPage' +import { ModerationWordsPage } from './pages/moderation/ModerationWordsPage' import { AcademyChoicePage, AcademyPasswordResetPage, @@ -340,6 +341,12 @@ export function App() { } /> + {/* + Slownik moderacji: ADMIN i MODERATOR, tak jak backend (/admin/**). + Prowadzacy dopisuje slowo zauwazone w trakcie spotkania bez szukania + administratora. + */} + } /> {/* Zasoby pokazuja stan zadan prowadzonych w tle i pozwalaja je zatrzymac, wiec dotycza instancji jako calosci, tak samo jak ustawienia. Backend diff --git a/frontend/src/components/Icon.tsx b/frontend/src/components/Icon.tsx index b988f5f..98f70e9 100644 --- a/frontend/src/components/Icon.tsx +++ b/frontend/src/components/Icon.tsx @@ -47,6 +47,7 @@ import { Search, Send, Settings, + ShieldAlert, Trash2, Upload, UserPlus, @@ -117,6 +118,7 @@ const ICONS = { search: Search, send: Send, settings: Settings, + 'shield-alert': ShieldAlert, trash: Trash2, upload: Upload, 'user-plus': UserPlus, diff --git a/frontend/src/lib/navigation.ts b/frontend/src/lib/navigation.ts index 94b8805..448565c 100644 --- a/frontend/src/lib/navigation.ts +++ b/frontend/src/lib/navigation.ts @@ -104,6 +104,8 @@ export const NAV_AREAS: NavArea[] = [ export const NAV_COMMON: NavItem[] = [ { to: panelPath('/kontakty'), label: 'Kontakty', icon: 'users' }, { to: panelPath('/banery'), label: 'Banery', icon: 'image', roles: ['ADMIN'] }, + // bez ograniczenia roli: prowadzacy (MODERATOR) ma dopisac slowo zauwazone w trakcie spotkania + { to: panelPath('/moderacja-wyrazow'), label: 'Moderacja wyrazów', icon: 'shield-alert' }, { to: panelPath('/ustawienia'), label: 'Ustawienia', icon: 'settings', roles: ['ADMIN'] }, { to: panelPath('/uzytkownicy'), label: 'Użytkownicy', icon: 'users', roles: ['ADMIN'] }, { to: panelPath('/klucze-api'), label: 'Klucze API', icon: 'key', roles: ['ADMIN'] }, diff --git a/frontend/src/lib/useInteractions.ts b/frontend/src/lib/useInteractions.ts index 9a12fe8..d8f54da 100644 --- a/frontend/src/lib/useInteractions.ts +++ b/frontend/src/lib/useInteractions.ts @@ -60,6 +60,8 @@ export type ModeratorInteractionView = { authorChatMuted: boolean authorRoomBanned: boolean createdAt: string + /** Slowo ze slownika moderacji, ktore wstrzymalo wiadomosc; null bez trafienia. */ + moderationHit: string | null } type History = { diff --git a/frontend/src/pages/moderation/ModerationWordsPage.tsx b/frontend/src/pages/moderation/ModerationWordsPage.tsx new file mode 100644 index 0000000..ff90969 --- /dev/null +++ b/frontend/src/pages/moderation/ModerationWordsPage.tsx @@ -0,0 +1,436 @@ +import { useEffect, useState } from 'react' +import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { client, errorMessage, fieldErrors } from '../../api/client' +import { useConfirm } from '../../components/Confirm' +import { Icon } from '../../components/Icon' +import { PAGE_SIZE, Pagination } from '../../components/Pagination' +import { useToasts } from '../../components/Toasts' +import { + Alert, + Badge, + Button, + Card, + EmptyState, + ErrorState, + Field, + IconButton, + Input, + Select, + Spinner, + Textarea, +} from '../../components/ui' + +type Category = 'VULGAR' | 'INSULT' | 'CHILD_ABUSE' +type MatchMode = 'WORD' | 'PREFIX' | 'CONTAINS' + +type Word = { + id: number + phrase: string + category: Category + matchMode: MatchMode + active: boolean + createdAt: string +} + +type Page = { + content: T[] + totalElements: number + totalPages: number + number: number +} + +type CheckResult = { held: boolean; phrase: string | null; category: Category | null } + +const CATEGORY_LABEL: Record = { + VULGAR: 'Wulgaryzm', + INSULT: 'Obelga', + CHILD_ABUSE: 'Pedofilia i wykorzystywanie dzieci', +} + +const CATEGORY_TONE: Record = { + VULGAR: 'warn', + INSULT: 'info', + CHILD_ABUSE: 'alarm', +} + +const MODE_LABEL: Record = { + WORD: 'Całe słowo', + PREFIX: 'Początek słowa (wszystkie odmiany)', + CONTAINS: 'Fragment słowa (także w złożeniach)', +} + +/** Skutek wpisu zwyklym zdaniem, zgodnie z zasada przyjaznego interfejsu. */ +function describeEffect(phrase: string, mode: MatchMode): string { + const p = phrase.trim() + if (!p) return 'Wpisz słowo, a zobaczysz, jakie wiadomości zostaną zatrzymane.' + if (/\s/.test(p)) return `Zatrzyma wiadomości zawierające frazę „${p}”.` + switch (mode) { + case 'WORD': + return `Zatrzyma wiadomości ze słowem „${p}”, ale nie z dłuższymi słowami, które się od niego zaczynają.` + case 'PREFIX': + return `Zatrzyma wiadomości ze słowami zaczynającymi się od „${p}”, czyli także jego odmiany.` + case 'CONTAINS': + return `Zatrzyma wiadomości ze słowami zawierającymi „${p}” w dowolnym miejscu, także w złożeniach.` + } +} + +type FormState = { phrase: string; category: Category; matchMode: MatchMode; active: boolean } + +const EMPTY_FORM: FormState = { phrase: '', category: 'VULGAR', matchMode: 'PREFIX', active: true } + +/** + * Slownik moderacji czatu i pytan. + * + * Wpis nie usuwa wiadomosci, tylko WSTRZYMUJE ja do decyzji prowadzacego: + * lista slow nie rozumie kontekstu, wiec falszywe trafienie ma kosztowac + * jedno klikniecie, a nie utracona wypowiedz. Dlatego skutek kazdego wpisu jest + * opisany zdaniem, a pole "Sprawdz tekst" pozwala zobaczyc dzialanie filtru + * przed webinarem, a nie w jego trakcie. + */ +export function ModerationWordsPage() { + const queryClient = useQueryClient() + const confirm = useConfirm() + const toasts = useToasts() + + const [search, setSearch] = useState('') + const [query, setQuery] = useState('') + const [category, setCategory] = useState('') + const [page, setPage] = useState(0) + + const [form, setForm] = useState(EMPTY_FORM) + const [editing, setEditing] = useState(null) + const [errors, setErrors] = useState>({}) + + const [sample, setSample] = useState('') + const [checkResult, setCheckResult] = useState(null) + + useEffect(() => { + const timer = window.setTimeout(() => { + setQuery(search.trim()) + setPage(0) + }, 300) + return () => window.clearTimeout(timer) + }, [search]) + + const words = useQuery({ + queryKey: ['moderation-words', query, category, page], + queryFn: async () => + ( + await client.get>('/admin/moderation-words', { + params: { q: query || undefined, category: category || undefined, page, size: PAGE_SIZE }, + }) + ).data, + placeholderData: keepPreviousData, + }) + + const refresh = () => queryClient.invalidateQueries({ queryKey: ['moderation-words'] }) + + const save = useMutation({ + mutationFn: async (value: FormState) => + editing + ? client.put(`/admin/moderation-words/${editing.id}`, value) + : client.post('/admin/moderation-words', value), + onSuccess: () => { + toasts.success(editing ? 'Wpis zapisany' : 'Wpis dodany') + setForm(EMPTY_FORM) + setEditing(null) + setErrors({}) + setCheckResult(null) + void refresh() + }, + onError: (error) => { + setErrors(fieldErrors(error)) + toasts.error(errorMessage(error, 'Nie udało się zapisać wpisu')) + }, + }) + + const toggle = useMutation({ + mutationFn: (word: Word) => + client.put(`/admin/moderation-words/${word.id}`, { + phrase: word.phrase, + category: word.category, + matchMode: word.matchMode, + active: !word.active, + }), + onSuccess: (_, word) => { + toasts.success(word.active ? `Wyłączono: „${word.phrase}”` : `Włączono: „${word.phrase}”`) + void refresh() + }, + onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zmienić wpisu')), + }) + + const remove = useMutation({ + mutationFn: (id: number) => client.delete(`/admin/moderation-words/${id}`), + onSuccess: () => { + toasts.success('Wpis usunięty') + void refresh() + }, + onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć wpisu')), + }) + + const check = useMutation({ + mutationFn: async (text: string) => + (await client.post('/admin/moderation-words/check', { text })).data, + onSuccess: (data) => setCheckResult(data), + onError: (error) => toasts.error(errorMessage(error, 'Nie udało się sprawdzić tekstu')), + }) + + function startEdit(word: Word) { + setEditing(word) + setErrors({}) + setForm({ + phrase: word.phrase, + category: word.category, + matchMode: word.matchMode, + active: word.active, + }) + window.scrollTo({ top: 0, behavior: 'smooth' }) + } + + function cancelEdit() { + setEditing(null) + setErrors({}) + setForm(EMPTY_FORM) + } + + async function handleDelete(word: Word) { + const ok = await confirm({ + title: `Usunąć wpis „${word.phrase}”?`, + message: + 'Wiadomości z tym słowem przestaną być wstrzymywane. Jeśli chcesz tylko na chwilę ' + + 'przestać go sprawdzać, wyłącz wpis zamiast go usuwać.', + confirmLabel: 'Usuń wpis', + destructive: true, + }) + if (ok) remove.mutate(word.id) + } + + const data = words.data + + return ( +
+
+

Moderacja wyrazów

+

+ Wiadomość na czacie albo pytanie uczestnika, które zawiera słowo z tej listy, nie trafia do + sali. Czeka w panelu prowadzenia, a prowadzący ją publikuje albo odrzuca. +

+
+ + +
{ + event.preventDefault() + save.mutate(form) + }} + > +
+ + setForm({ ...form, phrase: event.target.value })} + placeholder="np. debil" + /> + + + + + + + + + + +
+ +

{describeEffect(form.phrase, form.matchMode)}

+ +
+ + {editing && ( + + )} +
+
+
+ + +
{ + event.preventDefault() + check.mutate(sample) + }} + > + +