feat(geschichte): add GeschichteService with HTML sanitization and DRAFT visibility rules

DRAFT stories are 404 to readers without BLOG_WRITE (NOT_FOUND, not FORBIDDEN,
to avoid leaking existence). list() forces status=PUBLISHED for non-writers
even when they pass status=null. Body HTML is sanitised via OWASP allow-list
(p, br, strong, em, h2, h3, ul, ol, li) on every save. publishedAt is set on
every transition into PUBLISHED and cleared on retract.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-02 17:29:11 +02:00
parent b7a2f6c2fe
commit 08d96e5b0f
2 changed files with 591 additions and 0 deletions

View File

@@ -0,0 +1,178 @@
package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.owasp.html.HtmlPolicyBuilder;
import org.owasp.html.PolicyFactory;
import org.raddatz.familienarchiv.dto.GeschichteUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.Geschichte;
import org.raddatz.familienarchiv.model.GeschichteStatus;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.repository.GeschichteRepository;
import org.raddatz.familienarchiv.security.Permission;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
@Service
@RequiredArgsConstructor
@Slf4j
public class GeschichteService {
private final GeschichteRepository geschichteRepository;
private final PersonService personService;
private final DocumentService documentService;
private final UserService userService;
/**
* Allow-list policy for Geschichte body HTML. Tiptap on the writer side
* already constrains the marks/nodes, but the backend re-sanitises every
* save so that an attacker calling the API directly cannot inject more.
*/
private static final PolicyFactory BODY_SANITIZER = new HtmlPolicyBuilder()
.allowElements("p", "br", "strong", "em", "h2", "h3", "ul", "ol", "li")
.toFactory();
private static final int DEFAULT_LIMIT = 50;
private static final int MAX_LIMIT = 200;
// ─── Read API ────────────────────────────────────────────────────────────
public Geschichte getById(UUID id) {
Geschichte g = geschichteRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(
ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id));
if (g.getStatus() == GeschichteStatus.DRAFT && !currentUserHasBlogWrite()) {
// Use NOT_FOUND, not FORBIDDEN — don't leak DRAFT existence.
throw DomainException.notFound(
ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id);
}
return g;
}
public List<Geschichte> list(GeschichteStatus status, UUID personId, UUID documentId, int limit) {
GeschichteStatus effective = currentUserHasBlogWrite() ? status : GeschichteStatus.PUBLISHED;
int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT);
Pageable pageable = PageRequest.of(0, safeLimit);
return geschichteRepository.search(effective, personId, documentId, pageable);
}
// ─── Write API ───────────────────────────────────────────────────────────
@Transactional
public Geschichte create(GeschichteUpdateDTO dto) {
requireTitle(dto.getTitle());
Geschichte g = Geschichte.builder()
.title(dto.getTitle().trim())
.body(sanitize(dto.getBody()))
.status(GeschichteStatus.DRAFT)
.author(currentUser())
.persons(resolvePersons(dto.getPersonIds()))
.documents(resolveDocuments(dto.getDocumentIds()))
.build();
if (dto.getStatus() == GeschichteStatus.PUBLISHED) {
g.setStatus(GeschichteStatus.PUBLISHED);
g.setPublishedAt(LocalDateTime.now());
}
return geschichteRepository.save(g);
}
@Transactional
public Geschichte update(UUID id, GeschichteUpdateDTO dto) {
Geschichte g = geschichteRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(
ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id));
if (dto.getTitle() != null) {
requireTitle(dto.getTitle());
g.setTitle(dto.getTitle().trim());
}
if (dto.getBody() != null) {
g.setBody(sanitize(dto.getBody()));
}
if (dto.getPersonIds() != null) {
g.setPersons(resolvePersons(dto.getPersonIds()));
}
if (dto.getDocumentIds() != null) {
g.setDocuments(resolveDocuments(dto.getDocumentIds()));
}
if (dto.getStatus() != null && dto.getStatus() != g.getStatus()) {
applyStatusTransition(g, dto.getStatus());
}
return geschichteRepository.save(g);
}
@Transactional
public void delete(UUID id) {
if (!geschichteRepository.existsById(id)) {
throw DomainException.notFound(
ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id);
}
geschichteRepository.deleteById(id);
}
// ─── private helpers ─────────────────────────────────────────────────────
private void applyStatusTransition(Geschichte g, GeschichteStatus next) {
g.setStatus(next);
if (next == GeschichteStatus.PUBLISHED) {
g.setPublishedAt(LocalDateTime.now());
} else {
g.setPublishedAt(null);
}
}
private void requireTitle(String title) {
if (title == null || title.trim().isEmpty()) {
throw DomainException.badRequest(
ErrorCode.VALIDATION_ERROR, "Title is required");
}
}
private String sanitize(String body) {
if (body == null) return null;
return BODY_SANITIZER.sanitize(body);
}
private Set<Person> resolvePersons(List<UUID> ids) {
if (ids == null || ids.isEmpty()) return new HashSet<>();
return new LinkedHashSet<>(personService.getAllById(ids));
}
private Set<Document> resolveDocuments(List<UUID> ids) {
if (ids == null || ids.isEmpty()) return new HashSet<>();
Set<Document> out = new LinkedHashSet<>();
for (UUID id : ids) {
out.add(documentService.getDocumentById(id));
}
return out;
}
private AppUser currentUser() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
throw DomainException.unauthorized("Authentication required");
}
return userService.findByEmail(auth.getName());
}
private boolean currentUserHasBlogWrite() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) return false;
return auth.getAuthorities().stream()
.anyMatch(a -> Permission.BLOG_WRITE.name().equals(a.getAuthority()));
}
}