mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Remove server validation
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,8 +48,75 @@ 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) {
|
||||||
let ast;
|
let ast;
|
||||||
|
|||||||
Reference in New Issue
Block a user