Validate environment

This commit is contained in:
2025-12-11 16:35:41 +01:00
parent 7418bca56b
commit 018e11b595
7 changed files with 371 additions and 330 deletions

View File

@@ -3,6 +3,12 @@
.DEFAULT_GOAL := build .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 WATCHDIR = ./src/main
.PHONY: build .PHONY: build
@@ -11,11 +17,12 @@ build:
.PHONY: run .PHONY: run
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 .PHONY: watch
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 .PHONY: test
test: test:

View File

@@ -1,31 +1,102 @@
package dev.kruhlmann.imgfloat.config; 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.beans.factory.annotation.Value;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.ArrayList; import java.util.Locale;
import java.util.List;
@Component @Component
public class SystemEnvironmentValidator implements ApplicationRunner { public class SystemEnvironmentValidator {
private static final Logger log = LoggerFactory.getLogger(SystemEnvironmentValidator.class);
@Value("${IMGFLOAT_UPLOAD_MAX_BYTES:#{null}}") @Value("${spring.security.oauth2.client.registration.twitch.client-id:#{null}}")
private Long maxUploadBytes; 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 @PostConstruct
public void run(ApplicationArguments args) { public void validate() {
List<String> missing = new ArrayList<>(); StringBuilder missing = new StringBuilder();
if (maxUploadBytes == null) long maxUploadBytes = parseSizeToBytes(springMaxFileSize);
missing.add("IMGFLOAT_UPLOAD_MAX_BYTES"); 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( throw new IllegalStateException(
"Missing required environment variables:\n - " + "Missing or invalid environment variables:\n" + missing
String.join("\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;
}
} }
} }

View File

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

View File

@@ -1,161 +1,193 @@
package dev.kruhlmann.imgfloat.service; package dev.kruhlmann.imgfloat.service;
import dev.kruhlmann.imgfloat.service.media.AssetContent; import dev.kruhlmann.imgfloat.service.media.AssetContent;
import dev.kruhlmann.imgfloat.model.Asset;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.*;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.Locale; import java.util.Locale;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
@Service @Service
public class AssetStorageService { public class AssetStorageService {
private static final Logger logger = LoggerFactory.getLogger(AssetStorageService.class); private static final Logger logger = LoggerFactory.getLogger(AssetStorageService.class);
private static final Map<String, String> 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 assetRoot;
private final Path previewRoot; private final Path previewRoot;
public AssetStorageService(@Value("${IMGFLOAT_ASSETS_PATH:assets}") String assetRoot, public AssetStorageService(
@Value("${IMGFLOAT_PREVIEWS_PATH:previews}") String previewRoot) { @Value("${IMGFLOAT_ASSETS_PATH:#{null}}") String assetRoot,
this.assetRoot = Paths.get(assetRoot); @Value("${IMGFLOAT_PREVIEWS_PATH:#{null}}") String previewRoot
this.previewRoot = Paths.get(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) { if (assetBytes == null || assetBytes.length == 0) {
throw new IOException("Asset content is empty"); 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); Files.createDirectories(directory);
String extension = extensionForMediaType(mediaType);
Path assetFile = directory.resolve(assetId + extension); String extension = resolveExtension(mediaType);
Files.write(assetFile, assetBytes, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); Path file = directory.resolve(assetId + extension);
return assetFile.toString();
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) { if (previewBytes == null || previewBytes.length == 0) {
return null; return null;
} }
Path directory = previewRoot.resolve(normalize(broadcaster));
String safeUser = sanitizeUserSegment(broadcaster);
Path directory = safeJoin(previewRoot, safeUser);
Files.createDirectories(directory); Files.createDirectories(directory);
Path previewFile = directory.resolve(assetId + ".png");
Files.write(previewFile, previewBytes, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); Path file = directory.resolve(assetId + ".png");
return previewFile.toString();
Files.write(file, previewBytes,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE);
return previewRoot.relativize(file).toString();
} }
public Optional<AssetContent> loadPreview(String previewPath) { public Optional<AssetContent> loadAssetFile(String relativePath, String mediaType) {
if (previewPath == null || previewPath.isBlank()) { if (relativePath == null || relativePath.isBlank()) return Optional.empty();
return Optional.empty();
}
try { try {
Path path = Paths.get(previewPath); Path file = safeJoin(assetRoot, relativePath);
if (!Files.exists(path)) {
return Optional.empty(); if (!Files.exists(file)) return Optional.empty();
String resolved = mediaType;
if (resolved == null || resolved.isBlank()) {
resolved = Files.probeContentType(file);
} }
try { if (resolved == null || resolved.isBlank()) {
return Optional.of(new AssetContent(Files.readAllBytes(path), "image/png")); resolved = "application/octet-stream";
} catch (IOException e) {
logger.warn("Unable to read preview from {}", previewPath, e);
return Optional.empty();
} }
} 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(); return Optional.empty();
} }
} }
public Optional<AssetContent> loadAssetFile(String assetPath, String mediaType) { public Optional<AssetContent> loadPreview(String relativePath) {
if (assetPath == null || assetPath.isBlank()) { if (relativePath == null || relativePath.isBlank()) return Optional.empty();
return Optional.empty();
}
try { try {
Path path = Paths.get(assetPath); Path file = safeJoin(previewRoot, relativePath);
if (!Files.exists(path)) {
return Optional.empty(); if (!Files.exists(file)) return Optional.empty();
}
try { byte[] bytes = Files.readAllBytes(file);
String resolvedMediaType = mediaType; return Optional.of(new AssetContent(bytes, "image/png"));
if (resolvedMediaType == null || resolvedMediaType.isBlank()) {
resolvedMediaType = Files.probeContentType(path); } catch (Exception e) {
} logger.warn("Failed to load preview {}", relativePath, e);
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);
return Optional.empty(); return Optional.empty();
} }
} }
public void deleteAssetFile(String assetPath) { public Optional<AssetContent> loadAssetFileSafely(Asset asset) {
if (assetPath == null || assetPath.isBlank()) { if (asset.getUrl() == null) return Optional.empty();
return; return loadAssetFile(asset.getUrl(), asset.getMediaType());
} }
try {
Path path = Paths.get(assetPath); public Optional<AssetContent> loadPreviewSafely(Asset asset) {
try { if (asset.getPreview() == null) return Optional.empty();
Files.deleteIfExists(path); return loadPreview(asset.getPreview());
} 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); public void deleteAssetFile(String relativePath) {
if (relativePath == null || relativePath.isBlank()) return;
try {
Path file = safeJoin(assetRoot, relativePath);
Files.deleteIfExists(file);
} catch (Exception e) {
logger.warn("Failed to delete asset {}", relativePath, e);
} }
} }
public void deletePreviewFile(String previewPath) { public void deletePreviewFile(String relativePath) {
if (previewPath == null || previewPath.isBlank()) { if (relativePath == null || relativePath.isBlank()) return;
return;
}
try { try {
Path path = Paths.get(previewPath); Path file = safeJoin(previewRoot, relativePath);
try { Files.deleteIfExists(file);
Files.deleteIfExists(path); } catch (Exception e) {
} catch (IOException e) { logger.warn("Failed to delete preview {}", relativePath, 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);
} }
} }
private String extensionForMediaType(String mediaType) { private String sanitizeUserSegment(String value) {
if (mediaType == null || mediaType.isBlank()) { if (value == null) throw new IllegalArgumentException("Broadcaster is null");
return ".bin";
} String safe = value.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]", "");
return switch (mediaType.toLowerCase(Locale.ROOT)) { if (safe.isBlank()) throw new IllegalArgumentException("Invalid broadcaster: " + value);
case "image/png" -> ".png"; return safe;
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 normalize(String value) { private String resolveExtension(String mediaType) throws IOException {
return value == null ? null : value.toLowerCase(Locale.ROOT); 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;
} }
} }

View File

@@ -11,6 +11,11 @@ import dev.kruhlmann.imgfloat.model.TransformRequest;
import dev.kruhlmann.imgfloat.model.VisibilityRequest; import dev.kruhlmann.imgfloat.model.VisibilityRequest;
import dev.kruhlmann.imgfloat.repository.AssetRepository; import dev.kruhlmann.imgfloat.repository.AssetRepository;
import dev.kruhlmann.imgfloat.repository.ChannelRepository; 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.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -20,54 +25,47 @@ import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.io.IOException; import java.io.IOException;
import java.util.Base64; import java.util.*;
import java.util.Collection; import java.util.regex.Pattern;
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 static org.springframework.http.HttpStatus.BAD_REQUEST; import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.PAYLOAD_TOO_LARGE; import static org.springframework.http.HttpStatus.PAYLOAD_TOO_LARGE;
@Service @Service
public class ChannelDirectoryService { public class ChannelDirectoryService {
private static final Logger logger = LoggerFactory.getLogger(ChannelDirectoryService.class);
private static final double MAX_SPEED = 4.0; private static final double MAX_SPEED = 4.0;
private static final double MIN_AUDIO_SPEED = 0.1; private static final double MIN_AUDIO_SPEED = 0.1;
private static final double MAX_AUDIO_SPEED = 4.0; private static final double MAX_AUDIO_SPEED = 4.0;
private static final double MIN_AUDIO_PITCH = 0.5; private static final double MIN_AUDIO_PITCH = 0.5;
private static final double MAX_AUDIO_PITCH = 2.0; private static final double MAX_AUDIO_PITCH = 2.0;
private static final double MAX_AUDIO_VOLUME = 1.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 ChannelRepository channelRepository;
private final AssetRepository assetRepository; private final AssetRepository assetRepository;
private final SimpMessagingTemplate messagingTemplate; private final SimpMessagingTemplate messagingTemplate;
private final AssetStorageService assetStorageService; private final AssetStorageService assetStorageService;
private final MediaDetectionService mediaDetectionService; private final MediaDetectionService mediaDetectionService;
private final MediaOptimizationService mediaOptimizationService; private final MediaOptimizationService mediaOptimizationService;
private final long maxUploadBytes;
public ChannelDirectoryService(ChannelRepository channelRepository, public ChannelDirectoryService(
ChannelRepository channelRepository,
AssetRepository assetRepository, AssetRepository assetRepository,
SimpMessagingTemplate messagingTemplate, SimpMessagingTemplate messagingTemplate,
AssetStorageService assetStorageService, AssetStorageService assetStorageService,
MediaDetectionService mediaDetectionService, MediaDetectionService mediaDetectionService,
MediaOptimizationService mediaOptimizationService, MediaOptimizationService mediaOptimizationService
@Value("${IMGFLOAT_UPLOAD_MAX_BYTES:26214400}") long maxUploadBytes) { ) {
this.channelRepository = channelRepository; this.channelRepository = channelRepository;
this.assetRepository = assetRepository; this.assetRepository = assetRepository;
this.messagingTemplate = messagingTemplate; this.messagingTemplate = messagingTemplate;
this.assetStorageService = assetStorageService; this.assetStorageService = assetStorageService;
this.mediaDetectionService = mediaDetectionService; this.mediaDetectionService = mediaDetectionService;
this.mediaOptimizationService = mediaOptimizationService; this.mediaOptimizationService = mediaOptimizationService;
this.maxUploadBytes = maxUploadBytes;
} }
public Channel getOrCreateChannel(String broadcaster) { public Channel getOrCreateChannel(String broadcaster) {
String normalized = normalize(broadcaster); String normalized = normalize(broadcaster);
return channelRepository.findById(normalized) return channelRepository.findById(normalized)
@@ -75,9 +73,10 @@ public class ChannelDirectoryService {
} }
public List<String> searchBroadcasters(String query) { public List<String> searchBroadcasters(String query) {
String normalizedQuery = normalize(query); String q = normalize(query);
String searchTerm = normalizedQuery == null || normalizedQuery.isBlank() ? "" : normalizedQuery; return channelRepository
return channelRepository.findTop50ByBroadcasterContainingIgnoreCaseOrderByBroadcasterAsc(searchTerm) .findTop50ByBroadcasterContainingIgnoreCaseOrderByBroadcasterAsc(
q == null ? "" : q)
.stream() .stream()
.map(Channel::getBroadcaster) .map(Channel::getBroadcaster)
.toList(); .toList();
@@ -88,7 +87,8 @@ public class ChannelDirectoryService {
boolean added = channel.addAdmin(username); boolean added = channel.addAdmin(username);
if (added) { if (added) {
channelRepository.save(channel); channelRepository.save(channel);
messagingTemplate.convertAndSend(topicFor(broadcaster), "Admin added: " + username); messagingTemplate.convertAndSend(topicFor(broadcaster),
"Admin added: " + username);
} }
return added; return added;
} }
@@ -98,19 +98,22 @@ public class ChannelDirectoryService {
boolean removed = channel.removeAdmin(username); boolean removed = channel.removeAdmin(username);
if (removed) { if (removed) {
channelRepository.save(channel); channelRepository.save(channel);
messagingTemplate.convertAndSend(topicFor(broadcaster), "Admin removed: " + username); messagingTemplate.convertAndSend(topicFor(broadcaster),
"Admin removed: " + username);
} }
return removed; return removed;
} }
public Collection<AssetView> getAssetsForAdmin(String broadcaster) { public Collection<AssetView> getAssetsForAdmin(String broadcaster) {
String normalized = normalize(broadcaster); String normalized = normalize(broadcaster);
return sortAndMapAssets(normalized, assetRepository.findByBroadcaster(normalized)); return sortAndMapAssets(normalized,
assetRepository.findByBroadcaster(normalized));
} }
public Collection<AssetView> getVisibleAssets(String broadcaster) { public Collection<AssetView> getVisibleAssets(String broadcaster) {
String normalized = normalize(broadcaster); String normalized = normalize(broadcaster);
return sortAndMapAssets(normalized, assetRepository.findByBroadcasterAndHiddenFalse(normalize(broadcaster))); return sortAndMapAssets(normalized,
assetRepository.findByBroadcasterAndHiddenFalse(normalized));
} }
public CanvasSettingsRequest getCanvasSettings(String broadcaster) { public CanvasSettingsRequest getCanvasSettings(String broadcaster) {
@@ -118,46 +121,59 @@ public class ChannelDirectoryService {
return new CanvasSettingsRequest(channel.getCanvasWidth(), channel.getCanvasHeight()); 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 channel = getOrCreateChannel(broadcaster);
channel.setCanvasWidth(request.getWidth()); channel.setCanvasWidth(req.getWidth());
channel.setCanvasHeight(request.getHeight()); channel.setCanvasHeight(req.getHeight());
channelRepository.save(channel); channelRepository.save(channel);
return new CanvasSettingsRequest(channel.getCanvasWidth(), channel.getCanvasHeight()); return new CanvasSettingsRequest(channel.getCanvasWidth(), channel.getCanvasHeight());
} }
public Optional<AssetView> createAsset(String broadcaster, MultipartFile file) throws IOException { public Optional<AssetView> createAsset(String broadcaster, MultipartFile file) throws IOException {
Channel channel = getOrCreateChannel(broadcaster); Channel channel = getOrCreateChannel(broadcaster);
long reportedSize = file.getSize();
if (reportedSize > 0 && reportedSize > maxUploadBytes) { long reported = file.getSize();
throw new ResponseStatusException(PAYLOAD_TOO_LARGE, "Upload exceeds limit");
}
byte[] bytes = file.getBytes(); byte[] bytes = file.getBytes();
if (bytes.length > maxUploadBytes) {
throw new ResponseStatusException(PAYLOAD_TOO_LARGE, "Upload exceeds limit");
}
String mediaType = mediaDetectionService.detectAllowedMediaType(file, bytes) 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); OptimizedAsset optimized = mediaOptimizationService.optimizeAsset(bytes, mediaType);
if (optimized == null) { if (optimized == null) {
return Optional.empty(); return Optional.empty();
} }
String name = Optional.ofNullable(file.getOriginalFilename()) String safeName = Optional.ofNullable(file.getOriginalFilename())
.map(filename -> filename.replaceAll("^.*[/\\\\]", "")) .map(this::sanitizeFilename)
.filter(s -> !s.isBlank()) .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 width = optimized.width() > 0 ? optimized.width() :
double height = optimized.height() > 0 ? optimized.height() : (optimized.mediaType().startsWith("audio/") ? 80 : 360); (optimized.mediaType().startsWith("audio/") ? 400 : 640);
Asset asset = new Asset(channel.getBroadcaster(), name, "", width, height); 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.setOriginalMediaType(mediaType);
asset.setMediaType(optimized.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.setSpeed(1.0);
asset.setMuted(optimized.mediaType().startsWith("video/")); asset.setMuted(optimized.mediaType().startsWith("video/"));
asset.setAudioLoop(false); asset.setAudioLoop(false);
@@ -168,89 +184,78 @@ public class ChannelDirectoryService {
asset.setZIndex(nextZIndex(channel.getBroadcaster())); asset.setZIndex(nextZIndex(channel.getBroadcaster()));
assetRepository.save(asset); assetRepository.save(asset);
AssetView view = AssetView.from(channel.getBroadcaster(), 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); return Optional.of(view);
} }
public Optional<AssetView> updateTransform(String broadcaster, String assetId, TransformRequest request) { private String sanitizeFilename(String original) {
String stripped = original.replaceAll("^.*[/\\\\]", "");
return SAFE_FILENAME.matcher(stripped).replaceAll("_");
}
public Optional<AssetView> updateTransform(String broadcaster, String assetId, TransformRequest req) {
String normalized = normalize(broadcaster); String normalized = normalize(broadcaster);
return assetRepository.findById(assetId) return assetRepository.findById(assetId)
.filter(asset -> normalized.equals(asset.getBroadcaster())) .filter(asset -> normalized.equals(asset.getBroadcaster()))
.map(asset -> { .map(asset -> {
validateTransform(request); validateTransform(req);
asset.setX(request.getX());
asset.setY(request.getY()); asset.setX(req.getX());
asset.setWidth(request.getWidth()); asset.setY(req.getY());
asset.setHeight(request.getHeight()); asset.setWidth(req.getWidth());
asset.setRotation(request.getRotation()); asset.setHeight(req.getHeight());
if (request.getZIndex() != null) { asset.setRotation(req.getRotation());
asset.setZIndex(request.getZIndex());
} if (req.getZIndex() != null) asset.setZIndex(req.getZIndex());
if (request.getSpeed() != null) { if (req.getSpeed() != null) asset.setSpeed(req.getSpeed());
asset.setSpeed(request.getSpeed()); if (req.getMuted() != null && asset.isVideo()) asset.setMuted(req.getMuted());
} if (req.getAudioLoop() != null) asset.setAudioLoop(req.getAudioLoop());
if (request.getMuted() != null && asset.isVideo()) { if (req.getAudioDelayMillis() != null) asset.setAudioDelayMillis(req.getAudioDelayMillis());
asset.setMuted(request.getMuted()); if (req.getAudioSpeed() != null) asset.setAudioSpeed(req.getAudioSpeed());
} if (req.getAudioPitch() != null) asset.setAudioPitch(req.getAudioPitch());
if (request.getAudioLoop() != null) { if (req.getAudioVolume() != null) asset.setAudioVolume(req.getAudioVolume());
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());
}
assetRepository.save(asset); assetRepository.save(asset);
AssetView view = AssetView.from(normalized, asset); AssetView view = AssetView.from(normalized, asset);
AssetPatch patch = AssetPatch.fromTransform(asset); AssetPatch patch = AssetPatch.fromTransform(asset);
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, patch)); messagingTemplate.convertAndSend(topicFor(broadcaster),
AssetEvent.updated(broadcaster, patch));
return view; return view;
}); });
} }
private void validateTransform(TransformRequest request) { private void validateTransform(TransformRequest req) {
if (request.getWidth() <= 0) { if (req.getWidth() <= 0) throw new ResponseStatusException(BAD_REQUEST, "Width must be > 0");
throw new ResponseStatusException(BAD_REQUEST, "Width must be greater than 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))
if (request.getHeight() <= 0) { throw new ResponseStatusException(BAD_REQUEST, "Speed must be between 0 and " + MAX_SPEED);
throw new ResponseStatusException(BAD_REQUEST, "Height must be greater than 0"); if (req.getZIndex() != null && req.getZIndex() < 1)
} throw new ResponseStatusException(BAD_REQUEST, "zIndex must be >= 1");
if (request.getSpeed() != null && (request.getSpeed() < 0 || request.getSpeed() > MAX_SPEED)) { if (req.getAudioDelayMillis() != null && req.getAudioDelayMillis() < 0)
throw new ResponseStatusException(BAD_REQUEST, "Playback speed must be between 0 and " + MAX_SPEED); throw new ResponseStatusException(BAD_REQUEST, "Audio delay >= 0");
} if (req.getAudioSpeed() != null && (req.getAudioSpeed() < MIN_AUDIO_SPEED || req.getAudioSpeed() > MAX_AUDIO_SPEED))
if (request.getZIndex() != null && request.getZIndex() < 1) { throw new ResponseStatusException(BAD_REQUEST, "Audio speed out of range");
throw new ResponseStatusException(BAD_REQUEST, "zIndex must be at least 1"); 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 (request.getAudioDelayMillis() != null && request.getAudioDelayMillis() < 0) { if (req.getAudioVolume() != null && (req.getAudioVolume() < 0 || req.getAudioVolume() > MAX_AUDIO_VOLUME))
throw new ResponseStatusException(BAD_REQUEST, "Audio delay must be zero or greater"); throw new ResponseStatusException(BAD_REQUEST, "Audio volume out of range");
}
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);
}
} }
public Optional<AssetView> triggerPlayback(String broadcaster, String assetId, PlaybackRequest request) { public Optional<AssetView> triggerPlayback(String broadcaster, String assetId, PlaybackRequest req) {
String normalized = normalize(broadcaster); String normalized = normalize(broadcaster);
return assetRepository.findById(assetId) return assetRepository.findById(assetId)
.filter(asset -> normalized.equals(asset.getBroadcaster())) .filter(a -> normalized.equals(a.getBroadcaster()))
.map(asset -> { .map(asset -> {
AssetView view = AssetView.from(normalized, asset); AssetView view = AssetView.from(normalized, asset);
boolean shouldPlay = request == null || request.getPlay(); boolean play = req == null || req.getPlay();
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.play(broadcaster, view, shouldPlay)); messagingTemplate.convertAndSend(topicFor(broadcaster),
AssetEvent.play(broadcaster, view, play));
return view; return view;
}); });
} }
@@ -258,26 +263,27 @@ public class ChannelDirectoryService {
public Optional<AssetView> updateVisibility(String broadcaster, String assetId, VisibilityRequest request) { public Optional<AssetView> updateVisibility(String broadcaster, String assetId, VisibilityRequest request) {
String normalized = normalize(broadcaster); String normalized = normalize(broadcaster);
return assetRepository.findById(assetId) return assetRepository.findById(assetId)
.filter(asset -> normalized.equals(asset.getBroadcaster())) .filter(a -> normalized.equals(a.getBroadcaster()))
.map(asset -> { .map(asset -> {
asset.setHidden(request.isHidden()); asset.setHidden(request.isHidden());
assetRepository.save(asset); assetRepository.save(asset);
AssetView view = AssetView.from(normalized, asset);
AssetPatch patch = AssetPatch.fromVisibility(asset); AssetPatch patch = AssetPatch.fromVisibility(asset);
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.visibility(broadcaster, patch)); messagingTemplate.convertAndSend(topicFor(broadcaster),
return view; AssetEvent.visibility(broadcaster, patch));
return AssetView.from(normalized, asset);
}); });
} }
public boolean deleteAsset(String broadcaster, String assetId) { public boolean deleteAsset(String broadcaster, String assetId) {
String normalized = normalize(broadcaster); String normalized = normalize(broadcaster);
return assetRepository.findById(assetId) return assetRepository.findById(assetId)
.filter(asset -> normalized.equals(asset.getBroadcaster())) .filter(a -> normalized.equals(a.getBroadcaster()))
.map(asset -> { .map(asset -> {
assetStorageService.deleteAssetFile(asset.getUrl()); assetStorageService.deleteAssetFile(asset.getUrl());
assetStorageService.deletePreviewFile(asset.getPreview()); assetStorageService.deletePreviewFile(asset.getPreview());
assetRepository.delete(asset); assetRepository.delete(asset);
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.deleted(broadcaster, assetId)); messagingTemplate.convertAndSend(topicFor(broadcaster),
AssetEvent.deleted(broadcaster, assetId));
return true; return true;
}) })
.orElse(false); .orElse(false);
@@ -286,35 +292,23 @@ public class ChannelDirectoryService {
public Optional<AssetContent> getAssetContent(String broadcaster, String assetId) { public Optional<AssetContent> getAssetContent(String broadcaster, String assetId) {
String normalized = normalize(broadcaster); String normalized = normalize(broadcaster);
return assetRepository.findById(assetId) return assetRepository.findById(assetId)
.filter(asset -> normalized.equals(asset.getBroadcaster())) .filter(a -> normalized.equals(a.getBroadcaster()))
.flatMap(this::decodeAssetData); .flatMap(assetStorageService::loadAssetFileSafely);
} }
public Optional<AssetContent> getVisibleAssetContent(String broadcaster, String assetId) { public Optional<AssetContent> getVisibleAssetContent(String broadcaster, String assetId) {
String normalized = normalize(broadcaster); String normalized = normalize(broadcaster);
return assetRepository.findById(assetId) return assetRepository.findById(assetId)
.filter(asset -> normalized.equals(asset.getBroadcaster())) .filter(a -> normalized.equals(a.getBroadcaster()) && !a.isHidden())
.filter(asset -> !asset.isHidden()) .flatMap(assetStorageService::loadAssetFileSafely);
.flatMap(this::decodeAssetData);
} }
public Optional<AssetContent> getAssetPreview(String broadcaster, String assetId, boolean includeHidden) { public Optional<AssetContent> getAssetPreview(String broadcaster, String assetId, boolean includeHidden) {
String normalized = normalize(broadcaster); String normalized = normalize(broadcaster);
return assetRepository.findById(assetId) return assetRepository.findById(assetId)
.filter(asset -> normalized.equals(asset.getBroadcaster())) .filter(a -> normalized.equals(a.getBroadcaster()))
.filter(asset -> includeHidden || !asset.isHidden()) .filter(a -> includeHidden || !a.isHidden())
.map(asset -> { .flatMap(assetStorageService::loadPreviewSafely);
Optional<AssetContent> 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);
} }
public boolean isBroadcaster(String broadcaster, String username) { public boolean isBroadcaster(String broadcaster, String username) {
@@ -329,67 +323,35 @@ public class ChannelDirectoryService {
} }
public Collection<String> adminChannelsFor(String username) { public Collection<String> adminChannelsFor(String username) {
if (username == null) { if (username == null) return List.of();
return List.of();
}
String login = username.toLowerCase(); String login = username.toLowerCase();
return channelRepository.findAll().stream() return channelRepository.findAll().stream()
.filter(channel -> channel.getAdmins().contains(login)) .filter(c -> c.getAdmins().contains(login))
.map(Channel::getBroadcaster) .map(Channel::getBroadcaster)
.toList(); .toList();
} }
private String topicFor(String broadcaster) {
return "/topic/channel/" + broadcaster.toLowerCase();
}
private String normalize(String value) { private String normalize(String value) {
return value == null ? null : value.toLowerCase(Locale.ROOT); return value == null ? null : value.toLowerCase(Locale.ROOT);
} }
private String topicFor(String broadcaster) {
return "/topic/channel/" + broadcaster.toLowerCase(Locale.ROOT);
}
private List<AssetView> sortAndMapAssets(String broadcaster, Collection<Asset> assets) { private List<AssetView> sortAndMapAssets(String broadcaster, Collection<Asset> assets) {
return assets.stream() return assets.stream()
.sorted(Comparator.comparingInt(Asset::getZIndex) .sorted(Comparator.comparingInt(Asset::getZIndex)
.thenComparing(Asset::getCreatedAt, Comparator.nullsFirst(Comparator.naturalOrder()))) .thenComparing(Asset::getCreatedAt, Comparator.nullsFirst(Comparator.naturalOrder())))
.map(asset -> AssetView.from(broadcaster, asset)) .map(a -> AssetView.from(broadcaster, a))
.toList(); .toList();
} }
private Optional<AssetContent> 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<AssetContent> 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) { private int nextZIndex(String broadcaster) {
return assetRepository.findByBroadcaster(normalize(broadcaster)).stream() return assetRepository.findByBroadcaster(normalize(broadcaster))
.stream()
.mapToInt(Asset::getZIndex) .mapToInt(Asset::getZIndex)
.max() .max()
.orElse(0) + 1; .orElse(0) + 1;
} }
} }

View File

@@ -56,7 +56,7 @@ class ChannelDirectoryServiceTest {
setupInMemoryPersistence(); setupInMemoryPersistence();
Path assetRoot = Files.createTempDirectory("imgfloat-assets-test"); Path assetRoot = Files.createTempDirectory("imgfloat-assets-test");
Path previewRoot = Files.createTempDirectory("imgfloat-previews-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(); MediaPreviewService mediaPreviewService = new MediaPreviewService();
MediaOptimizationService mediaOptimizationService = new MediaOptimizationService(mediaPreviewService); MediaOptimizationService mediaOptimizationService = new MediaOptimizationService(mediaPreviewService);
MediaDetectionService mediaDetectionService = new MediaDetectionService(); MediaDetectionService mediaDetectionService = new MediaDetectionService();

View File

@@ -20,7 +20,7 @@ class AssetStorageServiceTest {
void setUp() throws IOException { void setUp() throws IOException {
assets = Files.createTempDirectory("asset-storage-service"); assets = Files.createTempDirectory("asset-storage-service");
previews = Files.createTempDirectory("preview-storage-service"); previews = Files.createTempDirectory("preview-storage-service");
service = new AssetStorageService(assets.toString(), previews.toString()); service = new AssetStorageService(assets.toString(), previews.toString(), 26214400L);
} }
@Test @Test