From b696f51663a74c3c116c4b63af0bc4b31f723c1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Fri, 24 Apr 2026 16:22:47 +0200 Subject: [PATCH] refactor: extract ChannelScriptAssetApiController from ChannelApiController --- .../controller/ChannelApiController.java | 203 +------------- .../ChannelScriptAssetApiController.java | 259 ++++++++++++++++++ 2 files changed, 264 insertions(+), 198 deletions(-) create mode 100644 src/main/java/dev/kruhlmann/imgfloat/controller/ChannelScriptAssetApiController.java diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java index 6760713..9aa42fd 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java @@ -6,16 +6,15 @@ import static org.springframework.http.HttpStatus.NOT_FOUND; import dev.kruhlmann.imgfloat.model.api.request.AssetOrderRequest; import dev.kruhlmann.imgfloat.model.api.request.CanvasSettingsRequest; import dev.kruhlmann.imgfloat.model.api.request.ChannelScriptSettingsRequest; -import dev.kruhlmann.imgfloat.model.api.request.CodeAssetRequest; import dev.kruhlmann.imgfloat.model.api.request.PlaybackRequest; import dev.kruhlmann.imgfloat.model.api.request.TransformRequest; import dev.kruhlmann.imgfloat.model.api.request.VisibilityRequest; import dev.kruhlmann.imgfloat.model.api.response.AssetView; -import dev.kruhlmann.imgfloat.model.api.response.ScriptAssetAttachmentView; import dev.kruhlmann.imgfloat.model.OauthSessionUser; import dev.kruhlmann.imgfloat.service.AuthorizationService; import dev.kruhlmann.imgfloat.service.ChannelDirectoryService; import dev.kruhlmann.imgfloat.service.ChannelSettingsService; +import dev.kruhlmann.imgfloat.service.media.MediaDetectionService; import dev.kruhlmann.imgfloat.util.LogSanitizer; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import jakarta.validation.Valid; @@ -34,14 +33,14 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; /** - * Manages assets, canvas settings, and script attachments for a specific broadcaster channel. - * Admin management has been extracted to {@link ChannelAdminApiController}. + * Manages assets, canvas settings, and playback for a specific broadcaster channel. + * Admin management is handled by {@link ChannelAdminApiController}. + * Script asset management (code assets, logos, attachments) is handled by {@link ChannelScriptAssetApiController}. */ @RestController @RequestMapping("/api/channels/{broadcaster}") @@ -147,48 +146,6 @@ public class ChannelApiController { } } - @PostMapping("/assets/code") - public ResponseEntity createCodeAsset( - @PathVariable("broadcaster") String broadcaster, - @Valid @RequestBody CodeAssetRequest request, - OAuth2AuthenticationToken oauthToken - ) { - String sessionUsername = OauthSessionUser.from(oauthToken).login(); - String logBroadcaster = LogSanitizer.sanitize(broadcaster); - String logSessionUsername = LogSanitizer.sanitize(sessionUsername); - authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError( - broadcaster, - sessionUsername - ); - LOG.info("Creating custom script for {} by {}", logBroadcaster, logSessionUsername); - return channelDirectoryService - .createCodeAsset(broadcaster, request, sessionUsername) - .map(ResponseEntity::ok) - .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to save custom script")); - } - - @PutMapping("/assets/{assetId}/code") - public ResponseEntity updateCodeAsset( - @PathVariable("broadcaster") String broadcaster, - @PathVariable("assetId") String assetId, - @Valid @RequestBody CodeAssetRequest request, - OAuth2AuthenticationToken oauthToken - ) { - String sessionUsername = OauthSessionUser.from(oauthToken).login(); - String logBroadcaster = LogSanitizer.sanitize(broadcaster); - String logSessionUsername = LogSanitizer.sanitize(sessionUsername); - String logAssetId = LogSanitizer.sanitize(assetId); - authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError( - broadcaster, - sessionUsername - ); - LOG.info("Updating custom script {} for {} by {}", logAssetId, logBroadcaster, logSessionUsername); - return channelDirectoryService - .updateCodeAsset(broadcaster, assetId, request, sessionUsername) - .map(ResponseEntity::ok) - .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found")); - } - @PutMapping("/assets/{assetId}/transform") public ResponseEntity transform( @PathVariable("broadcaster") String broadcaster, @@ -315,56 +272,6 @@ public class ChannelApiController { .orElseThrow(this::createAsset404); } - @GetMapping("/script-assets/{assetId}/attachments/{attachmentId}/content") - public ResponseEntity getScriptAttachmentContent( - @PathVariable("broadcaster") String broadcaster, - @PathVariable("assetId") String assetId, - @PathVariable("attachmentId") String attachmentId - ) { - String logBroadcaster = LogSanitizer.sanitize(broadcaster); - String logAssetId = LogSanitizer.sanitize(assetId); - String logAttachmentId = LogSanitizer.sanitize(attachmentId); - LOG.debug( - "Serving script attachment {} for asset {} for broadcaster {}", - logAttachmentId, - logAssetId, - logBroadcaster - ); - return channelDirectoryService - .getScriptAttachmentContent(broadcaster, assetId, attachmentId) - .map((content) -> - ResponseEntity.ok() - .header("X-Content-Type-Options", "nosniff") - .header(HttpHeaders.CONTENT_DISPOSITION, contentDispositionFor(content.mediaType())) - .contentType(MediaType.parseMediaType(content.mediaType())) - .body(content.bytes()) - ) - .orElseThrow(this::createAsset404); - } - - @GetMapping("/assets/{assetId}/logo") - public ResponseEntity getScriptLogo( - @PathVariable("broadcaster") String broadcaster, - @PathVariable("assetId") String assetId, - OAuth2AuthenticationToken oauthToken - ) { - String sessionUsername = OauthSessionUser.from(oauthToken).login(); - authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError( - broadcaster, - sessionUsername - ); - return channelDirectoryService - .getScriptLogoContent(broadcaster, assetId) - .map((content) -> - ResponseEntity.ok() - .header("X-Content-Type-Options", "nosniff") - .header(HttpHeaders.CONTENT_DISPOSITION, contentDispositionFor(content.mediaType())) - .contentType(MediaType.parseMediaType(content.mediaType())) - .body(content.bytes()) - ) - .orElseThrow(this::createAsset404); - } - @GetMapping("/assets/{assetId}/preview") public ResponseEntity getAssetPreview( @PathVariable("broadcaster") String broadcaster, @@ -407,108 +314,8 @@ public class ChannelApiController { return ResponseEntity.ok().build(); } - @GetMapping("/assets/{assetId}/attachments") - public Collection listScriptAttachments( - @PathVariable("broadcaster") String broadcaster, - @PathVariable("assetId") String assetId, - OAuth2AuthenticationToken oauthToken - ) { - String sessionUsername = OauthSessionUser.from(oauthToken).login(); - authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError( - broadcaster, - sessionUsername - ); - return channelDirectoryService.listScriptAttachments(broadcaster, assetId); - } - - @PostMapping(value = "/assets/{assetId}/attachments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ResponseEntity createScriptAttachment( - @PathVariable("broadcaster") String broadcaster, - @PathVariable("assetId") String assetId, - @RequestPart("file") MultipartFile file, - OAuth2AuthenticationToken oauthToken - ) { - String sessionUsername = OauthSessionUser.from(oauthToken).login(); - authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError( - broadcaster, - sessionUsername - ); - if (file == null || file.isEmpty()) { - throw new ResponseStatusException(BAD_REQUEST, "Attachment file is required"); - } - try { - return channelDirectoryService - .createScriptAttachment(broadcaster, assetId, file, sessionUsername) - .map(ResponseEntity::ok) - .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to save attachment")); - } catch (IOException e) { - LOG.error("Failed to process attachment upload for {} by {}", broadcaster, sessionUsername, e); - throw new ResponseStatusException(BAD_REQUEST, "Failed to process attachment", e); - } - } - - @PostMapping(value = "/assets/{assetId}/logo", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ResponseEntity updateScriptLogo( - @PathVariable("broadcaster") String broadcaster, - @PathVariable("assetId") String assetId, - @RequestPart("file") MultipartFile file, - OAuth2AuthenticationToken oauthToken - ) { - String sessionUsername = OauthSessionUser.from(oauthToken).login(); - authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError( - broadcaster, - sessionUsername - ); - if (file == null || file.isEmpty()) { - throw new ResponseStatusException(BAD_REQUEST, "Logo file is required"); - } - try { - return channelDirectoryService - .updateScriptLogo(broadcaster, assetId, file, sessionUsername) - .map(ResponseEntity::ok) - .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to save logo")); - } catch (IOException e) { - LOG.error("Failed to process logo upload for {} by {}", broadcaster, sessionUsername, e); - throw new ResponseStatusException(BAD_REQUEST, "Failed to process logo", e); - } - } - - @DeleteMapping("/assets/{assetId}/logo") - public ResponseEntity deleteScriptLogo( - @PathVariable("broadcaster") String broadcaster, - @PathVariable("assetId") String assetId, - OAuth2AuthenticationToken oauthToken - ) { - String sessionUsername = OauthSessionUser.from(oauthToken).login(); - authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError( - broadcaster, - sessionUsername - ); - channelDirectoryService.clearScriptLogo(broadcaster, assetId, sessionUsername); - return ResponseEntity.ok().build(); - } - - @DeleteMapping("/assets/{assetId}/attachments/{attachmentId}") - public ResponseEntity deleteScriptAttachment( - @PathVariable("broadcaster") String broadcaster, - @PathVariable("assetId") String assetId, - @PathVariable("attachmentId") String attachmentId, - OAuth2AuthenticationToken oauthToken - ) { - String sessionUsername = OauthSessionUser.from(oauthToken).login(); - authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError( - broadcaster, - sessionUsername - ); - boolean removed = channelDirectoryService.deleteScriptAttachment(broadcaster, assetId, attachmentId, sessionUsername); - if (!removed) { - throw createAsset404(); - } - return ResponseEntity.ok().build(); - } - private String contentDispositionFor(String mediaType) { - if (dev.kruhlmann.imgfloat.service.media.MediaDetectionService.isInlineDisplayType(mediaType)) { + if (MediaDetectionService.isInlineDisplayType(mediaType)) { return "inline"; } return "attachment"; diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelScriptAssetApiController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelScriptAssetApiController.java new file mode 100644 index 0000000..5fd4303 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelScriptAssetApiController.java @@ -0,0 +1,259 @@ +package dev.kruhlmann.imgfloat.controller; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +import dev.kruhlmann.imgfloat.model.api.request.CodeAssetRequest; +import dev.kruhlmann.imgfloat.model.api.response.AssetView; +import dev.kruhlmann.imgfloat.model.api.response.ScriptAssetAttachmentView; +import dev.kruhlmann.imgfloat.model.OauthSessionUser; +import dev.kruhlmann.imgfloat.service.AuthorizationService; +import dev.kruhlmann.imgfloat.service.ChannelDirectoryService; +import dev.kruhlmann.imgfloat.service.media.MediaDetectionService; +import dev.kruhlmann.imgfloat.util.LogSanitizer; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import jakarta.validation.Valid; +import java.io.IOException; +import java.util.Collection; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.web.bind.annotation.DeleteMapping; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +/** + * Manages script assets, logos, and attachments for a specific broadcaster channel. + * General asset management is handled by {@link ChannelApiController}. + */ +@RestController +@RequestMapping("/api/channels/{broadcaster}") +@SecurityRequirement(name = "twitchOAuth") +public class ChannelScriptAssetApiController { + + private static final Logger LOG = LoggerFactory.getLogger(ChannelScriptAssetApiController.class); + private final ChannelDirectoryService channelDirectoryService; + private final AuthorizationService authorizationService; + + public ChannelScriptAssetApiController( + ChannelDirectoryService channelDirectoryService, + AuthorizationService authorizationService + ) { + this.channelDirectoryService = channelDirectoryService; + this.authorizationService = authorizationService; + } + + @PostMapping("/assets/code") + public ResponseEntity createCodeAsset( + @PathVariable("broadcaster") String broadcaster, + @Valid @RequestBody CodeAssetRequest request, + OAuth2AuthenticationToken oauthToken + ) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + String logBroadcaster = LogSanitizer.sanitize(broadcaster); + String logSessionUsername = LogSanitizer.sanitize(sessionUsername); + authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError( + broadcaster, + sessionUsername + ); + LOG.info("Creating custom script for {} by {}", logBroadcaster, logSessionUsername); + return channelDirectoryService + .createCodeAsset(broadcaster, request, sessionUsername) + .map(ResponseEntity::ok) + .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to save custom script")); + } + + @PutMapping("/assets/{assetId}/code") + public ResponseEntity updateCodeAsset( + @PathVariable("broadcaster") String broadcaster, + @PathVariable("assetId") String assetId, + @Valid @RequestBody CodeAssetRequest request, + OAuth2AuthenticationToken oauthToken + ) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + String logBroadcaster = LogSanitizer.sanitize(broadcaster); + String logSessionUsername = LogSanitizer.sanitize(sessionUsername); + String logAssetId = LogSanitizer.sanitize(assetId); + authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError( + broadcaster, + sessionUsername + ); + LOG.info("Updating custom script {} for {} by {}", logAssetId, logBroadcaster, logSessionUsername); + return channelDirectoryService + .updateCodeAsset(broadcaster, assetId, request, sessionUsername) + .map(ResponseEntity::ok) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found")); + } + + @GetMapping("/script-assets/{assetId}/attachments/{attachmentId}/content") + public ResponseEntity getScriptAttachmentContent( + @PathVariable("broadcaster") String broadcaster, + @PathVariable("assetId") String assetId, + @PathVariable("attachmentId") String attachmentId + ) { + String logBroadcaster = LogSanitizer.sanitize(broadcaster); + String logAssetId = LogSanitizer.sanitize(assetId); + String logAttachmentId = LogSanitizer.sanitize(attachmentId); + LOG.debug( + "Serving script attachment {} for asset {} for broadcaster {}", + logAttachmentId, + logAssetId, + logBroadcaster + ); + return channelDirectoryService + .getScriptAttachmentContent(broadcaster, assetId, attachmentId) + .map((content) -> + ResponseEntity.ok() + .header("X-Content-Type-Options", "nosniff") + .header(HttpHeaders.CONTENT_DISPOSITION, contentDispositionFor(content.mediaType())) + .contentType(MediaType.parseMediaType(content.mediaType())) + .body(content.bytes()) + ) + .orElseThrow(this::createAsset404); + } + + @GetMapping("/assets/{assetId}/logo") + public ResponseEntity getScriptLogo( + @PathVariable("broadcaster") String broadcaster, + @PathVariable("assetId") String assetId, + OAuth2AuthenticationToken oauthToken + ) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError( + broadcaster, + sessionUsername + ); + return channelDirectoryService + .getScriptLogoContent(broadcaster, assetId) + .map((content) -> + ResponseEntity.ok() + .header("X-Content-Type-Options", "nosniff") + .header(HttpHeaders.CONTENT_DISPOSITION, contentDispositionFor(content.mediaType())) + .contentType(MediaType.parseMediaType(content.mediaType())) + .body(content.bytes()) + ) + .orElseThrow(this::createAsset404); + } + + @GetMapping("/assets/{assetId}/attachments") + public Collection listScriptAttachments( + @PathVariable("broadcaster") String broadcaster, + @PathVariable("assetId") String assetId, + OAuth2AuthenticationToken oauthToken + ) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError( + broadcaster, + sessionUsername + ); + return channelDirectoryService.listScriptAttachments(broadcaster, assetId); + } + + @PostMapping(value = "/assets/{assetId}/attachments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity createScriptAttachment( + @PathVariable("broadcaster") String broadcaster, + @PathVariable("assetId") String assetId, + @RequestPart("file") MultipartFile file, + OAuth2AuthenticationToken oauthToken + ) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError( + broadcaster, + sessionUsername + ); + if (file == null || file.isEmpty()) { + throw new ResponseStatusException(BAD_REQUEST, "Attachment file is required"); + } + try { + return channelDirectoryService + .createScriptAttachment(broadcaster, assetId, file, sessionUsername) + .map(ResponseEntity::ok) + .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to save attachment")); + } catch (IOException e) { + LOG.error("Failed to process attachment upload for {} by {}", broadcaster, sessionUsername, e); + throw new ResponseStatusException(BAD_REQUEST, "Failed to process attachment", e); + } + } + + @PostMapping(value = "/assets/{assetId}/logo", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity updateScriptLogo( + @PathVariable("broadcaster") String broadcaster, + @PathVariable("assetId") String assetId, + @RequestPart("file") MultipartFile file, + OAuth2AuthenticationToken oauthToken + ) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError( + broadcaster, + sessionUsername + ); + if (file == null || file.isEmpty()) { + throw new ResponseStatusException(BAD_REQUEST, "Logo file is required"); + } + try { + return channelDirectoryService + .updateScriptLogo(broadcaster, assetId, file, sessionUsername) + .map(ResponseEntity::ok) + .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to save logo")); + } catch (IOException e) { + LOG.error("Failed to process logo upload for {} by {}", broadcaster, sessionUsername, e); + throw new ResponseStatusException(BAD_REQUEST, "Failed to process logo", e); + } + } + + @DeleteMapping("/assets/{assetId}/logo") + public ResponseEntity deleteScriptLogo( + @PathVariable("broadcaster") String broadcaster, + @PathVariable("assetId") String assetId, + OAuth2AuthenticationToken oauthToken + ) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError( + broadcaster, + sessionUsername + ); + channelDirectoryService.clearScriptLogo(broadcaster, assetId, sessionUsername); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/assets/{assetId}/attachments/{attachmentId}") + public ResponseEntity deleteScriptAttachment( + @PathVariable("broadcaster") String broadcaster, + @PathVariable("assetId") String assetId, + @PathVariable("attachmentId") String attachmentId, + OAuth2AuthenticationToken oauthToken + ) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError( + broadcaster, + sessionUsername + ); + boolean removed = channelDirectoryService.deleteScriptAttachment(broadcaster, assetId, attachmentId, sessionUsername); + if (!removed) { + throw createAsset404(); + } + return ResponseEntity.ok().build(); + } + + private String contentDispositionFor(String mediaType) { + if (MediaDetectionService.isInlineDisplayType(mediaType)) { + return "inline"; + } + return "attachment"; + } + + private ResponseStatusException createAsset404() { + return new ResponseStatusException(NOT_FOUND, "Asset not found"); + } +}