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.
+