mirror of
https://github.com/imgfloat/server.git
synced 2026-05-08 10:19:35 +00:00
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:
@@ -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");
|
||||
}
|
||||
}
|
||||
+73
@@ -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
|
||||
) {}
|
||||
+13
@@ -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 ---
|
||||
|
||||
Reference in New Issue
Block a user