Moderacja wyrazow: slownik i wstrzymywanie wiadomosci do akceptacji
Deploy PROD / deploy (push) Successful in 33s

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 09:48:39 +02:00
parent c53ad95104
commit dda54f00a1
22 changed files with 1457 additions and 16 deletions
@@ -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;
@@ -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
}
@@ -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
}
@@ -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();
}
}
@@ -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;
}
@@ -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<Interaction> 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));
}
/**
@@ -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
@@ -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());
}
}
@@ -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<ModerationWordService.WordView> 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) {
}
}
@@ -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<WordFilter.Entry> 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<WordFilter.Entry> entries() {
List<WordFilter.Entry> 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<WordView> 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) {
}
}
@@ -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<Entry> 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<String> tokens = new LinkedHashSet<>();
for (String variant : List.of(spaced, removed, joined)) {
for (String token : variant.split(" ")) {
if (!token.isEmpty()) tokens.add(token);
}
}
List<String> 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<String> 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<String> result) {
if (run.isEmpty()) return;
result.add(run.toString());
run.setLength(0);
}
}
@@ -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<ModerationWord, Long> {
/** Wpisy uzywane przez filtr. Wylaczone zostaja w bazie, ale nie filtruja. */
List<ModerationWord> 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<ModerationWord> search(@Param("q") String q,
@Param("category") ModerationCategory category,
Pageable pageable);
}
@@ -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');
@@ -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<WordFilter.Entry> SEED = loadSeed();
private static List<WordFilter.Entry> 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<WordFilter.Entry> 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();
}
}