diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java index fb1164ea..eb71ec19 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java @@ -134,11 +134,12 @@ public class GeschichteService { @Transactional public GeschichteView create(GeschichteUpdateDTO dto) { requireTitle(dto.getTitle()); + GeschichteType type = dto.getType() != null ? dto.getType() : GeschichteType.STORY; Geschichte g = Geschichte.builder() .title(dto.getTitle().trim()) - .body(sanitize(dto.getBody())) + .body(bodyForType(type, dto.getBody())) .status(GeschichteStatus.DRAFT) - .type(dto.getType() != null ? dto.getType() : GeschichteType.STORY) + .type(type) .author(currentUser()) .persons(resolvePersons(dto.getPersonIds())) .build(); @@ -164,7 +165,7 @@ public class GeschichteService { g.setTitle(dto.getTitle().trim()); } if (dto.getBody() != null) { - g.setBody(sanitize(dto.getBody())); + g.setBody(bodyForType(g.getType(), dto.getBody())); } if (dto.getPersonIds() != null) { g.setPersons(resolvePersons(dto.getPersonIds())); @@ -203,6 +204,16 @@ public class GeschichteService { } } + /** + * STORY bodies are Tiptap HTML and go through the OWASP allow-list sanitizer. + * JOURNEY intros are plain text: the reader renders them via Svelte text + * interpolation (never {@code {@html}}), so entity-encoding them here would + * corrupt content ("&" → "&") and re-encode on every editor round-trip. + */ + private String bodyForType(GeschichteType type, String body) { + return type == GeschichteType.JOURNEY ? body : sanitize(body); + } + private String sanitize(String body) { if (body == null) return null; return BODY_SANITIZER.sanitize(body); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java index a51408a4..37e00712 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java @@ -420,6 +420,63 @@ class GeschichteServiceTest { assertThat(saved.type()).isEqualTo(GeschichteType.STORY); } + @Test + void create_stores_JOURNEY_intro_verbatim_without_html_entity_encoding() { + // The journey intro is plain text: JourneyReader renders it via Svelte text + // interpolation (never {@html}), so the OWASP sanitizer's entity encoding + // would corrupt real content ("Müller & Söhne" → "Müller & Söhne") and + // re-encode cumulatively on every editor round-trip. + authenticateAs(writer, Permission.BLOG_WRITE); + when(userService.findByEmail(writer.getEmail())).thenReturn(writer); + when(geschichteRepository.save(any(Geschichte.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + GeschichteUpdateDTO dto = new GeschichteUpdateDTO(); + dto.setTitle("Winterbriefe"); + dto.setType(GeschichteType.JOURNEY); + dto.setBody("Müller & Söhne, Temperatur < 0"); + + GeschichteView saved = geschichteService.create(dto); + + assertThat(saved.body()).isEqualTo("Müller & Söhne, Temperatur < 0"); + } + + @Test + void update_stores_JOURNEY_intro_verbatim_without_html_entity_encoding() { + authenticateAs(writer, Permission.BLOG_WRITE); + UUID id = UUID.randomUUID(); + Geschichte existing = draft(id); + existing.setType(GeschichteType.JOURNEY); + when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing)); + when(geschichteRepository.save(any(Geschichte.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + GeschichteUpdateDTO dto = new GeschichteUpdateDTO(); + dto.setBody("Temperatur < 0 & Schnee"); + + GeschichteView saved = geschichteService.update(id, dto); + + assertThat(saved.body()).isEqualTo("Temperatur < 0 & Schnee"); + } + + @Test + void update_still_sanitizes_STORY_body() { + authenticateAs(writer, Permission.BLOG_WRITE); + UUID id = UUID.randomUUID(); + Geschichte existing = draft(id); + existing.setType(GeschichteType.STORY); + when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing)); + when(geschichteRepository.save(any(Geschichte.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + GeschichteUpdateDTO dto = new GeschichteUpdateDTO(); + dto.setBody("
ok
"); + + GeschichteView saved = geschichteService.update(id, dto); + + assertThat(saved.body()).doesNotContain("