mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Validate environment
This commit is contained in:
11
Makefile
11
Makefile
@@ -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:
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Optional<AssetContent> 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 {
|
try {
|
||||||
Path path = Paths.get(assetPath);
|
Path file = safeJoin(assetRoot, relativePath);
|
||||||
try {
|
Files.deleteIfExists(file);
|
||||||
Files.deleteIfExists(path);
|
} catch (Exception e) {
|
||||||
} catch (IOException e) {
|
logger.warn("Failed to delete asset {}", relativePath, 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 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
AssetRepository assetRepository,
|
ChannelRepository channelRepository,
|
||||||
SimpMessagingTemplate messagingTemplate,
|
AssetRepository assetRepository,
|
||||||
AssetStorageService assetStorageService,
|
SimpMessagingTemplate messagingTemplate,
|
||||||
MediaDetectionService mediaDetectionService,
|
AssetStorageService assetStorageService,
|
||||||
MediaOptimizationService mediaOptimizationService,
|
MediaDetectionService mediaDetectionService,
|
||||||
@Value("${IMGFLOAT_UPLOAD_MAX_BYTES:26214400}") long maxUploadBytes) {
|
MediaOptimizationService mediaOptimizationService
|
||||||
|
) {
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user