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.
+
+ {messages.map((message) => {
+ /*
+ * Wiadomosc wstrzymana przez slownik moderacji. Widzi ja tylko panel;
+ * decyzja jest na wierzchu, a nie pod najechaniem myszy, bo bez niej
+ * wiadomosc nigdy nie trafi na sale.
+ */
+ const held = message.status === 'NEW'
+ return (
+
)}
{message.content}
+ {held && (
+ <>
+
+ Wstrzymana przez filtr słów
+ {message.moderationHit && <>: „{message.moderationHit}”>}. Nikt poza prowadzącymi jej
+ nie widzi.
+
+
+
+
+
+ >
+ )}
{/* moderacja autora tylko dla wiadomosci uczestnika (nie prelegenta) */}
@@ -553,7 +599,8 @@ function ChatColumn({
- ))}
+ )
+ })}
)
@@ -581,6 +628,11 @@ function QuestionsColumn({
{question.content}
{question.authorName}
+ {question.moderationHit && (
+
+ Wstrzymane przez filtr słów: „{question.moderationHit}”
+