From 96b1cf501c81ad443b9beecb36993b99b2132a0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Fri, 9 Jan 2026 18:42:37 +0100 Subject: [PATCH] Add script asset sub-assets --- .../imgfloat/config/SchemaMigration.java | 12 ++ .../controller/ChannelApiController.java | 87 ++++++++ .../kruhlmann/imgfloat/model/AssetView.java | 5 + .../kruhlmann/imgfloat/model/ScriptAsset.java | 13 ++ .../imgfloat/model/ScriptAssetAttachment.java | 103 +++++++++ .../model/ScriptAssetAttachmentView.java | 28 +++ .../ScriptAssetAttachmentRepository.java | 14 ++ .../imgfloat/service/AssetCleanupService.java | 23 +- .../service/ChannelDirectoryService.java | 196 +++++++++++++++++- src/main/resources/assets/icon/favicon.ico | 3 - .../resources/static/css/customAssets.css | 51 +++++ .../resources/static/js/broadcast/renderer.js | 17 ++ .../static/js/broadcast/script-worker.js | 58 +++++- src/main/resources/static/js/customAssets.js | 148 ++++++++++++- src/main/resources/templates/admin.html | 25 ++- .../imgfloat/ChannelDirectoryServiceTest.java | 4 + 16 files changed, 770 insertions(+), 17 deletions(-) create mode 100644 src/main/java/dev/kruhlmann/imgfloat/model/ScriptAssetAttachment.java create mode 100644 src/main/java/dev/kruhlmann/imgfloat/model/ScriptAssetAttachmentView.java create mode 100644 src/main/java/dev/kruhlmann/imgfloat/repository/ScriptAssetAttachmentRepository.java delete mode 100644 src/main/resources/assets/icon/favicon.ico diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/SchemaMigration.java b/src/main/java/dev/kruhlmann/imgfloat/config/SchemaMigration.java index 3c59a4b..e69abbb 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/config/SchemaMigration.java +++ b/src/main/java/dev/kruhlmann/imgfloat/config/SchemaMigration.java @@ -134,6 +134,18 @@ public class SchemaMigration implements ApplicationRunner { ) """ ); + jdbcTemplate.execute( + """ + CREATE TABLE IF NOT EXISTS script_asset_attachments ( + id TEXT PRIMARY KEY, + script_asset_id TEXT NOT NULL, + name TEXT NOT NULL, + media_type TEXT, + original_media_type TEXT, + asset_type TEXT + ) + """ + ); backfillAssetTypes(assetColumns); } catch (DataAccessException ex) { logger.warn("Unable to ensure asset type tables", ex); diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java index 69d7b0f..09b88db 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java @@ -10,6 +10,7 @@ import dev.kruhlmann.imgfloat.model.CanvasSettingsRequest; import dev.kruhlmann.imgfloat.model.CodeAssetRequest; import dev.kruhlmann.imgfloat.model.OauthSessionUser; import dev.kruhlmann.imgfloat.model.PlaybackRequest; +import dev.kruhlmann.imgfloat.model.ScriptAssetAttachmentView; import dev.kruhlmann.imgfloat.model.TransformRequest; import dev.kruhlmann.imgfloat.model.TwitchUserProfile; import dev.kruhlmann.imgfloat.model.VisibilityRequest; @@ -387,6 +388,33 @@ public class ChannelApiController { .orElseThrow(() -> 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(() -> createAsset404()); + } + @GetMapping("/assets/{assetId}/preview") public ResponseEntity getAssetPreview( @PathVariable("broadcaster") String broadcaster, @@ -439,6 +467,65 @@ 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, + @org.springframework.web.bind.annotation.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) + .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); + } + } + + @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); + if (!removed) { + throw createAsset404(); + } + return ResponseEntity.ok().build(); + } + private ResponseStatusException createAsset404() { return new ResponseStatusException(NOT_FOUND, "Asset not found"); } diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/AssetView.java b/src/main/java/dev/kruhlmann/imgfloat/model/AssetView.java index efb9946..c4c9e17 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/AssetView.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/AssetView.java @@ -1,6 +1,7 @@ package dev.kruhlmann.imgfloat.model; import java.time.Instant; +import java.util.List; public record AssetView( String id, @@ -18,6 +19,7 @@ public record AssetView( String mediaType, String originalMediaType, AssetType assetType, + List scriptAttachments, Integer zIndex, Boolean audioLoop, Integer audioDelayMillis, @@ -47,6 +49,7 @@ public record AssetView( visual.getMediaType(), visual.getOriginalMediaType(), asset.getAssetType(), + null, visual.getZIndex(), null, null, @@ -78,6 +81,7 @@ public record AssetView( audio.getOriginalMediaType(), asset.getAssetType(), null, + null, audio.isAudioLoop(), audio.getAudioDelayMillis(), audio.getAudioSpeed(), @@ -107,6 +111,7 @@ public record AssetView( script.getMediaType(), script.getOriginalMediaType(), asset.getAssetType(), + script.getAttachments(), null, null, null, diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/ScriptAsset.java b/src/main/java/dev/kruhlmann/imgfloat/model/ScriptAsset.java index d0f06c4..72cd21e 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/ScriptAsset.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/ScriptAsset.java @@ -6,6 +6,8 @@ import jakarta.persistence.Id; import jakarta.persistence.PrePersist; import jakarta.persistence.PreUpdate; import jakarta.persistence.Table; +import jakarta.persistence.Transient; +import java.util.List; @Entity @Table(name = "script_assets") @@ -20,6 +22,9 @@ public class ScriptAsset { private String mediaType; private String originalMediaType; + @Transient + private List attachments = List.of(); + public ScriptAsset() {} public ScriptAsset(String assetId, String name) { @@ -66,4 +71,12 @@ public class ScriptAsset { public void setOriginalMediaType(String originalMediaType) { this.originalMediaType = originalMediaType; } + + public List getAttachments() { + return attachments == null ? List.of() : attachments; + } + + public void setAttachments(List attachments) { + this.attachments = attachments; + } } diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/ScriptAssetAttachment.java b/src/main/java/dev/kruhlmann/imgfloat/model/ScriptAssetAttachment.java new file mode 100644 index 0000000..d8902ab --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/model/ScriptAssetAttachment.java @@ -0,0 +1,103 @@ +package dev.kruhlmann.imgfloat.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import java.util.UUID; + +@Entity +@Table(name = "script_asset_attachments") +public class ScriptAssetAttachment { + + @Id + private String id; + + @Column(name = "script_asset_id", nullable = false) + private String scriptAssetId; + + @Column(nullable = false) + private String name; + + private String mediaType; + private String originalMediaType; + + @Enumerated(EnumType.STRING) + @Column(name = "asset_type", nullable = false) + private AssetType assetType; + + public ScriptAssetAttachment() {} + + public ScriptAssetAttachment(String scriptAssetId, String name) { + this.id = UUID.randomUUID().toString(); + this.scriptAssetId = scriptAssetId; + this.name = name; + this.assetType = AssetType.OTHER; + } + + @PrePersist + @PreUpdate + public void prepare() { + if (this.id == null) { + this.id = UUID.randomUUID().toString(); + } + if (this.name == null || this.name.isBlank()) { + this.name = this.id; + } + if (this.assetType == null) { + this.assetType = AssetType.OTHER; + } + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getScriptAssetId() { + return scriptAssetId; + } + + public void setScriptAssetId(String scriptAssetId) { + this.scriptAssetId = scriptAssetId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getMediaType() { + return mediaType; + } + + public void setMediaType(String mediaType) { + this.mediaType = mediaType; + } + + public String getOriginalMediaType() { + return originalMediaType; + } + + public void setOriginalMediaType(String originalMediaType) { + this.originalMediaType = originalMediaType; + } + + public AssetType getAssetType() { + return assetType == null ? AssetType.OTHER : assetType; + } + + public void setAssetType(AssetType assetType) { + this.assetType = assetType == null ? AssetType.OTHER : assetType; + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/ScriptAssetAttachmentView.java b/src/main/java/dev/kruhlmann/imgfloat/model/ScriptAssetAttachmentView.java new file mode 100644 index 0000000..9e721b5 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/model/ScriptAssetAttachmentView.java @@ -0,0 +1,28 @@ +package dev.kruhlmann.imgfloat.model; + +public record ScriptAssetAttachmentView( + String id, + String scriptAssetId, + String name, + String url, + String mediaType, + String originalMediaType, + AssetType assetType +) { + public static ScriptAssetAttachmentView fromAttachment(String broadcaster, ScriptAssetAttachment attachment) { + if (attachment == null) { + return null; + } + return new ScriptAssetAttachmentView( + attachment.getId(), + attachment.getScriptAssetId(), + attachment.getName(), + "/api/channels/" + broadcaster + "/script-assets/" + attachment.getScriptAssetId() + "/attachments/" + + attachment.getId() + + "/content", + attachment.getMediaType(), + attachment.getOriginalMediaType(), + attachment.getAssetType() + ); + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/repository/ScriptAssetAttachmentRepository.java b/src/main/java/dev/kruhlmann/imgfloat/repository/ScriptAssetAttachmentRepository.java new file mode 100644 index 0000000..7128d23 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/repository/ScriptAssetAttachmentRepository.java @@ -0,0 +1,14 @@ +package dev.kruhlmann.imgfloat.repository; + +import dev.kruhlmann.imgfloat.model.ScriptAssetAttachment; +import java.util.Collection; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ScriptAssetAttachmentRepository extends JpaRepository { + List findByScriptAssetId(String scriptAssetId); + + List findByScriptAssetIdIn(Collection scriptAssetIds); + + void deleteByScriptAssetId(String scriptAssetId); +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/AssetCleanupService.java b/src/main/java/dev/kruhlmann/imgfloat/service/AssetCleanupService.java index 0a20d04..49d7c5c 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/AssetCleanupService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/AssetCleanupService.java @@ -1,7 +1,9 @@ package dev.kruhlmann.imgfloat.service; import dev.kruhlmann.imgfloat.model.Asset; +import dev.kruhlmann.imgfloat.model.ScriptAssetAttachment; import dev.kruhlmann.imgfloat.repository.AssetRepository; +import dev.kruhlmann.imgfloat.repository.ScriptAssetAttachmentRepository; import java.util.Set; import java.util.stream.Collectors; import org.slf4j.Logger; @@ -19,10 +21,16 @@ public class AssetCleanupService { private final AssetRepository assetRepository; private final AssetStorageService assetStorageService; + private final ScriptAssetAttachmentRepository scriptAssetAttachmentRepository; - public AssetCleanupService(AssetRepository assetRepository, AssetStorageService assetStorageService) { + public AssetCleanupService( + AssetRepository assetRepository, + AssetStorageService assetStorageService, + ScriptAssetAttachmentRepository scriptAssetAttachmentRepository + ) { this.assetRepository = assetRepository; this.assetStorageService = assetStorageService; + this.scriptAssetAttachmentRepository = scriptAssetAttachmentRepository; } @Async @@ -31,7 +39,18 @@ public class AssetCleanupService { public void cleanup() { logger.info("Collecting referenced assets"); - Set referencedIds = assetRepository.findAll().stream().map(Asset::getId).collect(Collectors.toSet()); + Set referencedIds = assetRepository + .findAll() + .stream() + .map(Asset::getId) + .collect(Collectors.toSet()); + referencedIds.addAll( + scriptAssetAttachmentRepository + .findAll() + .stream() + .map(ScriptAssetAttachment::getId) + .collect(Collectors.toSet()) + ); assetStorageService.deleteOrphanedAssets(referencedIds); } diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java index 291c707..6a3fdf5 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java @@ -15,6 +15,8 @@ import dev.kruhlmann.imgfloat.model.Channel; import dev.kruhlmann.imgfloat.model.CodeAssetRequest; import dev.kruhlmann.imgfloat.model.PlaybackRequest; import dev.kruhlmann.imgfloat.model.ScriptAsset; +import dev.kruhlmann.imgfloat.model.ScriptAssetAttachment; +import dev.kruhlmann.imgfloat.model.ScriptAssetAttachmentView; import dev.kruhlmann.imgfloat.model.Settings; import dev.kruhlmann.imgfloat.model.TransformRequest; import dev.kruhlmann.imgfloat.model.VisibilityRequest; @@ -23,6 +25,7 @@ import dev.kruhlmann.imgfloat.repository.AssetRepository; import dev.kruhlmann.imgfloat.repository.AudioAssetRepository; import dev.kruhlmann.imgfloat.repository.ChannelRepository; import dev.kruhlmann.imgfloat.repository.ScriptAssetRepository; +import dev.kruhlmann.imgfloat.repository.ScriptAssetAttachmentRepository; import dev.kruhlmann.imgfloat.repository.VisualAssetRepository; import dev.kruhlmann.imgfloat.service.media.AssetContent; import dev.kruhlmann.imgfloat.service.media.MediaDetectionService; @@ -53,6 +56,7 @@ public class ChannelDirectoryService { private final VisualAssetRepository visualAssetRepository; private final AudioAssetRepository audioAssetRepository; private final ScriptAssetRepository scriptAssetRepository; + private final ScriptAssetAttachmentRepository scriptAssetAttachmentRepository; private final SimpMessagingTemplate messagingTemplate; private final AssetStorageService assetStorageService; private final MediaDetectionService mediaDetectionService; @@ -67,6 +71,7 @@ public class ChannelDirectoryService { VisualAssetRepository visualAssetRepository, AudioAssetRepository audioAssetRepository, ScriptAssetRepository scriptAssetRepository, + ScriptAssetAttachmentRepository scriptAssetAttachmentRepository, SimpMessagingTemplate messagingTemplate, AssetStorageService assetStorageService, MediaDetectionService mediaDetectionService, @@ -79,6 +84,7 @@ public class ChannelDirectoryService { this.visualAssetRepository = visualAssetRepository; this.audioAssetRepository = audioAssetRepository; this.scriptAssetRepository = scriptAssetRepository; + this.scriptAssetAttachmentRepository = scriptAssetAttachmentRepository; this.messagingTemplate = messagingTemplate; this.assetStorageService = assetStorageService; this.mediaDetectionService = mediaDetectionService; @@ -220,6 +226,7 @@ public class ChannelDirectoryService { ScriptAsset script = new ScriptAsset(asset.getId(), safeName); script.setMediaType(optimized.mediaType()); script.setOriginalMediaType(mediaType); + script.setAttachments(List.of()); scriptAssetRepository.save(script); view = AssetView.fromScript(channel.getBroadcaster(), asset, script); } else { @@ -261,6 +268,7 @@ public class ChannelDirectoryService { ScriptAsset script = new ScriptAsset(asset.getId(), request.getName().trim()); script.setOriginalMediaType(DEFAULT_CODE_MEDIA_TYPE); script.setMediaType(DEFAULT_CODE_MEDIA_TYPE); + script.setAttachments(List.of()); scriptAssetRepository.save(script); AssetView view = AssetView.fromScript(channel.getBroadcaster(), asset, script); messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, view)); @@ -286,6 +294,7 @@ public class ChannelDirectoryService { script.setName(request.getName().trim()); script.setOriginalMediaType(DEFAULT_CODE_MEDIA_TYPE); script.setMediaType(DEFAULT_CODE_MEDIA_TYPE); + script.setAttachments(loadScriptAttachments(normalized, asset.getId(), null)); try { assetStorageService.storeAsset(broadcaster, asset.getId(), bytes, DEFAULT_CODE_MEDIA_TYPE); } catch (IOException e) { @@ -341,6 +350,7 @@ public class ChannelDirectoryService { ScriptAsset script = scriptAssetRepository .findById(asset.getId()) .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not a script")); + script.setAttachments(loadScriptAttachments(normalized, asset.getId(), null)); return AssetView.fromScript(normalized, asset, script); } @@ -487,6 +497,7 @@ public class ChannelDirectoryService { ScriptAsset script = scriptAssetRepository .findById(asset.getId()) .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not a script")); + script.setAttachments(loadScriptAttachments(normalized, asset.getId(), null)); return AssetView.fromScript(normalized, asset, script); } @@ -516,7 +527,10 @@ public class ChannelDirectoryService { deleteAssetStorage(asset); switch (asset.getAssetType()) { case AUDIO -> audioAssetRepository.deleteById(asset.getId()); - case SCRIPT -> scriptAssetRepository.deleteById(asset.getId()); + case SCRIPT -> { + scriptAssetAttachmentRepository.deleteByScriptAssetId(asset.getId()); + scriptAssetRepository.deleteById(asset.getId()); + } default -> visualAssetRepository.deleteById(asset.getId()); } assetRepository.delete(asset); @@ -533,6 +547,119 @@ public class ChannelDirectoryService { return assetRepository.findById(assetId).flatMap(this::loadAssetContent); } + public List listScriptAttachments(String broadcaster, String scriptAssetId) { + Asset asset = requireScriptAssetForBroadcaster(broadcaster, scriptAssetId); + return loadScriptAttachments(normalize(broadcaster), asset.getId(), null); + } + + public Optional createScriptAttachment( + String broadcaster, + String scriptAssetId, + MultipartFile file + ) throws IOException { + long fileSize = file.getSize(); + if (fileSize > uploadLimitBytes) { + throw new ResponseStatusException( + PAYLOAD_TOO_LARGE, + String.format( + "Uploaded file is too large (%d bytes). Maximum allowed is %d bytes.", + fileSize, + uploadLimitBytes + ) + ); + } + + Asset asset = requireScriptAssetForBroadcaster(broadcaster, scriptAssetId); + byte[] bytes = file.getBytes(); + String mediaType = mediaDetectionService + .detectAllowedMediaType(file, bytes) + .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unsupported media type")); + + OptimizedAsset optimized = mediaOptimizationService.optimizeAsset(bytes, mediaType); + if (optimized == null) { + return Optional.empty(); + } + + AssetType assetType = AssetType.fromMediaType(optimized.mediaType(), mediaType); + if (assetType != AssetType.AUDIO && assetType != AssetType.IMAGE && assetType != AssetType.VIDEO) { + throw new ResponseStatusException(BAD_REQUEST, "Only image, video, or audio attachments are supported."); + } + + String safeName = Optional.ofNullable(file.getOriginalFilename()) + .map(this::sanitizeFilename) + .filter((s) -> !s.isBlank()) + .orElse("script_attachment_" + System.currentTimeMillis()); + + ScriptAssetAttachment attachment = new ScriptAssetAttachment(asset.getId(), safeName); + attachment.setMediaType(optimized.mediaType()); + attachment.setOriginalMediaType(mediaType); + attachment.setAssetType(assetType); + + assetStorageService.storeAsset(asset.getBroadcaster(), attachment.getId(), optimized.bytes(), optimized.mediaType()); + attachment = scriptAssetAttachmentRepository.save(attachment); + ScriptAssetAttachmentView view = ScriptAssetAttachmentView.fromAttachment(asset.getBroadcaster(), attachment); + + ScriptAsset script = scriptAssetRepository + .findById(asset.getId()) + .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not a script")); + script.setAttachments(loadScriptAttachments(asset.getBroadcaster(), asset.getId(), null)); + AssetView scriptView = AssetView.fromScript(asset.getBroadcaster(), asset, script); + messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, scriptView)); + + return Optional.of(view); + } + + public boolean deleteScriptAttachment(String broadcaster, String scriptAssetId, String attachmentId) { + Asset asset = requireScriptAssetForBroadcaster(broadcaster, scriptAssetId); + ScriptAssetAttachment attachment = scriptAssetAttachmentRepository + .findById(attachmentId) + .filter((item) -> item.getScriptAssetId().equals(asset.getId())) + .orElse(null); + if (attachment == null) { + return false; + } + assetStorageService.deleteAsset( + asset.getBroadcaster(), + attachment.getId(), + attachment.getMediaType(), + false + ); + scriptAssetAttachmentRepository.deleteById(attachment.getId()); + + ScriptAsset script = scriptAssetRepository + .findById(asset.getId()) + .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not a script")); + script.setAttachments(loadScriptAttachments(asset.getBroadcaster(), asset.getId(), null)); + AssetView scriptView = AssetView.fromScript(asset.getBroadcaster(), asset, script); + messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, scriptView)); + return true; + } + + public Optional getScriptAttachmentContent( + String broadcaster, + String scriptAssetId, + String attachmentId + ) { + Asset asset = assetRepository + .findById(scriptAssetId) + .filter((stored) -> normalize(broadcaster).equals(stored.getBroadcaster())) + .filter((stored) -> stored.getAssetType() == AssetType.SCRIPT) + .orElse(null); + if (asset == null) { + return Optional.empty(); + } + return scriptAssetAttachmentRepository + .findById(attachmentId) + .filter((item) -> item.getScriptAssetId().equals(scriptAssetId)) + .flatMap((attachment) -> + assetStorageService.loadAssetFileSafely( + asset.getBroadcaster(), + attachment.getId(), + attachment.getMediaType() + ) + ); + } + public Optional getAssetPreview(String assetId, boolean includeHidden) { return assetRepository.findById(assetId).flatMap((asset) -> loadAssetPreview(asset, includeHidden)); } @@ -625,10 +752,24 @@ public class ChannelDirectoryService { .findByIdIn(scriptIds) .stream() .collect(Collectors.toMap(ScriptAsset::getId, (asset) -> asset)); + Map> scriptAttachments = scriptIds.isEmpty() + ? Map.of() + : Optional.ofNullable(scriptAssetAttachmentRepository.findByScriptAssetIdIn(scriptIds)) + .orElse(List.of()) + .stream() + .collect( + Collectors.groupingBy( + ScriptAssetAttachment::getScriptAssetId, + Collectors.mapping( + (attachment) -> ScriptAssetAttachmentView.fromAttachment(broadcaster, attachment), + Collectors.toList() + ) + ) + ); return assets .stream() - .map((asset) -> resolveAssetView(broadcaster, asset, visuals, audios, scripts)) + .map((asset) -> resolveAssetView(broadcaster, asset, visuals, audios, scripts, scriptAttachments)) .filter(Objects::nonNull) .sorted( Comparator.comparing((AssetView view) -> @@ -662,7 +803,7 @@ public class ChannelDirectoryService { } private AssetView resolveAssetView(String broadcaster, Asset asset) { - return resolveAssetView(broadcaster, asset, null, null, null); + return resolveAssetView(broadcaster, asset, null, null, null, null); } private AssetView resolveAssetView( @@ -670,7 +811,8 @@ public class ChannelDirectoryService { Asset asset, Map visuals, Map audios, - Map scripts + Map scripts, + Map> scriptAttachments ) { if (asset.getAssetType() == AssetType.AUDIO) { AudioAsset audio = audios != null @@ -682,6 +824,9 @@ public class ChannelDirectoryService { ScriptAsset script = scripts != null ? scripts.get(asset.getId()) : scriptAssetRepository.findById(asset.getId()).orElse(null); + if (script != null) { + script.setAttachments(loadScriptAttachments(broadcaster, asset.getId(), scriptAttachments)); + } return script == null ? null : AssetView.fromScript(broadcaster, asset, script); } VisualAsset visual = visuals != null @@ -728,6 +873,33 @@ public class ChannelDirectoryService { } } + private List loadScriptAttachments( + String broadcaster, + String scriptAssetId, + Map> scriptAttachments + ) { + if (scriptAttachments != null) { + return scriptAttachments.getOrDefault(scriptAssetId, List.of()); + } + List attachments = Optional.ofNullable( + scriptAssetAttachmentRepository.findByScriptAssetId(scriptAssetId) + ) + .orElse(List.of()); + return attachments + .stream() + .map((attachment) -> ScriptAssetAttachmentView.fromAttachment(broadcaster, attachment)) + .toList(); + } + + private Asset requireScriptAssetForBroadcaster(String broadcaster, String scriptAssetId) { + String normalized = normalize(broadcaster); + return assetRepository + .findById(scriptAssetId) + .filter((asset) -> normalized.equals(asset.getBroadcaster())) + .filter((asset) -> asset.getAssetType() == AssetType.SCRIPT) + .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not a script")); + } + private Optional loadAssetPreview(Asset asset, boolean includeHidden) { if ( asset.getAssetType() != AssetType.VIDEO && @@ -757,9 +929,19 @@ public class ChannelDirectoryService { ); case SCRIPT -> scriptAssetRepository .findById(asset.getId()) - .ifPresent((script) -> - assetStorageService.deleteAsset(asset.getBroadcaster(), asset.getId(), script.getMediaType(), false) - ); + .ifPresent((script) -> { + assetStorageService.deleteAsset(asset.getBroadcaster(), asset.getId(), script.getMediaType(), false); + scriptAssetAttachmentRepository + .findByScriptAssetId(asset.getId()) + .forEach((attachment) -> + assetStorageService.deleteAsset( + asset.getBroadcaster(), + attachment.getId(), + attachment.getMediaType(), + false + ) + ); + }); default -> visualAssetRepository .findById(asset.getId()) .ifPresent((visual) -> diff --git a/src/main/resources/assets/icon/favicon.ico b/src/main/resources/assets/icon/favicon.ico deleted file mode 100644 index c4922b5..0000000 --- a/src/main/resources/assets/icon/favicon.ico +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c341a35693080f89d0a9aaf89261d0183516e5e62be24fdfb2436527a4904ffe -size 100704 diff --git a/src/main/resources/static/css/customAssets.css b/src/main/resources/static/css/customAssets.css index 1eab616..9c8154c 100644 --- a/src/main/resources/static/css/customAssets.css +++ b/src/main/resources/static/css/customAssets.css @@ -49,3 +49,54 @@ border: 1px solid #a00; border-radius: 4px; } + +.modal .modal-inner .attachment-actions { + display: flex; + gap: 12px; + align-items: center; +} + +.modal .modal-inner .attachment-actions .file-input-trigger.small { + padding: 10px 14px; +} + +.modal .modal-inner .attachment-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.modal .modal-inner .attachment-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border: 1px solid rgba(148, 163, 184, 0.3); + border-radius: 6px; + background-color: rgba(15, 23, 42, 0.6); +} + +.modal .modal-inner .attachment-meta { + display: flex; + flex-direction: column; + gap: 2px; +} + +.modal .modal-inner .attachment-meta span { + font-size: 0.85rem; + color: rgba(226, 232, 240, 0.8); +} + +.modal .modal-inner .attachment-actions-row { + display: flex; + gap: 8px; + align-items: center; +} + +.modal .modal-inner .attachment-empty { + color: rgba(226, 232, 240, 0.7); + font-size: 0.9rem; +} diff --git a/src/main/resources/static/js/broadcast/renderer.js b/src/main/resources/static/js/broadcast/renderer.js index 86323bd..c123a89 100644 --- a/src/main/resources/static/js/broadcast/renderer.js +++ b/src/main/resources/static/js/broadcast/renderer.js @@ -88,6 +88,9 @@ export class BroadcastRenderer { const wasExisting = this.state.assets.has(asset.id); this.state.assets.set(asset.id, asset); ensureLayerPosition(this.state, asset.id, placement); + if (isCodeAsset(asset)) { + this.updateScriptWorkerAttachments(asset); + } if (!wasExisting && !this.state.visibilityStates.has(asset.id)) { const initialAlpha = 0; // Fade in newly discovered assets this.state.visibilityStates.set(asset.id, { @@ -484,6 +487,20 @@ export class BroadcastRenderer { payload: { id: asset.id, source: assetSource, + attachments: asset.scriptAttachments || [], + }, + }); + } + + updateScriptWorkerAttachments(asset) { + if (!this.scriptWorker || !this.scriptWorkerReady || !asset?.id) { + return; + } + this.scriptWorker.postMessage({ + type: "updateAttachments", + payload: { + id: asset.id, + attachments: asset.scriptAttachments || [], }, }); } diff --git a/src/main/resources/static/js/broadcast/script-worker.js b/src/main/resources/static/js/broadcast/script-worker.js index f2c2823..19a2764 100644 --- a/src/main/resources/static/js/broadcast/script-worker.js +++ b/src/main/resources/static/js/broadcast/script-worker.js @@ -1,4 +1,5 @@ const scripts = new Map(); +const allowedFetchUrls = new Set(); let canvas = null; let ctx = null; let channelName = ""; @@ -9,9 +10,18 @@ const tickIntervalMs = 1000 / 60; const errorKeys = new Set(); function disableNetworkApis() { + const nativeFetch = typeof self.fetch === "function" ? self.fetch.bind(self) : null; const blockedApis = { - fetch: () => { - throw new Error("Network access is disabled in asset scripts."); + fetch: (...args) => { + if (!nativeFetch) { + throw new Error("Network access is disabled in asset scripts."); + } + const request = new Request(...args); + const url = normalizeUrl(request.url); + if (!allowedFetchUrls.has(url)) { + throw new Error("Network access is disabled in asset scripts."); + } + return nativeFetch(request); }, XMLHttpRequest: undefined, WebSocket: undefined, @@ -43,6 +53,32 @@ function disableNetworkApis() { disableNetworkApis(); +function normalizeUrl(url) { + try { + return new URL(url, self.location?.href || "http://localhost").toString(); + } catch (_error) { + return ""; + } +} + +function refreshAllowedFetchUrls() { + allowedFetchUrls.clear(); + scripts.forEach((script) => { + const assets = script?.context?.assets; + if (!Array.isArray(assets)) { + return; + } + assets.forEach((asset) => { + if (asset?.url) { + const normalized = normalizeUrl(asset.url); + if (normalized) { + allowedFetchUrls.add(normalized); + } + } + }); + }); +} + function reportScriptError(id, stage, error) { if (!id) { return; @@ -115,7 +151,8 @@ function stopTickLoopIfIdle() { } function createScriptHandlers(source, context, state, sourceLabel = "") { - const contextPrelude = "const { canvas, ctx, channelName, width, height, now, deltaMs, elapsedMs } = context;"; + const contextPrelude = + "const { canvas, ctx, channelName, width, height, now, deltaMs, elapsedMs, assets } = context;"; const sourceUrl = sourceLabel ? `\n//# sourceURL=${sourceLabel}` : ""; const factory = new Function( "context", @@ -172,6 +209,7 @@ self.addEventListener("message", (event) => { now: 0, deltaMs: 0, elapsedMs: 0, + assets: Array.isArray(payload.attachments) ? payload.attachments : [], }; let handlers = {}; try { @@ -189,6 +227,7 @@ self.addEventListener("message", (event) => { tick: handlers.tick, }; scripts.set(payload.id, script); + refreshAllowedFetchUrls(); if (script.init) { try { script.init(script.context, script.state); @@ -206,6 +245,19 @@ self.addEventListener("message", (event) => { return; } scripts.delete(payload.id); + refreshAllowedFetchUrls(); stopTickLoopIfIdle(); } + + if (type === "updateAttachments") { + if (!payload?.id) { + return; + } + const script = scripts.get(payload.id); + if (!script) { + return; + } + script.context.assets = Array.isArray(payload.attachments) ? payload.attachments : []; + refreshAllowedFetchUrls(); + } }); diff --git a/src/main/resources/static/js/customAssets.js b/src/main/resources/static/js/customAssets.js index b161a32..6dc6bd7 100644 --- a/src/main/resources/static/js/customAssets.js +++ b/src/main/resources/static/js/customAssets.js @@ -7,6 +7,11 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho const jsErrorDetails = document.getElementById("js-error-details"); const form = document.getElementById("custom-asset-form"); const cancelButton = document.getElementById("custom-asset-cancel"); + const attachmentInput = document.getElementById("custom-asset-attachment-file"); + const attachmentList = document.getElementById("custom-asset-attachment-list"); + const attachmentHint = document.getElementById("custom-asset-attachment-hint"); + let currentAssetId = null; + let attachmentState = []; const resetErrors = () => { if (formErrorWrapper) { @@ -37,8 +42,9 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho userSourceTextArea.disabled = false; userSourceTextArea.dataset.assetId = ""; userSourceTextArea.placeholder = - "function init(context, state) {\n\n}\n\nfunction tick(context, state) {\n\n}\n\n// or\n// module.exports.init = (context, state) => {};\n// module.exports.tick = (context, state) => {};"; + "function init(context, state) {\n const { assets } = context;\n\n}\n\nfunction tick(context, state) {\n\n}\n\n// or\n// module.exports.init = (context, state) => {};\n// module.exports.tick = (context, state) => {};"; } + setAttachmentState(null, []); resetErrors(); openModal(); }; @@ -57,6 +63,7 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho userSourceTextArea.disabled = true; userSourceTextArea.dataset.assetId = asset.id; } + setAttachmentState(asset.id, asset.scriptAttachments || []); openModal(); fetch(asset.url) @@ -151,9 +158,148 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho if (cancelButton) { cancelButton.addEventListener("click", () => closeModal()); } + if (attachmentInput) { + attachmentInput.addEventListener("change", (event) => { + const file = event.target?.files?.[0]; + if (!file) { + return; + } + if (!currentAssetId) { + showToast?.("Save the script before adding attachments.", "info"); + attachmentInput.value = ""; + return; + } + uploadAttachment(file) + .then((attachment) => { + if (attachment) { + attachmentState = [...attachmentState, attachment]; + renderAttachmentList(); + showToast?.("Attachment added.", "success"); + } + }) + .catch((error) => { + console.error(error); + showToast?.("Unable to upload attachment. Please try again.", "error"); + }) + .finally(() => { + attachmentInput.value = ""; + }); + }); + } return { openNew, openEditor }; + function setAttachmentState(assetId, attachments) { + currentAssetId = assetId || null; + attachmentState = Array.isArray(attachments) ? [...attachments] : []; + renderAttachmentList(); + } + + function renderAttachmentList() { + if (!attachmentList) { + return; + } + attachmentList.innerHTML = ""; + if (!currentAssetId) { + if (attachmentInput) { + attachmentInput.disabled = true; + } + if (attachmentHint) { + attachmentHint.textContent = "Save the script before adding attachments."; + } + const empty = document.createElement("li"); + empty.className = "attachment-empty"; + empty.textContent = "Attachments will appear here once the script is saved."; + attachmentList.appendChild(empty); + return; + } + if (attachmentInput) { + attachmentInput.disabled = false; + } + if (attachmentHint) { + attachmentHint.textContent = + "Attachments are available to this script only and are not visible on the canvas."; + } + if (!attachmentState.length) { + const empty = document.createElement("li"); + empty.className = "attachment-empty"; + empty.textContent = "No attachments yet."; + attachmentList.appendChild(empty); + return; + } + attachmentState.forEach((attachment) => { + const item = document.createElement("li"); + item.className = "attachment-item"; + + const meta = document.createElement("div"); + meta.className = "attachment-meta"; + const name = document.createElement("strong"); + name.textContent = attachment.name || "Untitled"; + const type = document.createElement("span"); + type.textContent = attachment.assetType || attachment.mediaType || "Attachment"; + meta.appendChild(name); + meta.appendChild(type); + + const actions = document.createElement("div"); + actions.className = "attachment-actions-row"; + if (attachment.url) { + const link = document.createElement("a"); + link.href = attachment.url; + link.target = "_blank"; + link.rel = "noopener"; + link.className = "button ghost"; + link.textContent = "Open"; + actions.appendChild(link); + } + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "secondary danger"; + remove.textContent = "Remove"; + remove.addEventListener("click", () => removeAttachment(attachment.id)); + actions.appendChild(remove); + + item.appendChild(meta); + item.appendChild(actions); + attachmentList.appendChild(item); + }); + } + + function uploadAttachment(file) { + const payload = new FormData(); + payload.append("file", file); + return fetch(`/api/channels/${encodeURIComponent(broadcaster)}/assets/${currentAssetId}/attachments`, { + method: "POST", + body: payload, + }).then((response) => { + if (!response.ok) { + throw new Error("Failed to upload attachment"); + } + return response.json(); + }); + } + + function removeAttachment(attachmentId) { + if (!attachmentId || !currentAssetId) { + return; + } + fetch( + `/api/channels/${encodeURIComponent(broadcaster)}/assets/${currentAssetId}/attachments/${attachmentId}`, + { method: "DELETE" }, + ) + .then((response) => { + if (!response.ok) { + throw new Error("Failed to delete attachment"); + } + attachmentState = attachmentState.filter((attachment) => attachment.id !== attachmentId); + renderAttachmentList(); + showToast?.("Attachment removed.", "success"); + }) + .catch((error) => { + console.error(error); + showToast?.("Unable to remove attachment. Please try again.", "error"); + }); + } + function saveCodeAsset({ name, src, assetId }) { const payload = { name, source: src }; const method = assetId ? "PUT" : "POST"; diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html index e165a9d..5c6aa0e 100644 --- a/src/main/resources/templates/admin.html +++ b/src/main/resources/templates/admin.html @@ -387,7 +387,7 @@ @@ -395,6 +395,29 @@ By submitting your script, you agree to release it under the MIT License to the public.

+
+ +
+ + +
+

+ Attachments are stored with the script and are available for scripts to render. Save the + script before adding attachments. +

+
    +