Add script asset sub-assets

This commit is contained in:
2026-01-09 18:42:37 +01:00
parent c4354782a8
commit 96b1cf501c
16 changed files with 770 additions and 17 deletions

View File

@@ -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);

View File

@@ -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<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(() -> createAsset404());
}
@GetMapping("/assets/{assetId}/preview")
public ResponseEntity<byte[]> getAssetPreview(
@PathVariable("broadcaster") String broadcaster,
@@ -439,6 +467,65 @@ public class ChannelApiController {
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,
@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<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);
if (!removed) {
throw createAsset404();
}
return ResponseEntity.ok().build();
}
private ResponseStatusException createAsset404() {
return new ResponseStatusException(NOT_FOUND, "Asset not found");
}

View File

@@ -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<ScriptAssetAttachmentView> 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,

View File

@@ -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<ScriptAssetAttachmentView> 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<ScriptAssetAttachmentView> getAttachments() {
return attachments == null ? List.of() : attachments;
}
public void setAttachments(List<ScriptAssetAttachmentView> attachments) {
this.attachments = attachments;
}
}

View File

@@ -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;
}
}

View File

@@ -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()
);
}
}

View File

@@ -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<ScriptAssetAttachment, String> {
List<ScriptAssetAttachment> findByScriptAssetId(String scriptAssetId);
List<ScriptAssetAttachment> findByScriptAssetIdIn(Collection<String> scriptAssetIds);
void deleteByScriptAssetId(String scriptAssetId);
}

View File

@@ -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<String> referencedIds = assetRepository.findAll().stream().map(Asset::getId).collect(Collectors.toSet());
Set<String> referencedIds = assetRepository
.findAll()
.stream()
.map(Asset::getId)
.collect(Collectors.toSet());
referencedIds.addAll(
scriptAssetAttachmentRepository
.findAll()
.stream()
.map(ScriptAssetAttachment::getId)
.collect(Collectors.toSet())
);
assetStorageService.deleteOrphanedAssets(referencedIds);
}

View File

@@ -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<ScriptAssetAttachmentView> listScriptAttachments(String broadcaster, String scriptAssetId) {
Asset asset = requireScriptAssetForBroadcaster(broadcaster, scriptAssetId);
return loadScriptAttachments(normalize(broadcaster), asset.getId(), null);
}
public Optional<ScriptAssetAttachmentView> 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<AssetContent> 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<AssetContent> 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<String, List<ScriptAssetAttachmentView>> 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<String, VisualAsset> visuals,
Map<String, AudioAsset> audios,
Map<String, ScriptAsset> scripts
Map<String, ScriptAsset> scripts,
Map<String, List<ScriptAssetAttachmentView>> 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<ScriptAssetAttachmentView> loadScriptAttachments(
String broadcaster,
String scriptAssetId,
Map<String, List<ScriptAssetAttachmentView>> scriptAttachments
) {
if (scriptAttachments != null) {
return scriptAttachments.getOrDefault(scriptAssetId, List.of());
}
List<ScriptAssetAttachment> 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<AssetContent> 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) ->

Binary file not shown.

View File

@@ -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;
}

View File

@@ -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 || [],
},
});
}

View File

@@ -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();
}
});

View File

@@ -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";

View File

@@ -387,7 +387,7 @@
<textarea
class="text-input"
id="custom-asset-code"
placeholder="exports.init = ({ surface, assets, channel }) => {&#10;&#10;};&#10;&#10;exports.tick = () => {&#10;&#10;};"
placeholder="exports.init = (context) => {&#10; const { assets } = context;&#10;&#10;};&#10;&#10;exports.tick = () => {&#10;&#10;};"
rows="25"
required
></textarea>
@@ -395,6 +395,29 @@
By submitting your script, you agree to release it under the MIT License to the public.
</p>
</div>
<div class="form-group">
<label>Script attachments</label>
<div class="attachment-actions">
<input
id="custom-asset-attachment-file"
class="file-input-field"
type="file"
accept="image/*,video/*,audio/*"
/>
<label for="custom-asset-attachment-file" class="file-input-trigger small">
<span class="file-input-icon"><i class="fa-solid fa-paperclip"></i></span>
<span class="file-input-copy">
<strong>Add attachment</strong>
<small>Images, video, or audio</small>
</span>
</label>
</div>
<p class="field-note" id="custom-asset-attachment-hint">
Attachments are stored with the script and are available for scripts to render. Save the
script before adding attachments.
</p>
<ul id="custom-asset-attachment-list" class="attachment-list"></ul>
</div>
<div class="form-error hidden" id="custom-asset-error">
<strong>JavaScript error: <span id="js-error-title"></span></strong>
<pre id="js-error-details"></pre>