Remove server validation

This commit is contained in:
2026-01-08 14:04:49 +01:00
parent 6e29256ee9
commit 9f41ecce5a
8 changed files with 295 additions and 10 deletions

View File

@@ -7,6 +7,7 @@ import static org.springframework.http.HttpStatus.NOT_FOUND;
import dev.kruhlmann.imgfloat.model.AdminRequest; import dev.kruhlmann.imgfloat.model.AdminRequest;
import dev.kruhlmann.imgfloat.model.AssetView; import dev.kruhlmann.imgfloat.model.AssetView;
import dev.kruhlmann.imgfloat.model.CanvasSettingsRequest; import dev.kruhlmann.imgfloat.model.CanvasSettingsRequest;
import dev.kruhlmann.imgfloat.model.CodeAssetRequest;
import dev.kruhlmann.imgfloat.model.OauthSessionUser; import dev.kruhlmann.imgfloat.model.OauthSessionUser;
import dev.kruhlmann.imgfloat.model.PlaybackRequest; import dev.kruhlmann.imgfloat.model.PlaybackRequest;
import dev.kruhlmann.imgfloat.model.TransformRequest; import dev.kruhlmann.imgfloat.model.TransformRequest;
@@ -236,6 +237,48 @@ public class ChannelApiController {
} }
} }
@PostMapping("/assets/code")
public ResponseEntity<AssetView> createCodeAsset(
@PathVariable("broadcaster") String broadcaster,
@Valid @RequestBody CodeAssetRequest request,
OAuth2AuthenticationToken oauthToken
) {
String sessionUsername = OauthSessionUser.from(oauthToken).login();
String logBroadcaster = LogSanitizer.sanitize(broadcaster);
String logSessionUsername = LogSanitizer.sanitize(sessionUsername);
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(
broadcaster,
sessionUsername
);
LOG.info("Creating custom script for {} by {}", logBroadcaster, logSessionUsername);
return channelDirectoryService
.createCodeAsset(broadcaster, request)
.map(ResponseEntity::ok)
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to save custom script"));
}
@PutMapping("/assets/{assetId}/code")
public ResponseEntity<AssetView> updateCodeAsset(
@PathVariable("broadcaster") String broadcaster,
@PathVariable("assetId") String assetId,
@Valid @RequestBody CodeAssetRequest request,
OAuth2AuthenticationToken oauthToken
) {
String sessionUsername = OauthSessionUser.from(oauthToken).login();
String logBroadcaster = LogSanitizer.sanitize(broadcaster);
String logSessionUsername = LogSanitizer.sanitize(sessionUsername);
String logAssetId = LogSanitizer.sanitize(assetId);
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(
broadcaster,
sessionUsername
);
LOG.info("Updating custom script {} for {} by {}", logAssetId, logBroadcaster, logSessionUsername);
return channelDirectoryService
.updateCodeAsset(broadcaster, assetId, request)
.map(ResponseEntity::ok)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found"));
}
@PutMapping("/assets/{assetId}/transform") @PutMapping("/assets/{assetId}/transform")
public ResponseEntity<AssetView> transform( public ResponseEntity<AssetView> transform(
@PathVariable("broadcaster") String broadcaster, @PathVariable("broadcaster") String broadcaster,

View File

@@ -38,6 +38,15 @@ public class AssetEvent {
return event; 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) { public static AssetEvent play(String channel, AssetView asset, boolean play) {
AssetEvent event = new AssetEvent(); AssetEvent event = new AssetEvent();
event.type = Type.PLAY; event.type = Type.PLAY;

View File

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

View File

@@ -10,6 +10,7 @@ import dev.kruhlmann.imgfloat.model.AssetView;
import dev.kruhlmann.imgfloat.model.CanvasEvent; import dev.kruhlmann.imgfloat.model.CanvasEvent;
import dev.kruhlmann.imgfloat.model.CanvasSettingsRequest; import dev.kruhlmann.imgfloat.model.CanvasSettingsRequest;
import dev.kruhlmann.imgfloat.model.Channel; import dev.kruhlmann.imgfloat.model.Channel;
import dev.kruhlmann.imgfloat.model.CodeAssetRequest;
import dev.kruhlmann.imgfloat.model.PlaybackRequest; import dev.kruhlmann.imgfloat.model.PlaybackRequest;
import dev.kruhlmann.imgfloat.model.Settings; import dev.kruhlmann.imgfloat.model.Settings;
import dev.kruhlmann.imgfloat.model.TransformRequest; 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.MediaOptimizationService;
import dev.kruhlmann.imgfloat.service.media.OptimizedAsset; import dev.kruhlmann.imgfloat.service.media.OptimizedAsset;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.*; import java.util.*;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -36,6 +38,9 @@ public class ChannelDirectoryService {
private static final Logger logger = LoggerFactory.getLogger(ChannelDirectoryService.class); 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 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 ChannelRepository channelRepository;
private final AssetRepository assetRepository; private final AssetRepository assetRepository;
@@ -192,6 +197,65 @@ public class ChannelDirectoryService {
return Optional.of(view); return Optional.of(view);
} }
public Optional<AssetView> 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<AssetView> 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) { private String sanitizeFilename(String original) {
String stripped = original.replaceAll("^.*[/\\\\]", ""); String stripped = original.replaceAll("^.*[/\\\\]", "");
return SAFE_FILENAME.matcher(stripped).replaceAll("_"); return SAFE_FILENAME.matcher(stripped).replaceAll("_");
@@ -371,6 +435,31 @@ public class ChannelDirectoryService {
return normalized.startsWith("application/javascript") || normalized.startsWith("text/javascript"); 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) { private String topicFor(String broadcaster) {
return "/topic/channel/" + broadcaster.toLowerCase(Locale.ROOT); return "/topic/channel/" + broadcaster.toLowerCase(Locale.ROOT);
} }

View File

@@ -3,6 +3,7 @@ package dev.kruhlmann.imgfloat.service.media;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.IOException; import java.io.IOException;
import java.net.URLConnection; import java.net.URLConnection;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
@@ -33,15 +34,17 @@ public class MediaDetectionService {
private static final Set<String> ALLOWED_MEDIA_TYPES = Set.copyOf(EXTENSION_TYPES.values()); private static final Set<String> ALLOWED_MEDIA_TYPES = Set.copyOf(EXTENSION_TYPES.values());
public Optional<String> detectAllowedMediaType(MultipartFile file, byte[] bytes) { public Optional<String> detectAllowedMediaType(MultipartFile file, byte[] bytes) {
Optional<String> detected = detectMediaType(bytes).filter(MediaDetectionService::isAllowedMediaType); Optional<String> detected = detectMediaType(bytes)
.map(MediaDetectionService::normalizeJavaScriptMediaType)
.filter(MediaDetectionService::isAllowedMediaType);
if (detected.isPresent()) { if (detected.isPresent()) {
return detected; return detected;
} }
Optional<String> declared = Optional.ofNullable(file.getContentType()).filter( Optional<String> declared = Optional.ofNullable(file.getContentType())
MediaDetectionService::isAllowedMediaType .map(MediaDetectionService::normalizeJavaScriptMediaType)
); .filter(MediaDetectionService::isAllowedMediaType);
if (declared.isPresent()) { if (declared.isPresent()) {
return declared; return declared;
} }
@@ -66,7 +69,8 @@ public class MediaDetectionService {
} }
public static boolean isAllowedMediaType(String mediaType) { 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) { public static boolean isInlineDisplayType(String mediaType) {
@@ -75,4 +79,15 @@ public class MediaDetectionService {
(mediaType.startsWith("image/") || mediaType.startsWith("video/") || mediaType.startsWith("audio/")) (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;
}
} }

View File

@@ -126,6 +126,7 @@ function renderAssets(list) {
function storeAsset(asset, placement = "keep") { function storeAsset(asset, placement = "keep") {
if (!asset) return; if (!asset) return;
console.info(`Storing asset: ${asset.id}`);
const wasExisting = assets.has(asset.id); const wasExisting = assets.has(asset.id);
assets.set(asset.id, asset); assets.set(asset.id, asset);
ensureLayerPosition(asset.id, placement); 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`) return fetch(`/api/channels/${encodeURIComponent(broadcaster)}/canvas`)
.then((r) => { .then((r) => {
if (!r.ok) { if (!r.ok) {
@@ -793,7 +794,7 @@ function setVideoSource(element, asset) {
} }
applyVideoSource(element, next.objectUrl, asset); applyVideoSource(element, next.objectUrl, asset);
}) })
.catch(() => {}); .catch(() => { });
} }
function applyVideoSource(element, objectUrl, asset) { function applyVideoSource(element, objectUrl, asset) {

View File

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

View File

@@ -1,4 +1,5 @@
const assetModal = document.getElementById("custom-asset-modal"); const assetModal = document.getElementById("custom-asset-modal");
const userNameInput = document.getElementById("custom-asset-name");
const userSourceTextArea = document.getElementById("custom-asset-code"); const userSourceTextArea = document.getElementById("custom-asset-code");
const formErrorWrapper = document.getElementById("custom-asset-error"); const formErrorWrapper = document.getElementById("custom-asset-error");
const jsErrorTitle = document.getElementById("js-error-title"); const jsErrorTitle = document.getElementById("js-error-title");
@@ -10,6 +11,25 @@ function toggleCustomAssetModal(event) {
} }
if (assetModal.classList.contains("hidden")) { if (assetModal.classList.contains("hidden")) {
assetModal.classList.remove("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 { } else {
assetModal.classList.add("hidden"); assetModal.classList.add("hidden");
} }
@@ -28,7 +48,74 @@ function submitCodeAsset(formEvent) {
formErrorWrapper.classList.add("hidden"); formErrorWrapper.classList.add("hidden");
jsErrorTitle.textContent = ""; jsErrorTitle.textContent = "";
jsErrorDetails.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; 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) { function getUserJavaScriptSourceError(src) {