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.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,

View File

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

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

View File

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