From 05a7c5d2b5e6673f5ceaf7d65e9ce36dfa5846d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Tue, 28 Apr 2026 14:46:53 +0200 Subject: [PATCH] feat: implement copyright report service, API, and broadcaster notice endpoints - CopyrightReportService: submit, list, get, review (DISMISS / REMOVE_ASSET / NOTIFY_BROADCASTER / BAN_BROADCASTER); NOTIFY_BROADCASTER sets NOTIFIED status so notices persist until broadcaster dismisses them - BroadcasterCopyrightNoticeApiController: channel-admin-scoped list + dismiss - CopyrightReportApiController: public submit, sysadmin list/get/review - RateLimitInterceptor: 5 reports/IP/hour on the public submit endpoint - Banned check added to asset upload path --- .../imgfloat/config/RateLimitInterceptor.java | 65 +++++ .../imgfloat/config/SecurityConfig.java | 5 +- .../imgfloat/config/WebMvcConfig.java | 21 ++ ...oadcasterCopyrightNoticeApiController.java | 73 ++++++ .../controller/ChannelApiController.java | 1 + .../CopyrightReportApiController.java | 118 +++++++++ .../api/request/CopyrightReportRequest.java | 28 +++ .../request/CopyrightReportReviewRequest.java | 13 + .../api/response/CopyrightReportPageView.java | 11 + .../api/response/CopyrightReportView.java | 42 ++++ .../repository/CopyrightReportRepository.java | 30 +++ .../service/AuthorizationService.java | 17 ++ .../service/CopyrightReportAction.java | 8 + .../service/CopyrightReportService.java | 232 ++++++++++++++++++ .../service/AuthorizationServiceTest.java | 7 +- 15 files changed, 668 insertions(+), 3 deletions(-) create mode 100644 src/main/java/dev/kruhlmann/imgfloat/config/RateLimitInterceptor.java create mode 100644 src/main/java/dev/kruhlmann/imgfloat/config/WebMvcConfig.java create mode 100644 src/main/java/dev/kruhlmann/imgfloat/controller/BroadcasterCopyrightNoticeApiController.java create mode 100644 src/main/java/dev/kruhlmann/imgfloat/controller/CopyrightReportApiController.java create mode 100644 src/main/java/dev/kruhlmann/imgfloat/model/api/request/CopyrightReportRequest.java create mode 100644 src/main/java/dev/kruhlmann/imgfloat/model/api/request/CopyrightReportReviewRequest.java create mode 100644 src/main/java/dev/kruhlmann/imgfloat/model/api/response/CopyrightReportPageView.java create mode 100644 src/main/java/dev/kruhlmann/imgfloat/model/api/response/CopyrightReportView.java create mode 100644 src/main/java/dev/kruhlmann/imgfloat/repository/CopyrightReportRepository.java create mode 100644 src/main/java/dev/kruhlmann/imgfloat/service/CopyrightReportAction.java create mode 100644 src/main/java/dev/kruhlmann/imgfloat/service/CopyrightReportService.java diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/RateLimitInterceptor.java b/src/main/java/dev/kruhlmann/imgfloat/config/RateLimitInterceptor.java new file mode 100644 index 0000000..f45a646 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/config/RateLimitInterceptor.java @@ -0,0 +1,65 @@ +package dev.kruhlmann.imgfloat.config; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.lang.NonNull; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Simple in-memory IP-based rate limiter for the public copyright report submission endpoint. + * Limits each IP to {@value #MAX_REQUESTS_PER_WINDOW} requests per {@value #WINDOW_MILLIS}ms window. + */ +public class RateLimitInterceptor implements HandlerInterceptor { + + private static final Logger LOG = LoggerFactory.getLogger(RateLimitInterceptor.class); + private static final int MAX_REQUESTS_PER_WINDOW = 5; + private static final long WINDOW_MILLIS = 60 * 60 * 1000L; // 1 hour + + private record BucketEntry(AtomicInteger count, long windowStart) {} + + private final ConcurrentHashMap buckets = new ConcurrentHashMap<>(); + + @Override + public boolean preHandle( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull Object handler + ) throws Exception { + String uri = request.getRequestURI(); + if (!"POST".equalsIgnoreCase(request.getMethod()) || !uri.contains("copyright-reports")) { + return true; + } + String ip = resolveClientIp(request); + long now = System.currentTimeMillis(); + BucketEntry entry = buckets.compute(ip, (key, existing) -> { + if (existing == null || (now - existing.windowStart()) >= WINDOW_MILLIS) { + return new BucketEntry(new AtomicInteger(1), now); + } + existing.count().incrementAndGet(); + return existing; + }); + int count = entry.count().get(); + if (count > MAX_REQUESTS_PER_WINDOW) { + LOG.warn("Rate limit exceeded for IP {} on {}", ip, uri); + response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); + response.setContentType("application/json"); + response.getWriter().write("{\"error\":\"Too many requests. Please try again later.\"}"); + return false; + } + return true; + } + + private String resolveClientIp(HttpServletRequest request) { + String forwarded = request.getHeader("X-Forwarded-For"); + if (forwarded != null && !forwarded.isBlank()) { + return forwarded.split(",")[0].trim(); + } + return request.getRemoteAddr(); + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/SecurityConfig.java b/src/main/java/dev/kruhlmann/imgfloat/config/SecurityConfig.java index 82d38b0..eb028f1 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/config/SecurityConfig.java +++ b/src/main/java/dev/kruhlmann/imgfloat/config/SecurityConfig.java @@ -63,7 +63,8 @@ public class SecurityConfig { "/channels", "/terms", "/privacy", - "/cookies" + "/cookies", + "/report" ) .permitAll() .requestMatchers(HttpMethod.GET, "/view/*/broadcast") @@ -86,6 +87,8 @@ public class SecurityConfig { .permitAll() .requestMatchers(HttpMethod.GET, "/api/7tv/emotes/**") .permitAll() + .requestMatchers(HttpMethod.POST, "/api/assets/*/copyright-reports") + .permitAll() .requestMatchers("/ws/**") .permitAll() .anyRequest() diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/WebMvcConfig.java b/src/main/java/dev/kruhlmann/imgfloat/config/WebMvcConfig.java new file mode 100644 index 0000000..38439b4 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/config/WebMvcConfig.java @@ -0,0 +1,21 @@ +package dev.kruhlmann.imgfloat.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + @Bean + public RateLimitInterceptor rateLimitInterceptor() { + return new RateLimitInterceptor(); + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(rateLimitInterceptor()) + .addPathPatterns("/api/assets/*/copyright-reports"); + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/BroadcasterCopyrightNoticeApiController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/BroadcasterCopyrightNoticeApiController.java new file mode 100644 index 0000000..cead3a6 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/BroadcasterCopyrightNoticeApiController.java @@ -0,0 +1,73 @@ +package dev.kruhlmann.imgfloat.controller; + +import dev.kruhlmann.imgfloat.model.OauthSessionUser; +import dev.kruhlmann.imgfloat.model.api.response.CopyrightReportView; +import dev.kruhlmann.imgfloat.service.AuthorizationService; +import dev.kruhlmann.imgfloat.service.CopyrightReportService; +import dev.kruhlmann.imgfloat.util.LogSanitizer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * Broadcaster-facing endpoints for copyright notices — reports that have been + * escalated to the broadcaster by a sysadmin via the NOTIFY_BROADCASTER action. + * These remain visible to the broadcaster until they explicitly dismiss them. + */ +@RestController +@RequestMapping("/api/channels/{broadcaster}/copyright-notices") +public class BroadcasterCopyrightNoticeApiController { + + private static final Logger LOG = LoggerFactory.getLogger(BroadcasterCopyrightNoticeApiController.class); + + private final CopyrightReportService copyrightReportService; + private final AuthorizationService authorizationService; + + public BroadcasterCopyrightNoticeApiController( + CopyrightReportService copyrightReportService, + AuthorizationService authorizationService + ) { + this.copyrightReportService = copyrightReportService; + this.authorizationService = authorizationService; + } + + /** List all pending (NOTIFIED) copyright notices for this broadcaster. */ + @GetMapping + public List listNotices( + @PathVariable("broadcaster") String broadcaster, + OAuth2AuthenticationToken oauthToken + ) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername); + return copyrightReportService.listNotices(broadcaster) + .stream() + .map(CopyrightReportView::fromReport) + .toList(); + } + + /** Broadcaster acknowledges and dismisses a notice (transitions it to RESOLVED). */ + @PostMapping("/{reportId}/dismiss") + public ResponseEntity dismissNotice( + @PathVariable("broadcaster") String broadcaster, + @PathVariable("reportId") String reportId, + OAuth2AuthenticationToken oauthToken + ) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername); + LOG.info("Copyright notice {} dismissed by {} for broadcaster {}", + reportId, + LogSanitizer.sanitize(sessionUsername), + LogSanitizer.sanitize(broadcaster) + ); + copyrightReportService.dismissNotice(reportId, broadcaster); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java index 9aa42fd..d7b1cb2 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java @@ -129,6 +129,7 @@ public class ChannelApiController { broadcaster, sessionUsername ); + authorizationService.channelIsNotBannedOrThrowHttpError(broadcaster); if (file == null || file.isEmpty()) { LOG.warn("User {} attempted to upload empty file to {}", logSessionUsername, logBroadcaster); throw new ResponseStatusException(BAD_REQUEST, "Asset file is required"); diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/CopyrightReportApiController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/CopyrightReportApiController.java new file mode 100644 index 0000000..db29dff --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/CopyrightReportApiController.java @@ -0,0 +1,118 @@ +package dev.kruhlmann.imgfloat.controller; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; + +import dev.kruhlmann.imgfloat.model.OauthSessionUser; +import dev.kruhlmann.imgfloat.model.api.request.CopyrightReportRequest; +import dev.kruhlmann.imgfloat.model.api.request.CopyrightReportReviewRequest; +import dev.kruhlmann.imgfloat.model.api.response.CopyrightReportPageView; +import dev.kruhlmann.imgfloat.model.api.response.CopyrightReportView; +import dev.kruhlmann.imgfloat.model.db.imgfloat.CopyrightReportStatus; +import dev.kruhlmann.imgfloat.service.AuthorizationService; +import dev.kruhlmann.imgfloat.service.CopyrightReportService; +import dev.kruhlmann.imgfloat.util.LogSanitizer; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import jakarta.validation.Valid; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping +public class CopyrightReportApiController { + + private static final Logger LOG = LoggerFactory.getLogger(CopyrightReportApiController.class); + + private final CopyrightReportService copyrightReportService; + private final AuthorizationService authorizationService; + + public CopyrightReportApiController( + CopyrightReportService copyrightReportService, + AuthorizationService authorizationService + ) { + this.copyrightReportService = copyrightReportService; + this.authorizationService = authorizationService; + } + + /** + * Public endpoint — no authentication required. Rate limiting is applied via + * {@link dev.kruhlmann.imgfloat.config.RateLimitInterceptor}. + */ + @PostMapping("/api/assets/{assetId}/copyright-reports") + public ResponseEntity submitReport( + @PathVariable("assetId") String assetId, + @Valid @RequestBody CopyrightReportRequest request + ) { + LOG.info("Copyright report submitted for asset {}", LogSanitizer.sanitize(assetId)); + var report = copyrightReportService.submitReport(assetId, request); + return ResponseEntity.ok(CopyrightReportView.fromReport(report)); + } + + @GetMapping("/api/copyright-reports") + @SecurityRequirement(name = "administrator") + public CopyrightReportPageView listReports( + @RequestParam(name = "status", required = false) CopyrightReportStatus status, + @RequestParam(name = "broadcaster", required = false) String broadcaster, + @RequestParam(name = "page", defaultValue = "0") int page, + @RequestParam(name = "size", defaultValue = "25") int size, + OAuth2AuthenticationToken oauthToken + ) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + authorizationService.userIsSystemAdministratorOrThrowHttpError(sessionUsername); + Page result = copyrightReportService + .listReports(status, broadcaster, page, size) + .map(CopyrightReportView::fromReport); + return new CopyrightReportPageView( + result.getContent(), + result.getNumber(), + result.getSize(), + result.getTotalElements(), + result.getTotalPages() + ); + } + + @GetMapping("/api/copyright-reports/{reportId}") + @SecurityRequirement(name = "administrator") + public CopyrightReportView getReport( + @PathVariable("reportId") String reportId, + OAuth2AuthenticationToken oauthToken + ) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + authorizationService.userIsSystemAdministratorOrThrowHttpError(sessionUsername); + return CopyrightReportView.fromReport(copyrightReportService.getReport(reportId)); + } + + @PostMapping("/api/copyright-reports/{reportId}/review") + @SecurityRequirement(name = "administrator") + public CopyrightReportView reviewReport( + @PathVariable("reportId") String reportId, + @Valid @RequestBody CopyrightReportReviewRequest request, + OAuth2AuthenticationToken oauthToken + ) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + authorizationService.userIsSystemAdministratorOrThrowHttpError(sessionUsername); + LOG.info( + "Copyright report {} reviewed by {} with action {}", + reportId, + LogSanitizer.sanitize(sessionUsername), + request.action() + ); + try { + return CopyrightReportView.fromReport( + copyrightReportService.reviewReport(reportId, request, sessionUsername) + ); + } catch (IllegalStateException e) { + throw new ResponseStatusException(BAD_REQUEST, e.getMessage(), e); + } + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/api/request/CopyrightReportRequest.java b/src/main/java/dev/kruhlmann/imgfloat/model/api/request/CopyrightReportRequest.java new file mode 100644 index 0000000..b939a6f --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/model/api/request/CopyrightReportRequest.java @@ -0,0 +1,28 @@ +package dev.kruhlmann.imgfloat.model.api.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record CopyrightReportRequest( + @NotBlank(message = "Claimant name is required") + @Size(max = 255, message = "Claimant name must be 255 characters or fewer") + String claimantName, + + @NotBlank(message = "Claimant email is required") + @Email(message = "Claimant email must be a valid email address") + @Size(max = 255, message = "Claimant email must be 255 characters or fewer") + String claimantEmail, + + @NotBlank(message = "Original work description is required") + @Size(max = 4000, message = "Original work description must be 4000 characters or fewer") + String originalWorkDescription, + + @NotBlank(message = "Description of infringement is required") + @Size(max = 4000, message = "Description of infringement must be 4000 characters or fewer") + String infringingDescription, + + @NotNull(message = "Good faith declaration is required") + Boolean goodFaithDeclaration +) {} diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/api/request/CopyrightReportReviewRequest.java b/src/main/java/dev/kruhlmann/imgfloat/model/api/request/CopyrightReportReviewRequest.java new file mode 100644 index 0000000..ab9a503 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/model/api/request/CopyrightReportReviewRequest.java @@ -0,0 +1,13 @@ +package dev.kruhlmann.imgfloat.model.api.request; + +import dev.kruhlmann.imgfloat.service.CopyrightReportAction; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record CopyrightReportReviewRequest( + @NotNull(message = "Action is required") + CopyrightReportAction action, + + @Size(max = 4000, message = "Resolution notes must be 4000 characters or fewer") + String resolutionNotes +) {} diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/api/response/CopyrightReportPageView.java b/src/main/java/dev/kruhlmann/imgfloat/model/api/response/CopyrightReportPageView.java new file mode 100644 index 0000000..37204a9 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/model/api/response/CopyrightReportPageView.java @@ -0,0 +1,11 @@ +package dev.kruhlmann.imgfloat.model.api.response; + +import java.util.List; + +public record CopyrightReportPageView( + List content, + int page, + int size, + long totalElements, + int totalPages +) {} diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/api/response/CopyrightReportView.java b/src/main/java/dev/kruhlmann/imgfloat/model/api/response/CopyrightReportView.java new file mode 100644 index 0000000..d4aa793 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/model/api/response/CopyrightReportView.java @@ -0,0 +1,42 @@ +package dev.kruhlmann.imgfloat.model.api.response; + +import dev.kruhlmann.imgfloat.model.db.imgfloat.CopyrightReport; +import dev.kruhlmann.imgfloat.model.db.imgfloat.CopyrightReportStatus; +import java.time.Instant; + +public record CopyrightReportView( + String id, + String assetId, + String broadcaster, + String claimantName, + String claimantEmail, + String originalWorkDescription, + String infringingDescription, + boolean goodFaithDeclaration, + CopyrightReportStatus status, + String resolutionNotes, + String resolvedBy, + Instant createdAt, + Instant updatedAt +) { + public static CopyrightReportView fromReport(CopyrightReport report) { + if (report == null) { + return null; + } + return new CopyrightReportView( + report.getId(), + report.getAssetId(), + report.getBroadcaster(), + report.getClaimantName(), + report.getClaimantEmail(), + report.getOriginalWorkDescription(), + report.getInfringingDescription(), + report.isGoodFaithDeclaration(), + report.getStatus(), + report.getResolutionNotes(), + report.getResolvedBy(), + report.getCreatedAt(), + report.getUpdatedAt() + ); + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/repository/CopyrightReportRepository.java b/src/main/java/dev/kruhlmann/imgfloat/repository/CopyrightReportRepository.java new file mode 100644 index 0000000..7417e6c --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/repository/CopyrightReportRepository.java @@ -0,0 +1,30 @@ +package dev.kruhlmann.imgfloat.repository; + +import dev.kruhlmann.imgfloat.model.db.imgfloat.CopyrightReport; +import dev.kruhlmann.imgfloat.model.db.imgfloat.CopyrightReportStatus; +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 CopyrightReportRepository extends JpaRepository { + + @Query(""" + SELECT r FROM CopyrightReport r + WHERE (:status IS NULL OR r.status = :status) + AND (:broadcaster IS NULL OR r.broadcaster = :broadcaster) + ORDER BY r.createdAt DESC + """) + Page searchReports( + @Param("status") CopyrightReportStatus status, + @Param("broadcaster") String broadcaster, + Pageable pageable + ); + + List findByBroadcasterAndStatusOrderByCreatedAtDesc(String broadcaster, CopyrightReportStatus status); + + void deleteByAssetId(String assetId); +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/AuthorizationService.java b/src/main/java/dev/kruhlmann/imgfloat/service/AuthorizationService.java index d589a69..7bec16f 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/AuthorizationService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/AuthorizationService.java @@ -4,6 +4,7 @@ import static org.springframework.http.HttpStatus.FORBIDDEN; import static org.springframework.http.HttpStatus.NOT_FOUND; import static org.springframework.http.HttpStatus.UNAUTHORIZED; +import dev.kruhlmann.imgfloat.repository.ChannelRepository; import dev.kruhlmann.imgfloat.util.LogSanitizer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -18,15 +19,18 @@ public class AuthorizationService { private final ChannelDirectoryService channelDirectoryService; private final SystemAdministratorService systemAdministratorService; + private final ChannelRepository channelRepository; private final boolean sysadminChannelAccessEnabled; public AuthorizationService( ChannelDirectoryService channelDirectoryService, SystemAdministratorService systemAdministratorService, + ChannelRepository channelRepository, @Value("${IMGFLOAT_SYSADMIN_CHANNEL_ACCESS_ENABLED:true}") boolean sysadminChannelAccessEnabled ) { this.channelDirectoryService = channelDirectoryService; this.systemAdministratorService = systemAdministratorService; + this.channelRepository = channelRepository; this.sysadminChannelAccessEnabled = sysadminChannelAccessEnabled; } @@ -115,4 +119,17 @@ public class AuthorizationService { } return systemAdministratorService.isSysadmin(sessionUsername); } + + public void channelIsNotBannedOrThrowHttpError(String broadcaster) { + if (broadcaster == null) { + return; + } + String normalized = broadcaster.toLowerCase(java.util.Locale.ROOT); + channelRepository.findById(normalized).ifPresent(channel -> { + if (channel.isBanned()) { + LOG.warn("Access denied for banned channel: {}", LogSanitizer.sanitize(normalized)); + throw new ResponseStatusException(FORBIDDEN, "This channel has been suspended"); + } + }); + } } diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/CopyrightReportAction.java b/src/main/java/dev/kruhlmann/imgfloat/service/CopyrightReportAction.java new file mode 100644 index 0000000..aebcce5 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/service/CopyrightReportAction.java @@ -0,0 +1,8 @@ +package dev.kruhlmann.imgfloat.service; + +public enum CopyrightReportAction { + DISMISS, + REMOVE_ASSET, + NOTIFY_BROADCASTER, + BAN_BROADCASTER +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/CopyrightReportService.java b/src/main/java/dev/kruhlmann/imgfloat/service/CopyrightReportService.java new file mode 100644 index 0000000..73bc2ec --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/service/CopyrightReportService.java @@ -0,0 +1,232 @@ +package dev.kruhlmann.imgfloat.service; + +import dev.kruhlmann.imgfloat.model.api.request.CopyrightReportRequest; +import dev.kruhlmann.imgfloat.model.api.request.CopyrightReportReviewRequest; +import dev.kruhlmann.imgfloat.model.db.imgfloat.Channel; +import dev.kruhlmann.imgfloat.model.db.imgfloat.CopyrightReport; +import dev.kruhlmann.imgfloat.model.db.imgfloat.CopyrightReportStatus; +import dev.kruhlmann.imgfloat.repository.AssetRepository; +import dev.kruhlmann.imgfloat.repository.ChannelRepository; +import dev.kruhlmann.imgfloat.repository.CopyrightReportRepository; +import dev.kruhlmann.imgfloat.util.LogSanitizer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +@Service +public class CopyrightReportService { + + private static final Logger LOG = LoggerFactory.getLogger(CopyrightReportService.class); + + private final CopyrightReportRepository copyrightReportRepository; + private final AssetRepository assetRepository; + private final ChannelRepository channelRepository; + private final ChannelDirectoryService channelDirectoryService; + private final AuditLogService auditLogService; + private final SimpMessagingTemplate messagingTemplate; + + public CopyrightReportService( + CopyrightReportRepository copyrightReportRepository, + AssetRepository assetRepository, + ChannelRepository channelRepository, + ChannelDirectoryService channelDirectoryService, + AuditLogService auditLogService, + SimpMessagingTemplate messagingTemplate + ) { + this.copyrightReportRepository = copyrightReportRepository; + this.assetRepository = assetRepository; + this.channelRepository = channelRepository; + this.channelDirectoryService = channelDirectoryService; + this.auditLogService = auditLogService; + this.messagingTemplate = messagingTemplate; + } + + @Transactional + public CopyrightReport submitReport(String assetId, CopyrightReportRequest request) { + var asset = assetRepository.findById(assetId).orElseThrow(() -> + new ResponseStatusException(NOT_FOUND, "Asset not found") + ); + if (request.goodFaithDeclaration() == null || !request.goodFaithDeclaration()) { + throw new ResponseStatusException(BAD_REQUEST, "Good faith declaration must be confirmed"); + } + CopyrightReport report = new CopyrightReport(); + report.setAssetId(assetId); + report.setBroadcaster(asset.getBroadcaster()); + report.setClaimantName(request.claimantName().trim()); + report.setClaimantEmail(request.claimantEmail().trim()); + report.setOriginalWorkDescription(request.originalWorkDescription().trim()); + report.setInfringingDescription(request.infringingDescription().trim()); + report.setGoodFaithDeclaration(true); + report.setStatus(CopyrightReportStatus.PENDING); + CopyrightReport saved = copyrightReportRepository.save(report); + auditLogService.recordEntry( + asset.getBroadcaster(), + "system", + "COPYRIGHT_REPORT_SUBMITTED", + "Copyright report submitted for asset " + assetId + " by claimant " + LogSanitizer.sanitize(request.claimantEmail()) + ); + LOG.info( + "Copyright report {} submitted for asset {} (broadcaster: {})", + saved.getId(), + LogSanitizer.sanitize(assetId), + LogSanitizer.sanitize(asset.getBroadcaster()) + ); + return saved; + } + + public Page listReports( + CopyrightReportStatus status, + String broadcaster, + int page, + int size + ) { + int safePage = Math.max(page, 0); + int safeSize = Math.min(Math.max(size, 1), 200); + PageRequest pageRequest = PageRequest.of(safePage, safeSize, Sort.by(Sort.Direction.DESC, "createdAt")); + String normalizedBroadcaster = (broadcaster != null && !broadcaster.isBlank()) + ? broadcaster.toLowerCase(java.util.Locale.ROOT) + : null; + return copyrightReportRepository.searchReports(status, normalizedBroadcaster, pageRequest); + } + + public CopyrightReport getReport(String reportId) { + return copyrightReportRepository.findById(reportId).orElseThrow(() -> + new ResponseStatusException(NOT_FOUND, "Report not found") + ); + } + + /** Returns all NOTIFIED reports for a broadcaster (their pending notices). */ + public List listNotices(String broadcaster) { + String normalized = broadcaster.toLowerCase(Locale.ROOT); + return copyrightReportRepository.findByBroadcasterAndStatusOrderByCreatedAtDesc( + normalized, CopyrightReportStatus.NOTIFIED + ); + } + + /** Broadcaster acknowledges a notice — transitions it to RESOLVED. */ + @Transactional + public void dismissNotice(String reportId, String broadcaster) { + String normalized = broadcaster.toLowerCase(Locale.ROOT); + CopyrightReport report = copyrightReportRepository.findById(reportId).orElseThrow(() -> + new ResponseStatusException(NOT_FOUND, "Notice not found") + ); + if (!report.getBroadcaster().equals(normalized)) { + throw new ResponseStatusException(org.springframework.http.HttpStatus.FORBIDDEN, "Not your notice"); + } + if (report.getStatus() != CopyrightReportStatus.NOTIFIED) { + throw new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "Notice is not in NOTIFIED state"); + } + report.setStatus(CopyrightReportStatus.RESOLVED); + copyrightReportRepository.save(report); + auditLogService.recordEntry( + normalized, + normalized, + "COPYRIGHT_NOTICE_DISMISSED", + "Broadcaster acknowledged and dismissed copyright notice for report " + reportId + ); + } + + @Transactional + public CopyrightReport reviewReport(String reportId, CopyrightReportReviewRequest request, String reviewerUsername) { + CopyrightReport report = copyrightReportRepository.findById(reportId).orElseThrow(() -> + new ResponseStatusException(NOT_FOUND, "Report not found") + ); + String broadcaster = report.getBroadcaster(); + + switch (request.action()) { + case DISMISS -> { + report.setStatus(CopyrightReportStatus.DISMISSED); + report.setResolutionNotes(request.resolutionNotes()); + report.setResolvedBy(reviewerUsername); + auditLogService.recordEntry( + broadcaster, + reviewerUsername, + "COPYRIGHT_REPORT_DISMISSED", + "Report " + reportId + " dismissed" + ); + LOG.info("Copyright report {} dismissed by {}", reportId, LogSanitizer.sanitize(reviewerUsername)); + } + case REMOVE_ASSET -> { + boolean deleted = channelDirectoryService.deleteAsset(report.getAssetId(), reviewerUsername); + if (!deleted) { + throw new ResponseStatusException(NOT_FOUND, "Asset not found or already deleted"); + } + report.setStatus(CopyrightReportStatus.RESOLVED); + report.setResolutionNotes(request.resolutionNotes()); + report.setResolvedBy(reviewerUsername); + auditLogService.recordEntry( + broadcaster, + reviewerUsername, + "ASSET_REMOVED_COPYRIGHT", + "Asset " + report.getAssetId() + " removed following copyright report " + reportId + ); + LOG.info("Asset {} removed for copyright by {} (report: {})", + LogSanitizer.sanitize(report.getAssetId()), + LogSanitizer.sanitize(reviewerUsername), + reportId + ); + } + case NOTIFY_BROADCASTER -> { + report.setStatus(CopyrightReportStatus.NOTIFIED); + report.setResolutionNotes(request.resolutionNotes()); + report.setResolvedBy(reviewerUsername); + messagingTemplate.convertAndSend( + "/topic/channel/" + broadcaster, + Map.of( + "type", "COPYRIGHT_WARNING", + "reportId", reportId, + "assetId", report.getAssetId(), + "message", "A copyright infringement report has been filed against one of your assets. Please review." + ) + ); + auditLogService.recordEntry( + broadcaster, + reviewerUsername, + "BROADCASTER_NOTIFIED_COPYRIGHT", + "Broadcaster notified of copyright report " + reportId + " for asset " + report.getAssetId() + ); + LOG.info("Broadcaster {} notified of copyright report {} by {}", + LogSanitizer.sanitize(broadcaster), + reportId, + LogSanitizer.sanitize(reviewerUsername) + ); + } + case BAN_BROADCASTER -> { + Channel channel = channelRepository.findById(broadcaster).orElseThrow(() -> + new ResponseStatusException(NOT_FOUND, "Channel not found") + ); + channel.setBanned(true); + channelRepository.save(channel); + report.setStatus(CopyrightReportStatus.RESOLVED); + report.setResolutionNotes(request.resolutionNotes()); + report.setResolvedBy(reviewerUsername); + auditLogService.recordEntry( + broadcaster, + reviewerUsername, + "BROADCASTER_BANNED_COPYRIGHT", + "Broadcaster banned following copyright report " + reportId + ); + LOG.info("Broadcaster {} banned for copyright by {} (report: {})", + LogSanitizer.sanitize(broadcaster), + LogSanitizer.sanitize(reviewerUsername), + reportId + ); + } + } + + return copyrightReportRepository.save(report); + } +} diff --git a/src/test/java/dev/kruhlmann/imgfloat/service/AuthorizationServiceTest.java b/src/test/java/dev/kruhlmann/imgfloat/service/AuthorizationServiceTest.java index 94114a7..2bad4bf 100644 --- a/src/test/java/dev/kruhlmann/imgfloat/service/AuthorizationServiceTest.java +++ b/src/test/java/dev/kruhlmann/imgfloat/service/AuthorizationServiceTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.*; +import dev.kruhlmann.imgfloat.repository.ChannelRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.web.server.ResponseStatusException; @@ -12,6 +13,7 @@ class AuthorizationServiceTest { private ChannelDirectoryService channelDirectoryService; private SystemAdministratorService sysadminService; + private ChannelRepository channelRepository; private AuthorizationService authorizationService; private AuthorizationService authorizationServiceSysadminDisabled; @@ -19,8 +21,9 @@ class AuthorizationServiceTest { void setup() { channelDirectoryService = mock(ChannelDirectoryService.class); sysadminService = mock(SystemAdministratorService.class); - authorizationService = new AuthorizationService(channelDirectoryService, sysadminService, true); - authorizationServiceSysadminDisabled = new AuthorizationService(channelDirectoryService, sysadminService, false); + channelRepository = mock(ChannelRepository.class); + authorizationService = new AuthorizationService(channelDirectoryService, sysadminService, channelRepository, true); + authorizationServiceSysadminDisabled = new AuthorizationService(channelDirectoryService, sysadminService, channelRepository, false); } // --- userMatchesSessionUsernameOrThrowHttpError ---