From 018e11b59511956f8b6202026f59a2d75496a7fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Thu, 11 Dec 2025 16:35:41 +0100 Subject: [PATCH] Validate environment --- Makefile | 11 +- .../config/SystemEnvironmentValidator.java | 101 +++++- .../config/TwitchCredentialsValidator.java | 31 -- .../imgfloat/service/AssetStorageService.java | 242 ++++++++------ .../service/ChannelDirectoryService.java | 312 ++++++++---------- .../imgfloat/ChannelDirectoryServiceTest.java | 2 +- .../service/AssetStorageServiceTest.java | 2 +- 7 files changed, 371 insertions(+), 330 deletions(-) delete mode 100644 src/main/java/dev/kruhlmann/imgfloat/config/TwitchCredentialsValidator.java diff --git a/Makefile b/Makefile index 0357319..23b2ad3 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,12 @@ .DEFAULT_GOAL := build +IMGFLOAT_ASSETS_PATH ?= ./assets +IMGFLOAT_PREVIEWS_PATH ?= ./previews +SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE ?= 10MB +RUNTIME_ENV = IMGFLOAT_ASSETS_PATH=$(IMGFLOAT_ASSETS_PATH) \ + IMGFLOAT_PREVIEWS_PATH=$(IMGFLOAT_PREVIEWS_PATH) \ + SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE=$(SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE) WATCHDIR = ./src/main .PHONY: build @@ -11,11 +17,12 @@ build: .PHONY: run run: - test -f .env && . ./.env; IMGFLOAT_UPLOAD_MAX_BYTES=16777216 mvn spring-boot:run + test -f .env && . ./.env; $(RUNTIME_ENV) mvn spring-boot:run .PHONY: watch watch: - while sleep 0.1; do find $(WATCHDIR) -type f | entr -d mvn -q compile; done + mvn compile + while sleep 0.1; do find $(WATCHDIR) -type f | entr -d mvn compile; done .PHONY: test test: diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/SystemEnvironmentValidator.java b/src/main/java/dev/kruhlmann/imgfloat/config/SystemEnvironmentValidator.java index 7828536..6cd9f51 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/config/SystemEnvironmentValidator.java +++ b/src/main/java/dev/kruhlmann/imgfloat/config/SystemEnvironmentValidator.java @@ -1,31 +1,102 @@ package dev.kruhlmann.imgfloat.config; +import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.ApplicationArguments; -import org.springframework.boot.ApplicationRunner; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; -import java.util.ArrayList; -import java.util.List; +import java.util.Locale; @Component -public class SystemEnvironmentValidator implements ApplicationRunner { +public class SystemEnvironmentValidator { + private static final Logger log = LoggerFactory.getLogger(SystemEnvironmentValidator.class); - @Value("${IMGFLOAT_UPLOAD_MAX_BYTES:#{null}}") - private Long maxUploadBytes; + @Value("${spring.security.oauth2.client.registration.twitch.client-id:#{null}}") + private String twitchClientId; + @Value("${spring.security.oauth2.client.registration.twitch.client-secret:#{null}}") + private String twitchClientSecret; + @Value("${spring.servlet.multipart.max-file-size:#{null}}") + private String springMaxFileSize; + @Value("${IMGFLOAT_ASSETS_PATH:#{null}}") + private String assetsPath; + @Value("${IMGFLOAT_PREVIEWS_PATH:#{null}}") + private String previewsPath; - @Override - public void run(ApplicationArguments args) { - List missing = new ArrayList<>(); + @PostConstruct + public void validate() { + StringBuilder missing = new StringBuilder(); - if (maxUploadBytes == null) - missing.add("IMGFLOAT_UPLOAD_MAX_BYTES"); + long maxUploadBytes = parseSizeToBytes(springMaxFileSize); + checkLong(maxUploadBytes, "SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE", missing); + checkString(twitchClientId, "TWITCH_CLIENT_ID", missing); + checkString(twitchClientSecret, "TWITCH_CLIENT_SECRET", missing); + checkString(assetsPath, "IMGFLOAT_ASSETS_PATH", missing); + checkString(previewsPath, "IMGFLOAT_PREVIEWS_PATH", missing); - if (!missing.isEmpty()) { + if (missing.length() > 0) { throw new IllegalStateException( - "Missing required environment variables:\n - " + - String.join("\n - ", missing) + "Missing or invalid environment variables:\n" + missing ); } + + log.info("Environment validation successful."); + log.info("Configuration:"); + log.info(" - TWITCH_CLIENT_ID: {}", redact(twitchClientId)); + log.info(" - TWITCH_CLIENT_SECRET: {}", redact(twitchClientSecret)); + log.info(" - SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE: {} ({} bytes)", + springMaxFileSize, + maxUploadBytes + ); + log.info(" - IMGFLOAT_ASSETS_PATH: {}", assetsPath); + log.info(" - IMGFLOAT_PREVIEWS_PATH: {}", previewsPath); + } + + private void checkString(String value, String name, StringBuilder missing) { + if (!StringUtils.hasText(value) || "changeme".equalsIgnoreCase(value.trim())) { + missing.append(" - ").append(name).append("\n"); + } + } + + private void checkLong(Long value, String name, StringBuilder missing) { + if (value == null || value <= 0) { + missing.append(" - ").append(name).append("\n"); + } + } + + private String formatBytes(long bytes) { + if (bytes < 1024) return bytes + " B"; + + double kb = bytes / 1024.0; + if (kb < 1024) return String.format("%.2f KB", kb); + + double mb = kb / 1024.0; + if (mb < 1024) return String.format("%.2f MB", mb); + + double gb = mb / 1024.0; + return String.format("%.2f GB", gb); + } + + private String redact(String value) { + if (!StringUtils.hasText(value)) return "(missing)"; + if (value.length() <= 6) return "******"; + return value.substring(0, 2) + "****" + value.substring(value.length() - 2); + } + + private long parseSizeToBytes(String value) { + if (value == null) return -1; + + String v = value.trim().toUpperCase(Locale.ROOT); + + try { + if (v.endsWith("GB")) return Long.parseLong(v.replace("GB", "")) * 1024 * 1024 * 1024; + if (v.endsWith("MB")) return Long.parseLong(v.replace("MB", "")) * 1024 * 1024; + if (v.endsWith("KB")) return Long.parseLong(v.replace("KB", "")) * 1024; + if (v.endsWith("B")) return Long.parseLong(v.replace("B", "")); + return Long.parseLong(v); + } catch (NumberFormatException e) { + return -1; + } } } diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/TwitchCredentialsValidator.java b/src/main/java/dev/kruhlmann/imgfloat/config/TwitchCredentialsValidator.java deleted file mode 100644 index 28405de..0000000 --- a/src/main/java/dev/kruhlmann/imgfloat/config/TwitchCredentialsValidator.java +++ /dev/null @@ -1,31 +0,0 @@ -package dev.kruhlmann.imgfloat.config; - -import jakarta.annotation.PostConstruct; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; -import org.springframework.util.StringUtils; - -@Component -public class TwitchCredentialsValidator { - private final String clientId; - private final String clientSecret; - - public TwitchCredentialsValidator( - @Value("${spring.security.oauth2.client.registration.twitch.client-id}") String clientId, - @Value("${spring.security.oauth2.client.registration.twitch.client-secret}") String clientSecret) { - this.clientId = clientId; - this.clientSecret = clientSecret; - } - - @PostConstruct - void validate() { - ensurePresent(clientId, "TWITCH_CLIENT_ID"); - ensurePresent(clientSecret, "TWITCH_CLIENT_SECRET"); - } - - private void ensurePresent(String value, String name) { - if (!StringUtils.hasText(value) || "changeme".equalsIgnoreCase(value.trim())) { - throw new IllegalStateException(name + " must be set in the environment or .env file"); - } - } -} diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/AssetStorageService.java b/src/main/java/dev/kruhlmann/imgfloat/service/AssetStorageService.java index 247cadb..fbdca8d 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/AssetStorageService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/AssetStorageService.java @@ -1,161 +1,193 @@ package dev.kruhlmann.imgfloat.service; import dev.kruhlmann.imgfloat.service.media.AssetContent; +import dev.kruhlmann.imgfloat.model.Asset; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.InvalidPathException; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.StandardOpenOption; +import java.nio.file.*; import java.util.Locale; +import java.util.Map; import java.util.Optional; @Service public class AssetStorageService { private static final Logger logger = LoggerFactory.getLogger(AssetStorageService.class); + private static final Map EXTENSIONS = Map.ofEntries( + Map.entry("image/png", ".png"), + Map.entry("image/jpeg", ".jpg"), + Map.entry("image/jpg", ".jpg"), + Map.entry("image/gif", ".gif"), + Map.entry("image/webp", ".webp"), + Map.entry("image/bmp", ".bmp"), + Map.entry("image/tiff", ".tiff"), + Map.entry("video/mp4", ".mp4"), + Map.entry("video/webm", ".webm"), + Map.entry("video/quicktime", ".mov"), + Map.entry("video/x-matroska", ".mkv"), + Map.entry("audio/mpeg", ".mp3"), + Map.entry("audio/mp3", ".mp3"), + Map.entry("audio/wav", ".wav"), + Map.entry("audio/ogg", ".ogg"), + Map.entry("audio/webm", ".webm"), + Map.entry("audio/flac", ".flac") + ); + private final Path assetRoot; private final Path previewRoot; - public AssetStorageService(@Value("${IMGFLOAT_ASSETS_PATH:assets}") String assetRoot, - @Value("${IMGFLOAT_PREVIEWS_PATH:previews}") String previewRoot) { - this.assetRoot = Paths.get(assetRoot); - this.previewRoot = Paths.get(previewRoot); + public AssetStorageService( + @Value("${IMGFLOAT_ASSETS_PATH:#{null}}") String assetRoot, + @Value("${IMGFLOAT_PREVIEWS_PATH:#{null}}") String previewRoot + ) { + this.assetRoot = Paths.get(assetRoot).normalize().toAbsolutePath(); + this.previewRoot = Paths.get(previewRoot).normalize().toAbsolutePath(); } - public String storeAsset(String broadcaster, String assetId, byte[] assetBytes, String mediaType) throws IOException { + public String storeAsset(String broadcaster, String assetId, byte[] assetBytes, String mediaType) + throws IOException { + if (assetBytes == null || assetBytes.length == 0) { throw new IOException("Asset content is empty"); } - Path directory = assetRoot.resolve(normalize(broadcaster)); + + String safeUser = sanitizeUserSegment(broadcaster); + Path directory = safeJoin(assetRoot, safeUser); Files.createDirectories(directory); - String extension = extensionForMediaType(mediaType); - Path assetFile = directory.resolve(assetId + extension); - Files.write(assetFile, assetBytes, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); - return assetFile.toString(); + + String extension = resolveExtension(mediaType); + Path file = directory.resolve(assetId + extension); + + Files.write(file, assetBytes, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, + StandardOpenOption.WRITE); + + return assetRoot.relativize(file).toString(); } - public String storePreview(String broadcaster, String assetId, byte[] previewBytes) throws IOException { + public String storePreview(String broadcaster, String assetId, byte[] previewBytes) + throws IOException { + if (previewBytes == null || previewBytes.length == 0) { return null; } - Path directory = previewRoot.resolve(normalize(broadcaster)); + + String safeUser = sanitizeUserSegment(broadcaster); + Path directory = safeJoin(previewRoot, safeUser); Files.createDirectories(directory); - Path previewFile = directory.resolve(assetId + ".png"); - Files.write(previewFile, previewBytes, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); - return previewFile.toString(); + + Path file = directory.resolve(assetId + ".png"); + + Files.write(file, previewBytes, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, + StandardOpenOption.WRITE); + + return previewRoot.relativize(file).toString(); } - public Optional loadPreview(String previewPath) { - if (previewPath == null || previewPath.isBlank()) { - return Optional.empty(); - } + public Optional loadAssetFile(String relativePath, String mediaType) { + if (relativePath == null || relativePath.isBlank()) return Optional.empty(); + try { - Path path = Paths.get(previewPath); - if (!Files.exists(path)) { - return Optional.empty(); + Path file = safeJoin(assetRoot, relativePath); + + if (!Files.exists(file)) return Optional.empty(); + + String resolved = mediaType; + if (resolved == null || resolved.isBlank()) { + resolved = Files.probeContentType(file); } - try { - return Optional.of(new AssetContent(Files.readAllBytes(path), "image/png")); - } catch (IOException e) { - logger.warn("Unable to read preview from {}", previewPath, e); - return Optional.empty(); + if (resolved == null || resolved.isBlank()) { + resolved = "application/octet-stream"; } - } catch (InvalidPathException e) { - logger.debug("Preview path {} is not a file path; skipping", previewPath); + + byte[] bytes = Files.readAllBytes(file); + return Optional.of(new AssetContent(bytes, resolved)); + + } catch (Exception e) { + logger.warn("Failed to load asset {}", relativePath, e); return Optional.empty(); } } - public Optional loadAssetFile(String assetPath, String mediaType) { - if (assetPath == null || assetPath.isBlank()) { - return Optional.empty(); - } + public Optional loadPreview(String relativePath) { + if (relativePath == null || relativePath.isBlank()) return Optional.empty(); + try { - Path path = Paths.get(assetPath); - if (!Files.exists(path)) { - return Optional.empty(); - } - try { - String resolvedMediaType = mediaType; - if (resolvedMediaType == null || resolvedMediaType.isBlank()) { - resolvedMediaType = Files.probeContentType(path); - } - if (resolvedMediaType == null || resolvedMediaType.isBlank()) { - resolvedMediaType = "application/octet-stream"; - } - return Optional.of(new AssetContent(Files.readAllBytes(path), resolvedMediaType)); - } catch (IOException e) { - logger.warn("Unable to read asset from {}", assetPath, e); - return Optional.empty(); - } - } catch (InvalidPathException e) { - logger.debug("Asset path {} is not a file path; skipping", assetPath); + Path file = safeJoin(previewRoot, relativePath); + + if (!Files.exists(file)) return Optional.empty(); + + byte[] bytes = Files.readAllBytes(file); + return Optional.of(new AssetContent(bytes, "image/png")); + + } catch (Exception e) { + logger.warn("Failed to load preview {}", relativePath, e); return Optional.empty(); } } - public void deleteAssetFile(String assetPath) { - if (assetPath == null || assetPath.isBlank()) { - return; - } + public Optional loadAssetFileSafely(Asset asset) { + if (asset.getUrl() == null) return Optional.empty(); + return loadAssetFile(asset.getUrl(), asset.getMediaType()); + } + + public Optional loadPreviewSafely(Asset asset) { + if (asset.getPreview() == null) return Optional.empty(); + return loadPreview(asset.getPreview()); + } + + public void deleteAssetFile(String relativePath) { + if (relativePath == null || relativePath.isBlank()) return; + try { - Path path = Paths.get(assetPath); - try { - Files.deleteIfExists(path); - } catch (IOException e) { - logger.warn("Unable to delete asset file {}", assetPath, e); - } - } catch (InvalidPathException e) { - logger.debug("Asset value {} is not a file path; nothing to delete", assetPath); + Path file = safeJoin(assetRoot, relativePath); + Files.deleteIfExists(file); + } catch (Exception e) { + logger.warn("Failed to delete asset {}", relativePath, e); } } - public void deletePreviewFile(String previewPath) { - if (previewPath == null || previewPath.isBlank()) { - return; - } + public void deletePreviewFile(String relativePath) { + if (relativePath == null || relativePath.isBlank()) return; + try { - Path path = Paths.get(previewPath); - try { - Files.deleteIfExists(path); - } catch (IOException e) { - logger.warn("Unable to delete preview file {}", previewPath, e); - } - } catch (InvalidPathException e) { - logger.debug("Preview value {} is not a file path; nothing to delete", previewPath); + Path file = safeJoin(previewRoot, relativePath); + Files.deleteIfExists(file); + } catch (Exception e) { + logger.warn("Failed to delete preview {}", relativePath, e); } } - private String extensionForMediaType(String mediaType) { - if (mediaType == null || mediaType.isBlank()) { - return ".bin"; - } - return switch (mediaType.toLowerCase(Locale.ROOT)) { - case "image/png" -> ".png"; - case "image/jpeg", "image/jpg" -> ".jpg"; - case "image/gif" -> ".gif"; - case "video/mp4" -> ".mp4"; - case "video/webm" -> ".webm"; - case "video/quicktime" -> ".mov"; - case "audio/mpeg" -> ".mp3"; - case "audio/wav" -> ".wav"; - case "audio/ogg" -> ".ogg"; - default -> { - int slash = mediaType.indexOf('/'); - if (slash > -1 && slash < mediaType.length() - 1) { - yield "." + mediaType.substring(slash + 1).replaceAll("[^a-z0-9.+-]", ""); - } - yield ".bin"; - } - }; + private String sanitizeUserSegment(String value) { + if (value == null) throw new IllegalArgumentException("Broadcaster is null"); + + String safe = value.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]", ""); + if (safe.isBlank()) throw new IllegalArgumentException("Invalid broadcaster: " + value); + return safe; } - private String normalize(String value) { - return value == null ? null : value.toLowerCase(Locale.ROOT); + private String resolveExtension(String mediaType) throws IOException { + if (mediaType == null || !EXTENSIONS.containsKey(mediaType)) { + throw new IOException("Unsupported media type: " + mediaType); + } + return EXTENSIONS.get(mediaType); + } + + /** + * Safe path-join that prevents path traversal. + * Accepts both "abc/123.png" (relative multi-level) and single components. + */ + private Path safeJoin(Path root, String relative) throws IOException { + Path resolved = root.resolve(relative).normalize(); + if (!resolved.startsWith(root)) { + throw new IOException("Path traversal attempt: " + relative); + } + return resolved; } } diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java index f53d66b..04f8ab5 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java @@ -11,6 +11,11 @@ import dev.kruhlmann.imgfloat.model.TransformRequest; import dev.kruhlmann.imgfloat.model.VisibilityRequest; import dev.kruhlmann.imgfloat.repository.AssetRepository; import dev.kruhlmann.imgfloat.repository.ChannelRepository; +import dev.kruhlmann.imgfloat.service.media.AssetContent; +import dev.kruhlmann.imgfloat.service.media.MediaDetectionService; +import dev.kruhlmann.imgfloat.service.media.MediaOptimizationService; +import dev.kruhlmann.imgfloat.service.media.OptimizedAsset; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -20,54 +25,47 @@ import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; import java.io.IOException; -import java.util.Base64; -import java.util.Collection; -import java.util.Comparator; -import java.util.List; -import java.util.Locale; -import java.util.Optional; - -import dev.kruhlmann.imgfloat.service.media.AssetContent; -import dev.kruhlmann.imgfloat.service.media.MediaDetectionService; -import dev.kruhlmann.imgfloat.service.media.MediaOptimizationService; -import dev.kruhlmann.imgfloat.service.media.OptimizedAsset; +import java.util.*; +import java.util.regex.Pattern; import static org.springframework.http.HttpStatus.BAD_REQUEST; import static org.springframework.http.HttpStatus.PAYLOAD_TOO_LARGE; @Service public class ChannelDirectoryService { + private static final Logger logger = LoggerFactory.getLogger(ChannelDirectoryService.class); private static final double MAX_SPEED = 4.0; private static final double MIN_AUDIO_SPEED = 0.1; private static final double MAX_AUDIO_SPEED = 4.0; private static final double MIN_AUDIO_PITCH = 0.5; private static final double MAX_AUDIO_PITCH = 2.0; private static final double MAX_AUDIO_VOLUME = 1.0; - private static final Logger logger = LoggerFactory.getLogger(ChannelDirectoryService.class); + private static final Pattern SAFE_FILENAME = Pattern.compile("[^a-zA-Z0-9._ -]"); + private final ChannelRepository channelRepository; private final AssetRepository assetRepository; private final SimpMessagingTemplate messagingTemplate; private final AssetStorageService assetStorageService; private final MediaDetectionService mediaDetectionService; private final MediaOptimizationService mediaOptimizationService; - private final long maxUploadBytes; - public ChannelDirectoryService(ChannelRepository channelRepository, - AssetRepository assetRepository, - SimpMessagingTemplate messagingTemplate, - AssetStorageService assetStorageService, - MediaDetectionService mediaDetectionService, - MediaOptimizationService mediaOptimizationService, - @Value("${IMGFLOAT_UPLOAD_MAX_BYTES:26214400}") long maxUploadBytes) { + public ChannelDirectoryService( + ChannelRepository channelRepository, + AssetRepository assetRepository, + SimpMessagingTemplate messagingTemplate, + AssetStorageService assetStorageService, + MediaDetectionService mediaDetectionService, + MediaOptimizationService mediaOptimizationService + ) { this.channelRepository = channelRepository; this.assetRepository = assetRepository; this.messagingTemplate = messagingTemplate; this.assetStorageService = assetStorageService; this.mediaDetectionService = mediaDetectionService; this.mediaOptimizationService = mediaOptimizationService; - this.maxUploadBytes = maxUploadBytes; } + public Channel getOrCreateChannel(String broadcaster) { String normalized = normalize(broadcaster); return channelRepository.findById(normalized) @@ -75,9 +73,10 @@ public class ChannelDirectoryService { } public List searchBroadcasters(String query) { - String normalizedQuery = normalize(query); - String searchTerm = normalizedQuery == null || normalizedQuery.isBlank() ? "" : normalizedQuery; - return channelRepository.findTop50ByBroadcasterContainingIgnoreCaseOrderByBroadcasterAsc(searchTerm) + String q = normalize(query); + return channelRepository + .findTop50ByBroadcasterContainingIgnoreCaseOrderByBroadcasterAsc( + q == null ? "" : q) .stream() .map(Channel::getBroadcaster) .toList(); @@ -88,7 +87,8 @@ public class ChannelDirectoryService { boolean added = channel.addAdmin(username); if (added) { channelRepository.save(channel); - messagingTemplate.convertAndSend(topicFor(broadcaster), "Admin added: " + username); + messagingTemplate.convertAndSend(topicFor(broadcaster), + "Admin added: " + username); } return added; } @@ -98,19 +98,22 @@ public class ChannelDirectoryService { boolean removed = channel.removeAdmin(username); if (removed) { channelRepository.save(channel); - messagingTemplate.convertAndSend(topicFor(broadcaster), "Admin removed: " + username); + messagingTemplate.convertAndSend(topicFor(broadcaster), + "Admin removed: " + username); } return removed; } public Collection getAssetsForAdmin(String broadcaster) { String normalized = normalize(broadcaster); - return sortAndMapAssets(normalized, assetRepository.findByBroadcaster(normalized)); + return sortAndMapAssets(normalized, + assetRepository.findByBroadcaster(normalized)); } public Collection getVisibleAssets(String broadcaster) { String normalized = normalize(broadcaster); - return sortAndMapAssets(normalized, assetRepository.findByBroadcasterAndHiddenFalse(normalize(broadcaster))); + return sortAndMapAssets(normalized, + assetRepository.findByBroadcasterAndHiddenFalse(normalized)); } public CanvasSettingsRequest getCanvasSettings(String broadcaster) { @@ -118,46 +121,59 @@ public class ChannelDirectoryService { return new CanvasSettingsRequest(channel.getCanvasWidth(), channel.getCanvasHeight()); } - public CanvasSettingsRequest updateCanvasSettings(String broadcaster, CanvasSettingsRequest request) { + public CanvasSettingsRequest updateCanvasSettings(String broadcaster, CanvasSettingsRequest req) { Channel channel = getOrCreateChannel(broadcaster); - channel.setCanvasWidth(request.getWidth()); - channel.setCanvasHeight(request.getHeight()); + channel.setCanvasWidth(req.getWidth()); + channel.setCanvasHeight(req.getHeight()); channelRepository.save(channel); return new CanvasSettingsRequest(channel.getCanvasWidth(), channel.getCanvasHeight()); } public Optional createAsset(String broadcaster, MultipartFile file) throws IOException { + Channel channel = getOrCreateChannel(broadcaster); - long reportedSize = file.getSize(); - if (reportedSize > 0 && reportedSize > maxUploadBytes) { - throw new ResponseStatusException(PAYLOAD_TOO_LARGE, "Upload exceeds limit"); - } + + long reported = file.getSize(); byte[] bytes = file.getBytes(); - if (bytes.length > maxUploadBytes) { - throw new ResponseStatusException(PAYLOAD_TOO_LARGE, "Upload exceeds limit"); - } String mediaType = mediaDetectionService.detectAllowedMediaType(file, bytes) - .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unsupported media type")); + .orElseThrow(() -> new ResponseStatusException( + BAD_REQUEST, "Unsupported media type")); OptimizedAsset optimized = mediaOptimizationService.optimizeAsset(bytes, mediaType); if (optimized == null) { return Optional.empty(); } - String name = Optional.ofNullable(file.getOriginalFilename()) - .map(filename -> filename.replaceAll("^.*[/\\\\]", "")) + String safeName = Optional.ofNullable(file.getOriginalFilename()) + .map(this::sanitizeFilename) .filter(s -> !s.isBlank()) - .orElse("Asset " + System.currentTimeMillis()); + .orElse("asset_" + System.currentTimeMillis()); - double width = optimized.width() > 0 ? optimized.width() : (optimized.mediaType().startsWith("audio/") ? 400 : 640); - double height = optimized.height() > 0 ? optimized.height() : (optimized.mediaType().startsWith("audio/") ? 80 : 360); - Asset asset = new Asset(channel.getBroadcaster(), name, "", width, height); + double width = optimized.width() > 0 ? optimized.width() : + (optimized.mediaType().startsWith("audio/") ? 400 : 640); + double height = optimized.height() > 0 ? optimized.height() : + (optimized.mediaType().startsWith("audio/") ? 80 : 360); + + Asset asset = new Asset(channel.getBroadcaster(), safeName, "", + width, height); asset.setOriginalMediaType(mediaType); asset.setMediaType(optimized.mediaType()); - asset.setUrl(assetStorageService.storeAsset(channel.getBroadcaster(), asset.getId(), optimized.bytes(), optimized.mediaType())); - asset.setPreview(assetStorageService.storePreview(channel.getBroadcaster(), asset.getId(), optimized.previewBytes())); + + asset.setUrl(assetStorageService.storeAsset( + channel.getBroadcaster(), + asset.getId(), + optimized.bytes(), + optimized.mediaType() + )); + + asset.setPreview(assetStorageService.storePreview( + channel.getBroadcaster(), + asset.getId(), + optimized.previewBytes() + )); + asset.setSpeed(1.0); asset.setMuted(optimized.mediaType().startsWith("video/")); asset.setAudioLoop(false); @@ -168,89 +184,78 @@ public class ChannelDirectoryService { asset.setZIndex(nextZIndex(channel.getBroadcaster())); assetRepository.save(asset); + AssetView view = AssetView.from(channel.getBroadcaster(), asset); - messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, view)); + messagingTemplate.convertAndSend(topicFor(broadcaster), + AssetEvent.created(broadcaster, view)); + return Optional.of(view); } - public Optional updateTransform(String broadcaster, String assetId, TransformRequest request) { + private String sanitizeFilename(String original) { + String stripped = original.replaceAll("^.*[/\\\\]", ""); + return SAFE_FILENAME.matcher(stripped).replaceAll("_"); + } + + public Optional updateTransform(String broadcaster, String assetId, TransformRequest req) { String normalized = normalize(broadcaster); + return assetRepository.findById(assetId) .filter(asset -> normalized.equals(asset.getBroadcaster())) .map(asset -> { - validateTransform(request); - asset.setX(request.getX()); - asset.setY(request.getY()); - asset.setWidth(request.getWidth()); - asset.setHeight(request.getHeight()); - asset.setRotation(request.getRotation()); - if (request.getZIndex() != null) { - asset.setZIndex(request.getZIndex()); - } - if (request.getSpeed() != null) { - asset.setSpeed(request.getSpeed()); - } - if (request.getMuted() != null && asset.isVideo()) { - asset.setMuted(request.getMuted()); - } - if (request.getAudioLoop() != null) { - asset.setAudioLoop(request.getAudioLoop()); - } - if (request.getAudioDelayMillis() != null) { - asset.setAudioDelayMillis(request.getAudioDelayMillis()); - } - if (request.getAudioSpeed() != null) { - asset.setAudioSpeed(request.getAudioSpeed()); - } - if (request.getAudioPitch() != null) { - asset.setAudioPitch(request.getAudioPitch()); - } - if (request.getAudioVolume() != null) { - asset.setAudioVolume(request.getAudioVolume()); - } + validateTransform(req); + + asset.setX(req.getX()); + asset.setY(req.getY()); + asset.setWidth(req.getWidth()); + asset.setHeight(req.getHeight()); + asset.setRotation(req.getRotation()); + + if (req.getZIndex() != null) asset.setZIndex(req.getZIndex()); + if (req.getSpeed() != null) asset.setSpeed(req.getSpeed()); + if (req.getMuted() != null && asset.isVideo()) asset.setMuted(req.getMuted()); + if (req.getAudioLoop() != null) asset.setAudioLoop(req.getAudioLoop()); + if (req.getAudioDelayMillis() != null) asset.setAudioDelayMillis(req.getAudioDelayMillis()); + if (req.getAudioSpeed() != null) asset.setAudioSpeed(req.getAudioSpeed()); + if (req.getAudioPitch() != null) asset.setAudioPitch(req.getAudioPitch()); + if (req.getAudioVolume() != null) asset.setAudioVolume(req.getAudioVolume()); + assetRepository.save(asset); + AssetView view = AssetView.from(normalized, asset); AssetPatch patch = AssetPatch.fromTransform(asset); - messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, patch)); + messagingTemplate.convertAndSend(topicFor(broadcaster), + AssetEvent.updated(broadcaster, patch)); return view; }); } - private void validateTransform(TransformRequest request) { - if (request.getWidth() <= 0) { - throw new ResponseStatusException(BAD_REQUEST, "Width must be greater than 0"); - } - if (request.getHeight() <= 0) { - throw new ResponseStatusException(BAD_REQUEST, "Height must be greater than 0"); - } - if (request.getSpeed() != null && (request.getSpeed() < 0 || request.getSpeed() > MAX_SPEED)) { - throw new ResponseStatusException(BAD_REQUEST, "Playback speed must be between 0 and " + MAX_SPEED); - } - if (request.getZIndex() != null && request.getZIndex() < 1) { - throw new ResponseStatusException(BAD_REQUEST, "zIndex must be at least 1"); - } - if (request.getAudioDelayMillis() != null && request.getAudioDelayMillis() < 0) { - throw new ResponseStatusException(BAD_REQUEST, "Audio delay must be zero or greater"); - } - if (request.getAudioSpeed() != null && (request.getAudioSpeed() < MIN_AUDIO_SPEED || request.getAudioSpeed() > MAX_AUDIO_SPEED)) { - throw new ResponseStatusException(BAD_REQUEST, "Audio speed must be between " + MIN_AUDIO_SPEED + " and " + MAX_AUDIO_SPEED + "x"); - } - if (request.getAudioPitch() != null && (request.getAudioPitch() < MIN_AUDIO_PITCH || request.getAudioPitch() > MAX_AUDIO_PITCH)) { - throw new ResponseStatusException(BAD_REQUEST, "Audio pitch must be between " + MIN_AUDIO_PITCH + " and " + MAX_AUDIO_PITCH + "x"); - } - if (request.getAudioVolume() != null && (request.getAudioVolume() < 0 || request.getAudioVolume() > MAX_AUDIO_VOLUME)) { - throw new ResponseStatusException(BAD_REQUEST, "Audio volume must be between 0 and " + MAX_AUDIO_VOLUME); - } + private void validateTransform(TransformRequest req) { + if (req.getWidth() <= 0) throw new ResponseStatusException(BAD_REQUEST, "Width must be > 0"); + if (req.getHeight() <= 0) throw new ResponseStatusException(BAD_REQUEST, "Height must be > 0"); + if (req.getSpeed() != null && (req.getSpeed() < 0 || req.getSpeed() > MAX_SPEED)) + throw new ResponseStatusException(BAD_REQUEST, "Speed must be between 0 and " + MAX_SPEED); + if (req.getZIndex() != null && req.getZIndex() < 1) + throw new ResponseStatusException(BAD_REQUEST, "zIndex must be >= 1"); + if (req.getAudioDelayMillis() != null && req.getAudioDelayMillis() < 0) + throw new ResponseStatusException(BAD_REQUEST, "Audio delay >= 0"); + if (req.getAudioSpeed() != null && (req.getAudioSpeed() < MIN_AUDIO_SPEED || req.getAudioSpeed() > MAX_AUDIO_SPEED)) + throw new ResponseStatusException(BAD_REQUEST, "Audio speed out of range"); + if (req.getAudioPitch() != null && (req.getAudioPitch() < MIN_AUDIO_PITCH || req.getAudioPitch() > MAX_AUDIO_PITCH)) + throw new ResponseStatusException(BAD_REQUEST, "Audio pitch out of range"); + if (req.getAudioVolume() != null && (req.getAudioVolume() < 0 || req.getAudioVolume() > MAX_AUDIO_VOLUME)) + throw new ResponseStatusException(BAD_REQUEST, "Audio volume out of range"); } - public Optional triggerPlayback(String broadcaster, String assetId, PlaybackRequest request) { + public Optional triggerPlayback(String broadcaster, String assetId, PlaybackRequest req) { String normalized = normalize(broadcaster); return assetRepository.findById(assetId) - .filter(asset -> normalized.equals(asset.getBroadcaster())) + .filter(a -> normalized.equals(a.getBroadcaster())) .map(asset -> { AssetView view = AssetView.from(normalized, asset); - boolean shouldPlay = request == null || request.getPlay(); - messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.play(broadcaster, view, shouldPlay)); + boolean play = req == null || req.getPlay(); + messagingTemplate.convertAndSend(topicFor(broadcaster), + AssetEvent.play(broadcaster, view, play)); return view; }); } @@ -258,26 +263,27 @@ public class ChannelDirectoryService { public Optional updateVisibility(String broadcaster, String assetId, VisibilityRequest request) { String normalized = normalize(broadcaster); return assetRepository.findById(assetId) - .filter(asset -> normalized.equals(asset.getBroadcaster())) + .filter(a -> normalized.equals(a.getBroadcaster())) .map(asset -> { asset.setHidden(request.isHidden()); assetRepository.save(asset); - AssetView view = AssetView.from(normalized, asset); AssetPatch patch = AssetPatch.fromVisibility(asset); - messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.visibility(broadcaster, patch)); - return view; + messagingTemplate.convertAndSend(topicFor(broadcaster), + AssetEvent.visibility(broadcaster, patch)); + return AssetView.from(normalized, asset); }); } public boolean deleteAsset(String broadcaster, String assetId) { String normalized = normalize(broadcaster); return assetRepository.findById(assetId) - .filter(asset -> normalized.equals(asset.getBroadcaster())) + .filter(a -> normalized.equals(a.getBroadcaster())) .map(asset -> { assetStorageService.deleteAssetFile(asset.getUrl()); assetStorageService.deletePreviewFile(asset.getPreview()); assetRepository.delete(asset); - messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.deleted(broadcaster, assetId)); + messagingTemplate.convertAndSend(topicFor(broadcaster), + AssetEvent.deleted(broadcaster, assetId)); return true; }) .orElse(false); @@ -286,35 +292,23 @@ public class ChannelDirectoryService { public Optional getAssetContent(String broadcaster, String assetId) { String normalized = normalize(broadcaster); return assetRepository.findById(assetId) - .filter(asset -> normalized.equals(asset.getBroadcaster())) - .flatMap(this::decodeAssetData); + .filter(a -> normalized.equals(a.getBroadcaster())) + .flatMap(assetStorageService::loadAssetFileSafely); } public Optional getVisibleAssetContent(String broadcaster, String assetId) { String normalized = normalize(broadcaster); return assetRepository.findById(assetId) - .filter(asset -> normalized.equals(asset.getBroadcaster())) - .filter(asset -> !asset.isHidden()) - .flatMap(this::decodeAssetData); + .filter(a -> normalized.equals(a.getBroadcaster()) && !a.isHidden()) + .flatMap(assetStorageService::loadAssetFileSafely); } public Optional getAssetPreview(String broadcaster, String assetId, boolean includeHidden) { String normalized = normalize(broadcaster); return assetRepository.findById(assetId) - .filter(asset -> normalized.equals(asset.getBroadcaster())) - .filter(asset -> includeHidden || !asset.isHidden()) - .map(asset -> { - Optional preview = assetStorageService.loadPreview(asset.getPreview()) - .or(() -> decodeDataUrl(asset.getPreview())); - if (preview.isPresent()) { - return preview.get(); - } - if (asset.getMediaType() != null && asset.getMediaType().startsWith("image/")) { - return decodeAssetData(asset).orElse(null); - } - return null; - }) - .flatMap(Optional::ofNullable); + .filter(a -> normalized.equals(a.getBroadcaster())) + .filter(a -> includeHidden || !a.isHidden()) + .flatMap(assetStorageService::loadPreviewSafely); } public boolean isBroadcaster(String broadcaster, String username) { @@ -329,67 +323,35 @@ public class ChannelDirectoryService { } public Collection adminChannelsFor(String username) { - if (username == null) { - return List.of(); - } + if (username == null) return List.of(); String login = username.toLowerCase(); return channelRepository.findAll().stream() - .filter(channel -> channel.getAdmins().contains(login)) + .filter(c -> c.getAdmins().contains(login)) .map(Channel::getBroadcaster) .toList(); } - private String topicFor(String broadcaster) { - return "/topic/channel/" + broadcaster.toLowerCase(); - } - private String normalize(String value) { return value == null ? null : value.toLowerCase(Locale.ROOT); } + private String topicFor(String broadcaster) { + return "/topic/channel/" + broadcaster.toLowerCase(Locale.ROOT); + } + private List sortAndMapAssets(String broadcaster, Collection assets) { return assets.stream() .sorted(Comparator.comparingInt(Asset::getZIndex) .thenComparing(Asset::getCreatedAt, Comparator.nullsFirst(Comparator.naturalOrder()))) - .map(asset -> AssetView.from(broadcaster, asset)) + .map(a -> AssetView.from(broadcaster, a)) .toList(); } - private Optional decodeAssetData(Asset asset) { - return assetStorageService.loadAssetFile(asset.getUrl(), asset.getMediaType()) - .or(() -> decodeDataUrl(asset.getUrl())) - .or(() -> { - logger.warn("Unable to decode asset data for {}", asset.getId()); - return Optional.empty(); - }); - } - - private Optional decodeDataUrl(String dataUrl) { - if (dataUrl == null || !dataUrl.startsWith("data:")) { - return Optional.empty(); - } - int commaIndex = dataUrl.indexOf(','); - if (commaIndex < 0) { - return Optional.empty(); - } - String metadata = dataUrl.substring(5, commaIndex); - String[] parts = metadata.split(";", 2); - String mediaType = parts.length > 0 && !parts[0].isBlank() ? parts[0] : "application/octet-stream"; - String encoded = dataUrl.substring(commaIndex + 1); - try { - byte[] bytes = Base64.getDecoder().decode(encoded); - return Optional.of(new AssetContent(bytes, mediaType)); - } catch (IllegalArgumentException e) { - logger.warn("Unable to decode data url", e); - return Optional.empty(); - } - } - private int nextZIndex(String broadcaster) { - return assetRepository.findByBroadcaster(normalize(broadcaster)).stream() + return assetRepository.findByBroadcaster(normalize(broadcaster)) + .stream() .mapToInt(Asset::getZIndex) .max() .orElse(0) + 1; } - } diff --git a/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java b/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java index ec53b45..5732106 100644 --- a/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java +++ b/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java @@ -56,7 +56,7 @@ class ChannelDirectoryServiceTest { setupInMemoryPersistence(); Path assetRoot = Files.createTempDirectory("imgfloat-assets-test"); Path previewRoot = Files.createTempDirectory("imgfloat-previews-test"); - AssetStorageService assetStorageService = new AssetStorageService(assetRoot.toString(), previewRoot.toString()); + AssetStorageService assetStorageService = new AssetStorageService(assetRoot.toString(), previewRoot.toString(), 26214400L); MediaPreviewService mediaPreviewService = new MediaPreviewService(); MediaOptimizationService mediaOptimizationService = new MediaOptimizationService(mediaPreviewService); MediaDetectionService mediaDetectionService = new MediaDetectionService(); diff --git a/src/test/java/dev/kruhlmann/imgfloat/service/AssetStorageServiceTest.java b/src/test/java/dev/kruhlmann/imgfloat/service/AssetStorageServiceTest.java index b3fc7c2..0362f8b 100644 --- a/src/test/java/dev/kruhlmann/imgfloat/service/AssetStorageServiceTest.java +++ b/src/test/java/dev/kruhlmann/imgfloat/service/AssetStorageServiceTest.java @@ -20,7 +20,7 @@ class AssetStorageServiceTest { void setUp() throws IOException { assets = Files.createTempDirectory("asset-storage-service"); previews = Files.createTempDirectory("preview-storage-service"); - service = new AssetStorageService(assets.toString(), previews.toString()); + service = new AssetStorageService(assets.toString(), previews.toString(), 26214400L); } @Test