refactor: extract ChannelScriptAssetApiController from ChannelApiController

This commit is contained in:
2026-04-24 16:22:47 +02:00
parent a1c9e471e0
commit b696f51663
2 changed files with 264 additions and 198 deletions
@@ -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.AssetOrderRequest;
import dev.kruhlmann.imgfloat.model.api.request.CanvasSettingsRequest; import dev.kruhlmann.imgfloat.model.api.request.CanvasSettingsRequest;
import dev.kruhlmann.imgfloat.model.api.request.ChannelScriptSettingsRequest; 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.PlaybackRequest;
import dev.kruhlmann.imgfloat.model.api.request.TransformRequest; import dev.kruhlmann.imgfloat.model.api.request.TransformRequest;
import dev.kruhlmann.imgfloat.model.api.request.VisibilityRequest; import dev.kruhlmann.imgfloat.model.api.request.VisibilityRequest;
import dev.kruhlmann.imgfloat.model.api.response.AssetView; 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.model.OauthSessionUser;
import dev.kruhlmann.imgfloat.service.AuthorizationService; import dev.kruhlmann.imgfloat.service.AuthorizationService;
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService; import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
import dev.kruhlmann.imgfloat.service.ChannelSettingsService; import dev.kruhlmann.imgfloat.service.ChannelSettingsService;
import dev.kruhlmann.imgfloat.service.media.MediaDetectionService;
import dev.kruhlmann.imgfloat.util.LogSanitizer; import dev.kruhlmann.imgfloat.util.LogSanitizer;
import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import jakarta.validation.Valid; 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.PutMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; 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.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
/** /**
* Manages assets, canvas settings, and script attachments for a specific broadcaster channel. * Manages assets, canvas settings, and playback for a specific broadcaster channel.
* Admin management has been extracted to {@link ChannelAdminApiController}. * Admin management is handled by {@link ChannelAdminApiController}.
* Script asset management (code assets, logos, attachments) is handled by {@link ChannelScriptAssetApiController}.
*/ */
@RestController @RestController
@RequestMapping("/api/channels/{broadcaster}") @RequestMapping("/api/channels/{broadcaster}")
@@ -147,48 +146,6 @@ public class ChannelApiController {
} }
} }
@PostMapping("/assets/code")
public ResponseEntity<AssetView> 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<AssetView> 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") @PutMapping("/assets/{assetId}/transform")
public ResponseEntity<AssetView> transform( public ResponseEntity<AssetView> transform(
@PathVariable("broadcaster") String broadcaster, @PathVariable("broadcaster") String broadcaster,
@@ -315,56 +272,6 @@ public class ChannelApiController {
.orElseThrow(this::createAsset404); .orElseThrow(this::createAsset404);
} }
@GetMapping("/script-assets/{assetId}/attachments/{attachmentId}/content")
public ResponseEntity<byte[]> 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<byte[]> 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") @GetMapping("/assets/{assetId}/preview")
public ResponseEntity<byte[]> getAssetPreview( public ResponseEntity<byte[]> getAssetPreview(
@PathVariable("broadcaster") String broadcaster, @PathVariable("broadcaster") String broadcaster,
@@ -407,108 +314,8 @@ public class ChannelApiController {
return ResponseEntity.ok().build(); return ResponseEntity.ok().build();
} }
@GetMapping("/assets/{assetId}/attachments")
public Collection<ScriptAssetAttachmentView> 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<ScriptAssetAttachmentView> 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<AssetView> 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<Void> 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<Void> 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) { private String contentDispositionFor(String mediaType) {
if (dev.kruhlmann.imgfloat.service.media.MediaDetectionService.isInlineDisplayType(mediaType)) { if (MediaDetectionService.isInlineDisplayType(mediaType)) {
return "inline"; return "inline";
} }
return "attachment"; return "attachment";
@@ -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<AssetView> 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<AssetView> 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<byte[]> 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<byte[]> 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<ScriptAssetAttachmentView> 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<ScriptAssetAttachmentView> 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<AssetView> 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<Void> 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<Void> 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");
}
}