From 9f41ecce5a3fbda927abfbb4efebfd433fd95baa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Thu, 8 Jan 2026 14:04:49 +0100 Subject: [PATCH] Remove server validation --- .../controller/ChannelApiController.java | 43 +++++++++ .../kruhlmann/imgfloat/model/AssetEvent.java | 9 ++ .../imgfloat/model/CodeAssetRequest.java | 28 ++++++ .../service/ChannelDirectoryService.java | 89 +++++++++++++++++++ .../service/media/MediaDetectionService.java | 25 ++++-- src/main/resources/static/js/broadcast.js | 9 +- .../resources/static/js/broadcastWorkers.js | 15 +++- src/main/resources/static/js/customAssets.js | 87 ++++++++++++++++++ 8 files changed, 295 insertions(+), 10 deletions(-) create mode 100644 src/main/java/dev/kruhlmann/imgfloat/model/CodeAssetRequest.java diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java index 48da123..69d7b0f 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java @@ -7,6 +7,7 @@ import static org.springframework.http.HttpStatus.NOT_FOUND; import dev.kruhlmann.imgfloat.model.AdminRequest; import dev.kruhlmann.imgfloat.model.AssetView; 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.TransformRequest; @@ -236,6 +237,48 @@ public class ChannelApiController { } } + @PostMapping("/assets/code") + public ResponseEntity createCodeAsset( + @PathVariable("broadcaster") String broadcaster, + @Valid @RequestBody CodeAssetRequest request, + OAuth2AuthenticationToken oauthToken + ) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + String logBroadcaster = LogSanitizer.sanitize(broadcaster); + String logSessionUsername = LogSanitizer.sanitize(sessionUsername); + authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError( + broadcaster, + sessionUsername + ); + LOG.info("Creating custom script for {} by {}", logBroadcaster, logSessionUsername); + return channelDirectoryService + .createCodeAsset(broadcaster, request) + .map(ResponseEntity::ok) + .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to save custom script")); + } + + @PutMapping("/assets/{assetId}/code") + public ResponseEntity updateCodeAsset( + @PathVariable("broadcaster") String broadcaster, + @PathVariable("assetId") String assetId, + @Valid @RequestBody CodeAssetRequest request, + OAuth2AuthenticationToken oauthToken + ) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + String logBroadcaster = LogSanitizer.sanitize(broadcaster); + String logSessionUsername = LogSanitizer.sanitize(sessionUsername); + String logAssetId = LogSanitizer.sanitize(assetId); + authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError( + broadcaster, + sessionUsername + ); + LOG.info("Updating custom script {} for {} by {}", logAssetId, logBroadcaster, logSessionUsername); + return channelDirectoryService + .updateCodeAsset(broadcaster, assetId, request) + .map(ResponseEntity::ok) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found")); + } + @PutMapping("/assets/{assetId}/transform") public ResponseEntity transform( @PathVariable("broadcaster") String broadcaster, diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/AssetEvent.java b/src/main/java/dev/kruhlmann/imgfloat/model/AssetEvent.java index 16701c6..fcbe4ec 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/AssetEvent.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/AssetEvent.java @@ -38,6 +38,15 @@ public class AssetEvent { return event; } + public static AssetEvent updated(String channel, AssetView asset) { + AssetEvent event = new AssetEvent(); + event.type = Type.UPDATED; + event.channel = channel; + event.payload = asset; + event.assetId = asset.id(); + return event; + } + public static AssetEvent play(String channel, AssetView asset, boolean play) { AssetEvent event = new AssetEvent(); event.type = Type.PLAY; diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/CodeAssetRequest.java b/src/main/java/dev/kruhlmann/imgfloat/model/CodeAssetRequest.java new file mode 100644 index 0000000..2e9baeb --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/model/CodeAssetRequest.java @@ -0,0 +1,28 @@ +package dev.kruhlmann.imgfloat.model; + +import jakarta.validation.constraints.NotBlank; + +public class CodeAssetRequest { + + @NotBlank + private String name; + + @NotBlank + private String source; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getSource() { + return source; + } + + public void setSource(String source) { + this.source = source; + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java index 8406fce..23c736d 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java @@ -10,6 +10,7 @@ import dev.kruhlmann.imgfloat.model.AssetView; import dev.kruhlmann.imgfloat.model.CanvasEvent; import dev.kruhlmann.imgfloat.model.CanvasSettingsRequest; import dev.kruhlmann.imgfloat.model.Channel; +import dev.kruhlmann.imgfloat.model.CodeAssetRequest; import dev.kruhlmann.imgfloat.model.PlaybackRequest; import dev.kruhlmann.imgfloat.model.Settings; import dev.kruhlmann.imgfloat.model.TransformRequest; @@ -21,6 +22,7 @@ import dev.kruhlmann.imgfloat.service.media.MediaDetectionService; import dev.kruhlmann.imgfloat.service.media.MediaOptimizationService; import dev.kruhlmann.imgfloat.service.media.OptimizedAsset; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.*; import java.util.regex.Pattern; import org.slf4j.Logger; @@ -36,6 +38,9 @@ public class ChannelDirectoryService { private static final Logger logger = LoggerFactory.getLogger(ChannelDirectoryService.class); private static final Pattern SAFE_FILENAME = Pattern.compile("[^a-zA-Z0-9._ -]"); + private static final Pattern INIT_FUNCTION = Pattern.compile("\\bfunction\\s+init\\b"); + private static final Pattern TICK_FUNCTION = Pattern.compile("\\bfunction\\s+tick\\b"); + private static final String DEFAULT_CODE_MEDIA_TYPE = "application/javascript"; private final ChannelRepository channelRepository; private final AssetRepository assetRepository; @@ -192,6 +197,65 @@ public class ChannelDirectoryService { return Optional.of(view); } + public Optional createCodeAsset(String broadcaster, CodeAssetRequest request) { + validateCodeAssetSource(request.getSource()); + Channel channel = getOrCreateChannel(broadcaster); + byte[] bytes = request.getSource().getBytes(StandardCharsets.UTF_8); + enforceUploadLimit(bytes.length); + + Asset asset = new Asset(channel.getBroadcaster(), request.getName().trim(), "", 480, 270); + asset.setOriginalMediaType(DEFAULT_CODE_MEDIA_TYPE); + asset.setMediaType(DEFAULT_CODE_MEDIA_TYPE); + asset.setPreview(""); + asset.setSpeed(1.0); + asset.setMuted(false); + asset.setAudioLoop(false); + asset.setAudioDelayMillis(0); + asset.setAudioSpeed(1.0); + asset.setAudioPitch(1.0); + asset.setAudioVolume(1.0); + asset.setZIndex(nextZIndex(channel.getBroadcaster())); + + try { + assetStorageService.storeAsset(channel.getBroadcaster(), asset.getId(), bytes, DEFAULT_CODE_MEDIA_TYPE); + } catch (IOException e) { + throw new ResponseStatusException(BAD_REQUEST, "Unable to store custom script", e); + } + + assetRepository.save(asset); + AssetView view = AssetView.from(channel.getBroadcaster(), asset); + messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, view)); + return Optional.of(view); + } + + public Optional updateCodeAsset(String broadcaster, String assetId, CodeAssetRequest request) { + validateCodeAssetSource(request.getSource()); + String normalized = normalize(broadcaster); + byte[] bytes = request.getSource().getBytes(StandardCharsets.UTF_8); + enforceUploadLimit(bytes.length); + + return assetRepository + .findById(assetId) + .filter((asset) -> normalized.equals(asset.getBroadcaster())) + .map((asset) -> { + if (!isCodeMediaType(asset.getMediaType()) && !isCodeMediaType(asset.getOriginalMediaType())) { + throw new ResponseStatusException(BAD_REQUEST, "Asset is not a script"); + } + asset.setName(request.getName().trim()); + asset.setOriginalMediaType(DEFAULT_CODE_MEDIA_TYPE); + asset.setMediaType(DEFAULT_CODE_MEDIA_TYPE); + try { + assetStorageService.storeAsset(broadcaster, asset.getId(), bytes, DEFAULT_CODE_MEDIA_TYPE); + } catch (IOException e) { + throw new ResponseStatusException(BAD_REQUEST, "Unable to store custom script", e); + } + assetRepository.save(asset); + AssetView view = AssetView.from(normalized, asset); + messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, view)); + return view; + }); + } + private String sanitizeFilename(String original) { String stripped = original.replaceAll("^.*[/\\\\]", ""); return SAFE_FILENAME.matcher(stripped).replaceAll("_"); @@ -371,6 +435,31 @@ public class ChannelDirectoryService { return normalized.startsWith("application/javascript") || normalized.startsWith("text/javascript"); } + private void validateCodeAssetSource(String source) { + if (source == null || source.isBlank()) { + throw new ResponseStatusException(BAD_REQUEST, "Script source is required"); + } + if (!INIT_FUNCTION.matcher(source).find()) { + throw new ResponseStatusException(BAD_REQUEST, "Missing function: init"); + } + if (!TICK_FUNCTION.matcher(source).find()) { + throw new ResponseStatusException(BAD_REQUEST, "Missing function: tick"); + } + } + + private void enforceUploadLimit(long sizeBytes) { + if (sizeBytes > uploadLimitBytes) { + throw new ResponseStatusException( + PAYLOAD_TOO_LARGE, + String.format( + "Uploaded file is too large (%d bytes). Maximum allowed is %d bytes.", + sizeBytes, + uploadLimitBytes + ) + ); + } + } + private String topicFor(String broadcaster) { return "/topic/channel/" + broadcaster.toLowerCase(Locale.ROOT); } diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaDetectionService.java b/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaDetectionService.java index 64bf2a8..ccf4a31 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaDetectionService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaDetectionService.java @@ -3,6 +3,7 @@ package dev.kruhlmann.imgfloat.service.media; import java.io.ByteArrayInputStream; import java.io.IOException; import java.net.URLConnection; +import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.Set; @@ -33,15 +34,17 @@ public class MediaDetectionService { private static final Set ALLOWED_MEDIA_TYPES = Set.copyOf(EXTENSION_TYPES.values()); public Optional detectAllowedMediaType(MultipartFile file, byte[] bytes) { - Optional detected = detectMediaType(bytes).filter(MediaDetectionService::isAllowedMediaType); + Optional detected = detectMediaType(bytes) + .map(MediaDetectionService::normalizeJavaScriptMediaType) + .filter(MediaDetectionService::isAllowedMediaType); if (detected.isPresent()) { return detected; } - Optional declared = Optional.ofNullable(file.getContentType()).filter( - MediaDetectionService::isAllowedMediaType - ); + Optional declared = Optional.ofNullable(file.getContentType()) + .map(MediaDetectionService::normalizeJavaScriptMediaType) + .filter(MediaDetectionService::isAllowedMediaType); if (declared.isPresent()) { return declared; } @@ -66,7 +69,8 @@ public class MediaDetectionService { } public static boolean isAllowedMediaType(String mediaType) { - return mediaType != null && ALLOWED_MEDIA_TYPES.contains(mediaType.toLowerCase()); + String normalized = normalizeJavaScriptMediaType(mediaType); + return normalized != null && ALLOWED_MEDIA_TYPES.contains(normalized.toLowerCase()); } public static boolean isInlineDisplayType(String mediaType) { @@ -75,4 +79,15 @@ public class MediaDetectionService { (mediaType.startsWith("image/") || mediaType.startsWith("video/") || mediaType.startsWith("audio/")) ); } + + private static String normalizeJavaScriptMediaType(String mediaType) { + if (mediaType == null) { + return null; + } + String normalized = mediaType.toLowerCase(Locale.ROOT); + if (normalized.contains("javascript") || normalized.contains("ecmascript")) { + return "application/javascript"; + } + return mediaType; + } } diff --git a/src/main/resources/static/js/broadcast.js b/src/main/resources/static/js/broadcast.js index d28d735..2fbc881 100644 --- a/src/main/resources/static/js/broadcast.js +++ b/src/main/resources/static/js/broadcast.js @@ -126,6 +126,7 @@ function renderAssets(list) { function storeAsset(asset, placement = "keep") { if (!asset) return; + console.info(`Storing asset: ${asset.id}`); const wasExisting = assets.has(asset.id); assets.set(asset.id, asset); ensureLayerPosition(asset.id, placement); @@ -135,7 +136,7 @@ function storeAsset(asset, placement = "keep") { } } -function fetchCanvasSettings() { +async function fetchCanvasSettings() { return fetch(`/api/channels/${encodeURIComponent(broadcaster)}/canvas`) .then((r) => { if (!r.ok) { @@ -289,8 +290,8 @@ function applyPatch(assetId, patch) { const targetLayer = Number.isFinite(patch.layer) ? patch.layer : Number.isFinite(patch.zIndex) - ? patch.zIndex - : null; + ? patch.zIndex + : null; if (!isAudio && Number.isFinite(targetLayer)) { const currentOrder = getLayerOrder().filter((id) => id !== assetId); const insertIndex = Math.max(0, currentOrder.length - Math.round(targetLayer)); @@ -793,7 +794,7 @@ function setVideoSource(element, asset) { } applyVideoSource(element, next.objectUrl, asset); }) - .catch(() => {}); + .catch(() => { }); } function applyVideoSource(element, objectUrl, asset) { diff --git a/src/main/resources/static/js/broadcastWorkers.js b/src/main/resources/static/js/broadcastWorkers.js index 0b915f3..34ce58f 100644 --- a/src/main/resources/static/js/broadcastWorkers.js +++ b/src/main/resources/static/js/broadcastWorkers.js @@ -1,2 +1,15 @@ -function spawnUserJavaScriptWorker(jsSource, data) { +async function spawnUserJavaScriptWorker(asset) { + let assetSource; + try { + assetSource = await fetch(asset.url).then((r) => r.text()); + } catch (error) { + console.error(`Unable to fetch asset with id:${id} from url:${asset.url}`, error); + return; + } + const blob = new Blob([assetSource], { type: 'application/javascript' }); + const worker = new Worker(URL.createObjectURL(blob)); + worker.onmessage = (event) => { + console.log('Message from worker:', event.data); + } + worker.postMessage(data); } diff --git a/src/main/resources/static/js/customAssets.js b/src/main/resources/static/js/customAssets.js index 3b70d9d..38c5b1c 100644 --- a/src/main/resources/static/js/customAssets.js +++ b/src/main/resources/static/js/customAssets.js @@ -1,4 +1,5 @@ const assetModal = document.getElementById("custom-asset-modal"); +const userNameInput = document.getElementById("custom-asset-name"); const userSourceTextArea = document.getElementById("custom-asset-code"); const formErrorWrapper = document.getElementById("custom-asset-error"); const jsErrorTitle = document.getElementById("js-error-title"); @@ -10,6 +11,25 @@ function toggleCustomAssetModal(event) { } if (assetModal.classList.contains("hidden")) { assetModal.classList.remove("hidden"); + if (userNameInput) { + userNameInput.value = ""; + } + if (userSourceTextArea) { + userSourceTextArea.value = ""; + userSourceTextArea.disabled = false; + userSourceTextArea.dataset.assetId = ""; + userSourceTextArea.placeholder = + "function init({ surface, assets, channel }) {\n\n}\n\nfunction tick() {\n\n}"; + } + if (formErrorWrapper) { + formErrorWrapper.classList.add("hidden"); + } + if (jsErrorTitle) { + jsErrorTitle.textContent = ""; + } + if (jsErrorDetails) { + jsErrorDetails.textContent = ""; + } } else { assetModal.classList.add("hidden"); } @@ -28,9 +48,76 @@ function submitCodeAsset(formEvent) { formErrorWrapper.classList.add("hidden"); jsErrorTitle.textContent = ""; jsErrorDetails.textContent = ""; + const name = userNameInput?.value?.trim(); + if (!name) { + jsErrorTitle.textContent = "Missing name"; + jsErrorDetails.textContent = "Please provide a name for your custom asset."; + formErrorWrapper.classList.remove("hidden"); + return false; + } + const assetId = userSourceTextArea?.dataset?.assetId; + const submitButton = formEvent.currentTarget?.querySelector('button[type="submit"]'); + if (submitButton) { + submitButton.disabled = true; + submitButton.textContent = "Saving..."; + } + saveCodeAsset({ name, src, assetId }) + .then((asset) => { + if (asset && typeof storeAsset === "function") { + storeAsset(asset); + if (typeof updateRenderState === "function") { + updateRenderState(asset); + } + if (typeof selectedAssetId !== "undefined") { + selectedAssetId = asset.id; + } + if (typeof updateSelectedAssetControls === "function") { + updateSelectedAssetControls(asset); + } + if (typeof drawAndList === "function") { + drawAndList(); + } + } + if (assetModal) { + assetModal.classList.add("hidden"); + } + if (typeof showToast === "function") { + showToast(assetId ? "Custom asset updated." : "Custom asset created.", "success"); + } + }) + .catch((e) => { + if (typeof showToast === "function") { + showToast("Unable to save custom asset. Please try again.", "error"); + } + console.error(e); + }) + .finally(() => { + if (submitButton) { + submitButton.disabled = false; + submitButton.textContent = "Test and save"; + } + }); return false; } +function saveCodeAsset({ name, src, assetId }) { + const payload = { name, source: src }; + const method = assetId ? "PUT" : "POST"; + const url = assetId + ? `/api/channels/${encodeURIComponent(broadcaster)}/assets/${assetId}/code` + : `/api/channels/${encodeURIComponent(broadcaster)}/assets/code`; + return fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }).then((response) => { + if (!response.ok) { + throw new Error("Failed to save code asset"); + } + return response.json(); + }); +} + function getUserJavaScriptSourceError(src) { let ast;