mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 11:49:25 +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.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<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")
|
||||
public ResponseEntity<AssetView> transform(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.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<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) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<String> ALLOWED_MEDIA_TYPES = Set.copyOf(EXTENSION_TYPES.values());
|
||||
|
||||
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()) {
|
||||
return detected;
|
||||
}
|
||||
|
||||
Optional<String> declared = Optional.ofNullable(file.getContentType()).filter(
|
||||
MediaDetectionService::isAllowedMediaType
|
||||
);
|
||||
Optional<String> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user