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
This commit is contained in:
2026-04-28 14:46:53 +02:00
parent c17c1b469e
commit 05a7c5d2b5
15 changed files with 668 additions and 3 deletions
@@ -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<String, BucketEntry> 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();
}
}
@@ -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()
@@ -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");
}
}
@@ -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<CopyrightReportView> 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<Void> 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();
}
}
@@ -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");
@@ -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<CopyrightReportView> 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<CopyrightReportView> 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);
}
}
}
@@ -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
) {}
@@ -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
) {}
@@ -0,0 +1,11 @@
package dev.kruhlmann.imgfloat.model.api.response;
import java.util.List;
public record CopyrightReportPageView(
List<CopyrightReportView> content,
int page,
int size,
long totalElements,
int totalPages
) {}
@@ -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()
);
}
}
@@ -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<CopyrightReport, String> {
@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<CopyrightReport> searchReports(
@Param("status") CopyrightReportStatus status,
@Param("broadcaster") String broadcaster,
Pageable pageable
);
List<CopyrightReport> findByBroadcasterAndStatusOrderByCreatedAtDesc(String broadcaster, CopyrightReportStatus status);
void deleteByAssetId(String assetId);
}
@@ -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");
}
});
}
}
@@ -0,0 +1,8 @@
package dev.kruhlmann.imgfloat.service;
public enum CopyrightReportAction {
DISMISS,
REMOVE_ASSET,
NOTIFY_BROADCASTER,
BAN_BROADCASTER
}
@@ -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<CopyrightReport> 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<CopyrightReport> 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);
}
}
@@ -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 ---