Improve error reporting

This commit is contained in:
2025-12-15 13:19:20 +01:00
parent 018e11b595
commit 05c315a56f
15 changed files with 194 additions and 200 deletions

View File

@@ -3,12 +3,16 @@
.DEFAULT_GOAL := build .DEFAULT_GOAL := build
IMGFLOAT_DB_PATH ?= ./imgfloat.db
IMGFLOAT_ASSETS_PATH ?= ./assets IMGFLOAT_ASSETS_PATH ?= ./assets
IMGFLOAT_PREVIEWS_PATH ?= ./previews IMGFLOAT_PREVIEWS_PATH ?= ./previews
SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE ?= 10MB
SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE ?= 10MB SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE ?= 10MB
RUNTIME_ENV = IMGFLOAT_ASSETS_PATH=$(IMGFLOAT_ASSETS_PATH) \ RUNTIME_ENV = IMGFLOAT_ASSETS_PATH=$(IMGFLOAT_ASSETS_PATH) \
IMGFLOAT_PREVIEWS_PATH=$(IMGFLOAT_PREVIEWS_PATH) \ IMGFLOAT_PREVIEWS_PATH=$(IMGFLOAT_PREVIEWS_PATH) \
SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE=$(SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE) IMGFLOAT_DB_PATH=$(IMGFLOAT_DB_PATH) \
SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE=$(SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE) \
SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE=$(SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE)
WATCHDIR = ./src/main WATCHDIR = ./src/main
.PHONY: build .PHONY: build
@@ -37,3 +41,4 @@ ssl:
mkdir -p local mkdir -p local
keytool -genkeypair -alias imgfloat -keyalg RSA -keystore local/keystore.p12 -storetype PKCS12 -storepass changeit -keypass changeit -dname "CN=localhost" -validity 365 keytool -genkeypair -alias imgfloat -keyalg RSA -keystore local/keystore.p12 -storetype PKCS12 -storepass changeit -keypass changeit -dname "CN=localhost" -validity 365
echo "Use SSL_ENABLED=true SSL_KEYSTORE_PATH=file:$$PWD/local/keystore.p12" echo "Use SSL_ENABLED=true SSL_KEYSTORE_PATH=file:$$PWD/local/keystore.p12"

View File

@@ -14,6 +14,8 @@
<java.version>17</java.version> <java.version>17</java.version>
<spring.boot.version>3.2.5</spring.boot.version> <spring.boot.version>3.2.5</spring.boot.version>
<hibernate.version>6.4.4.Final</hibernate.version> <hibernate.version>6.4.4.Final</hibernate.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
</properties> </properties>
<dependencyManagement> <dependencyManagement>
@@ -140,8 +142,7 @@
<artifactId>maven-compiler-plugin</artifactId> <artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version> <version>3.11.0</version>
<configuration> <configuration>
<source>${java.version}</source> <release>17</release>
<target>${java.version}</target>
<compilerArgs> <compilerArgs>
<arg>-parameters</arg> <arg>-parameters</arg>
</compilerArgs> </compilerArgs>

View File

@@ -6,6 +6,7 @@ import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.util.unit.DataSize;
import java.util.Locale; import java.util.Locale;
@@ -19,18 +20,28 @@ public class SystemEnvironmentValidator {
private String twitchClientSecret; private String twitchClientSecret;
@Value("${spring.servlet.multipart.max-file-size:#{null}}") @Value("${spring.servlet.multipart.max-file-size:#{null}}")
private String springMaxFileSize; private String springMaxFileSize;
@Value("${spring.servlet.multipart.max-request-size:#{null}}")
private String springMaxRequestSize;
@Value("${IMGFLOAT_ASSETS_PATH:#{null}}") @Value("${IMGFLOAT_ASSETS_PATH:#{null}}")
private String assetsPath; private String assetsPath;
@Value("${IMGFLOAT_PREVIEWS_PATH:#{null}}") @Value("${IMGFLOAT_PREVIEWS_PATH:#{null}}")
private String previewsPath; private String previewsPath;
@Value("${IMGFLOAT_DB_PATH}")
private String dbPath;
private long maxUploadBytes;
private long maxRequestBytes;
@PostConstruct @PostConstruct
public void validate() { public void validate() {
StringBuilder missing = new StringBuilder(); StringBuilder missing = new StringBuilder();
long maxUploadBytes = parseSizeToBytes(springMaxFileSize); maxUploadBytes = DataSize.parse(springMaxFileSize).toBytes();
maxRequestBytes = DataSize.parse(springMaxRequestSize).toBytes();
checkLong(maxUploadBytes, "SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE", missing); checkLong(maxUploadBytes, "SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE", missing);
checkLong(maxRequestBytes, "SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE", missing);
checkString(twitchClientId, "TWITCH_CLIENT_ID", missing); checkString(twitchClientId, "TWITCH_CLIENT_ID", missing);
checkString(dbPath, "IMGFLOAT_DB_PATH", missing);
checkString(twitchClientSecret, "TWITCH_CLIENT_SECRET", missing); checkString(twitchClientSecret, "TWITCH_CLIENT_SECRET", missing);
checkString(assetsPath, "IMGFLOAT_ASSETS_PATH", missing); checkString(assetsPath, "IMGFLOAT_ASSETS_PATH", missing);
checkString(previewsPath, "IMGFLOAT_PREVIEWS_PATH", missing); checkString(previewsPath, "IMGFLOAT_PREVIEWS_PATH", missing);
@@ -49,6 +60,10 @@ public class SystemEnvironmentValidator {
springMaxFileSize, springMaxFileSize,
maxUploadBytes maxUploadBytes
); );
log.info(" - SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE: {} ({} bytes)",
springMaxRequestSize,
maxRequestBytes
);
log.info(" - IMGFLOAT_ASSETS_PATH: {}", assetsPath); log.info(" - IMGFLOAT_ASSETS_PATH: {}", assetsPath);
log.info(" - IMGFLOAT_PREVIEWS_PATH: {}", previewsPath); log.info(" - IMGFLOAT_PREVIEWS_PATH: {}", previewsPath);
} }

View File

@@ -0,0 +1,40 @@
package dev.kruhlmann.imgfloat.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.util.unit.DataSize;
@Configuration
public class UploadLimitsConfig {
private final Environment environment;
public UploadLimitsConfig(Environment environment) {
this.environment = environment;
}
@Bean
public long uploadLimitBytes() {
String value = environment.getProperty("spring.servlet.multipart.max-file-size");
if (value == null || value.isBlank()) {
throw new IllegalStateException(
"spring.servlet.multipart.max-file-size is not set"
);
}
return DataSize.parse(value).toBytes();
}
@Bean
public long uploadRequestLimitBytes() {
String value = environment.getProperty("spring.servlet.multipart.max-request-size");
if (value == null || value.isBlank()) {
throw new IllegalStateException(
"spring.servlet.multipart.max-request-size is not set"
);
}
return DataSize.parse(value).toBytes();
}
}

View File

@@ -247,7 +247,7 @@ public class ChannelApiController {
if (authorized) { if (authorized) {
LOG.debug("Serving asset {} for broadcaster {} to authenticated user {}", assetId, broadcaster, authentication.getName()); LOG.debug("Serving asset {} for broadcaster {} to authenticated user {}", assetId, broadcaster, authentication.getName());
return channelDirectoryService.getAssetContent(broadcaster, assetId) return channelDirectoryService.getAssetContent(assetId)
.map(content -> ResponseEntity.ok() .map(content -> ResponseEntity.ok()
.header("X-Content-Type-Options", "nosniff") .header("X-Content-Type-Options", "nosniff")
.header(HttpHeaders.CONTENT_DISPOSITION, contentDispositionFor(content.mediaType())) .header(HttpHeaders.CONTENT_DISPOSITION, contentDispositionFor(content.mediaType()))
@@ -256,7 +256,7 @@ public class ChannelApiController {
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found")); .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found"));
} }
return channelDirectoryService.getVisibleAssetContent(broadcaster, assetId) return channelDirectoryService.getVisibleAssetContent(assetId)
.map(content -> ResponseEntity.ok() .map(content -> ResponseEntity.ok()
.header("X-Content-Type-Options", "nosniff") .header("X-Content-Type-Options", "nosniff")
.header(HttpHeaders.CONTENT_DISPOSITION, contentDispositionFor(content.mediaType())) .header(HttpHeaders.CONTENT_DISPOSITION, contentDispositionFor(content.mediaType()))
@@ -278,7 +278,7 @@ public class ChannelApiController {
if (authorized) { if (authorized) {
LOG.debug("Serving preview for asset {} for broadcaster {}", assetId, broadcaster); LOG.debug("Serving preview for asset {} for broadcaster {}", assetId, broadcaster);
return channelDirectoryService.getAssetPreview(broadcaster, assetId, true) return channelDirectoryService.getAssetPreview(assetId, true)
.map(content -> ResponseEntity.ok() .map(content -> ResponseEntity.ok()
.header("X-Content-Type-Options", "nosniff") .header("X-Content-Type-Options", "nosniff")
.contentType(MediaType.parseMediaType(content.mediaType())) .contentType(MediaType.parseMediaType(content.mediaType()))
@@ -286,7 +286,7 @@ public class ChannelApiController {
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Preview not found")); .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Preview not found"));
} }
return channelDirectoryService.getAssetPreview(broadcaster, assetId, false) return channelDirectoryService.getAssetPreview(assetId, false)
.map(content -> ResponseEntity.ok() .map(content -> ResponseEntity.ok()
.header("X-Content-Type-Options", "nosniff") .header("X-Content-Type-Options", "nosniff")
.contentType(MediaType.parseMediaType(content.mediaType())) .contentType(MediaType.parseMediaType(content.mediaType()))
@@ -307,7 +307,7 @@ public class ChannelApiController {
OAuth2AuthenticationToken authentication) { OAuth2AuthenticationToken authentication) {
String login = TwitchUser.from(authentication).login(); String login = TwitchUser.from(authentication).login();
ensureAuthorized(broadcaster, login); ensureAuthorized(broadcaster, login);
boolean removed = channelDirectoryService.deleteAsset(broadcaster, assetId); boolean removed = channelDirectoryService.deleteAsset(assetId);
if (!removed) { if (!removed) {
LOG.warn("Attempt to delete missing asset {} on {} by {}", assetId, broadcaster, login); LOG.warn("Attempt to delete missing asset {} on {} by {}", assetId, broadcaster, login);
throw new ResponseStatusException(NOT_FOUND, "Asset not found"); throw new ResponseStatusException(NOT_FOUND, "Asset not found");

View File

@@ -4,9 +4,11 @@ import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
import dev.kruhlmann.imgfloat.service.VersionService; import dev.kruhlmann.imgfloat.service.VersionService;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
import org.springframework.util.unit.DataSize;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import static org.springframework.http.HttpStatus.FORBIDDEN; import static org.springframework.http.HttpStatus.FORBIDDEN;
@@ -17,6 +19,9 @@ public class ViewController {
private final ChannelDirectoryService channelDirectoryService; private final ChannelDirectoryService channelDirectoryService;
private final VersionService versionService; private final VersionService versionService;
@Autowired
private long uploadLimitBytes;
public ViewController(ChannelDirectoryService channelDirectoryService, VersionService versionService) { public ViewController(ChannelDirectoryService channelDirectoryService, VersionService versionService) {
this.channelDirectoryService = channelDirectoryService; this.channelDirectoryService = channelDirectoryService;
this.versionService = versionService; this.versionService = versionService;
@@ -55,6 +60,8 @@ public class ViewController {
LOG.info("Rendering admin console for {} (requested by {})", broadcaster, login); LOG.info("Rendering admin console for {} (requested by {})", broadcaster, login);
model.addAttribute("broadcaster", broadcaster.toLowerCase()); model.addAttribute("broadcaster", broadcaster.toLowerCase());
model.addAttribute("username", login); model.addAttribute("username", login);
model.addAttribute("uploadLimitBytes", uploadLimitBytes);
return "admin"; return "admin";
} }

View File

@@ -47,109 +47,93 @@ public class AssetStorageService {
this.previewRoot = Paths.get(previewRoot).normalize().toAbsolutePath(); this.previewRoot = Paths.get(previewRoot).normalize().toAbsolutePath();
} }
public String storeAsset(String broadcaster, String assetId, byte[] assetBytes, String mediaType) public void storeAsset(String broadcaster, String assetId, byte[] assetBytes, String mediaType)
throws IOException { 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");
} }
String safeUser = sanitizeUserSegment(broadcaster); Path file = assetPath(broadcaster, assetId, mediaType);
Path directory = safeJoin(assetRoot, safeUser); Files.createDirectories(file.getParent());
Files.createDirectories(directory);
String extension = resolveExtension(mediaType);
Path file = directory.resolve(assetId + extension);
Files.write(file, assetBytes, Files.write(file, assetBytes,
StandardOpenOption.CREATE, StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE); StandardOpenOption.WRITE);
logger.info("Wrote asset to {}", file.toString());
return assetRoot.relativize(file).toString();
} }
public String storePreview(String broadcaster, String assetId, byte[] previewBytes) public void storePreview(String broadcaster, String assetId, byte[] previewBytes)
throws IOException { throws IOException {
if (previewBytes == null || previewBytes.length == 0) { if (previewBytes == null || previewBytes.length == 0) return;
return null;
}
String safeUser = sanitizeUserSegment(broadcaster); Path file = previewPath(broadcaster, assetId);
Path directory = safeJoin(previewRoot, safeUser); Files.createDirectories(file.getParent());
Files.createDirectories(directory);
Path file = directory.resolve(assetId + ".png");
Files.write(file, previewBytes, Files.write(file, previewBytes,
StandardOpenOption.CREATE, StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE); StandardOpenOption.WRITE);
logger.info("Wrote asset to {}", file.toString());
return previewRoot.relativize(file).toString();
} }
public Optional<AssetContent> loadAssetFile(String relativePath, String mediaType) { public Optional<AssetContent> loadAssetFile(Asset asset) {
if (relativePath == null || relativePath.isBlank()) return Optional.empty();
try { try {
Path file = safeJoin(assetRoot, relativePath); Path file = assetPath(
asset.getBroadcaster(),
asset.getId(),
asset.getMediaType()
);
if (!Files.exists(file)) return Optional.empty(); if (!Files.exists(file)) return Optional.empty();
String resolved = mediaType;
if (resolved == null || resolved.isBlank()) {
resolved = Files.probeContentType(file);
}
if (resolved == null || resolved.isBlank()) {
resolved = "application/octet-stream";
}
byte[] bytes = Files.readAllBytes(file); byte[] bytes = Files.readAllBytes(file);
return Optional.of(new AssetContent(bytes, resolved)); return Optional.of(new AssetContent(bytes, asset.getMediaType()));
} catch (Exception e) { } catch (Exception e) {
logger.warn("Failed to load asset {}", relativePath, e); logger.warn("Failed to load asset {}", asset.getId(), e);
return Optional.empty(); return Optional.empty();
} }
} }
public Optional<AssetContent> loadPreview(String relativePath) { public Optional<AssetContent> loadPreview(Asset asset) {
if (relativePath == null || relativePath.isBlank()) return Optional.empty();
try { try {
Path file = safeJoin(previewRoot, relativePath); Path file = previewPath(asset.getBroadcaster(), asset.getId());
if (!Files.exists(file)) return Optional.empty(); if (!Files.exists(file)) return Optional.empty();
byte[] bytes = Files.readAllBytes(file); byte[] bytes = Files.readAllBytes(file);
return Optional.of(new AssetContent(bytes, "image/png")); return Optional.of(new AssetContent(bytes, "image/png"));
} catch (Exception e) { } catch (Exception e) {
logger.warn("Failed to load preview {}", relativePath, e); logger.warn("Failed to load preview {}", asset.getId(), e);
return Optional.empty(); return Optional.empty();
} }
} }
public Optional<AssetContent> loadAssetFileSafely(Asset asset) { public Optional<AssetContent> loadAssetFileSafely(Asset asset) {
if (asset.getUrl() == null) return Optional.empty(); if (asset.getUrl() == null) {
return loadAssetFile(asset.getUrl(), asset.getMediaType()); return Optional.empty();
}
return loadAssetFile(asset);
} }
public Optional<AssetContent> loadPreviewSafely(Asset asset) { public Optional<AssetContent> loadPreviewSafely(Asset asset) {
if (asset.getPreview() == null) return Optional.empty(); if (asset.getPreview() == null) {
return loadPreview(asset.getPreview()); return Optional.empty();
}
return loadPreview(asset);
} }
public void deleteAssetFile(String relativePath) { public void deleteAsset(Asset asset) {
if (relativePath == null || relativePath.isBlank()) return;
try { try {
Path file = safeJoin(assetRoot, relativePath); Files.deleteIfExists(
Files.deleteIfExists(file); assetPath(asset.getBroadcaster(), asset.getId(), asset.getMediaType())
);
Files.deleteIfExists(
previewPath(asset.getBroadcaster(), asset.getId())
);
} catch (Exception e) { } catch (Exception e) {
logger.warn("Failed to delete asset {}", relativePath, e); logger.warn("Failed to delete asset {}", asset.getId(), e);
} }
} }
@@ -179,6 +163,17 @@ public class AssetStorageService {
return EXTENSIONS.get(mediaType); return EXTENSIONS.get(mediaType);
} }
private Path assetPath(String broadcaster, String assetId, String mediaType) throws IOException {
String safeUser = sanitizeUserSegment(broadcaster);
String extension = resolveExtension(mediaType);
return safeJoin(assetRoot, safeUser).resolve(assetId + extension);
}
private Path previewPath(String broadcaster, String assetId) throws IOException {
String safeUser = sanitizeUserSegment(broadcaster);
return safeJoin(previewRoot, safeUser).resolve(assetId + ".png");
}
/** /**
* Safe path-join that prevents path traversal. * Safe path-join that prevents path traversal.
* Accepts both "abc/123.png" (relative multi-level) and single components. * Accepts both "abc/123.png" (relative multi-level) and single components.

View File

@@ -18,6 +18,7 @@ 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.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -49,6 +50,9 @@ public class ChannelDirectoryService {
private final MediaDetectionService mediaDetectionService; private final MediaDetectionService mediaDetectionService;
private final MediaOptimizationService mediaOptimizationService; private final MediaOptimizationService mediaOptimizationService;
@Autowired
private long uploadLimitBytes;
public ChannelDirectoryService( public ChannelDirectoryService(
ChannelRepository channelRepository, ChannelRepository channelRepository,
AssetRepository assetRepository, AssetRepository assetRepository,
@@ -130,13 +134,20 @@ public class ChannelDirectoryService {
} }
public Optional<AssetView> createAsset(String broadcaster, MultipartFile file) throws IOException { public Optional<AssetView> createAsset(String broadcaster, MultipartFile file) throws IOException {
long fileSize = file.getSize();
long maxSize = uploadLimitBytes;
if (fileSize > maxSize) {
throw new ResponseStatusException(
PAYLOAD_TOO_LARGE,
String.format(
"Uploaded file is too large (%d bytes). Maximum allowed is %d bytes.",
fileSize,
maxSize
)
);
}
Channel channel = getOrCreateChannel(broadcaster); Channel channel = getOrCreateChannel(broadcaster);
long reported = file.getSize();
byte[] bytes = file.getBytes(); byte[] bytes = file.getBytes();
String mediaType = mediaDetectionService.detectAllowedMediaType(file, bytes) String mediaType = mediaDetectionService.detectAllowedMediaType(file, bytes)
.orElseThrow(() -> new ResponseStatusException( .orElseThrow(() -> new ResponseStatusException(
BAD_REQUEST, "Unsupported media type")); BAD_REQUEST, "Unsupported media type"));
@@ -161,18 +172,18 @@ public class ChannelDirectoryService {
asset.setOriginalMediaType(mediaType); asset.setOriginalMediaType(mediaType);
asset.setMediaType(optimized.mediaType()); asset.setMediaType(optimized.mediaType());
asset.setUrl(assetStorageService.storeAsset( assetStorageService.storeAsset(
channel.getBroadcaster(), channel.getBroadcaster(),
asset.getId(), asset.getId(),
optimized.bytes(), optimized.bytes(),
optimized.mediaType() optimized.mediaType()
)); );
asset.setPreview(assetStorageService.storePreview( assetStorageService.storePreview(
channel.getBroadcaster(), channel.getBroadcaster(),
asset.getId(), asset.getId(),
optimized.previewBytes() optimized.previewBytes()
)); );
asset.setSpeed(1.0); asset.setSpeed(1.0);
asset.setMuted(optimized.mediaType().startsWith("video/")); asset.setMuted(optimized.mediaType().startsWith("video/"));
@@ -274,39 +285,30 @@ public class ChannelDirectoryService {
}); });
} }
public boolean deleteAsset(String broadcaster, String assetId) { public boolean deleteAsset(String assetId) {
String normalized = normalize(broadcaster);
return assetRepository.findById(assetId) return assetRepository.findById(assetId)
.filter(a -> normalized.equals(a.getBroadcaster()))
.map(asset -> { .map(asset -> {
assetStorageService.deleteAssetFile(asset.getUrl());
assetStorageService.deletePreviewFile(asset.getPreview());
assetRepository.delete(asset); assetRepository.delete(asset);
messagingTemplate.convertAndSend(topicFor(broadcaster), assetStorageService.deleteAsset(asset);
AssetEvent.deleted(broadcaster, assetId)); messagingTemplate.convertAndSend(topicFor(asset.getBroadcaster()),
AssetEvent.deleted(asset.getBroadcaster(), assetId));
return true; return true;
}) })
.orElse(false); .orElse(false);
} }
public Optional<AssetContent> getAssetContent(String broadcaster, String assetId) { public Optional<AssetContent> getAssetContent(String assetId) {
String normalized = normalize(broadcaster); return assetRepository.findById(assetId).flatMap(assetStorageService::loadAssetFileSafely);
}
public Optional<AssetContent> getVisibleAssetContent(String assetId) {
return assetRepository.findById(assetId) return assetRepository.findById(assetId)
.filter(a -> normalized.equals(a.getBroadcaster())) .filter(a -> !a.isHidden())
.flatMap(assetStorageService::loadAssetFileSafely); .flatMap(assetStorageService::loadAssetFileSafely);
} }
public Optional<AssetContent> getVisibleAssetContent(String broadcaster, String assetId) { public Optional<AssetContent> getAssetPreview(String assetId, boolean includeHidden) {
String normalized = normalize(broadcaster);
return assetRepository.findById(assetId) return assetRepository.findById(assetId)
.filter(a -> normalized.equals(a.getBroadcaster()) && !a.isHidden())
.flatMap(assetStorageService::loadAssetFileSafely);
}
public Optional<AssetContent> getAssetPreview(String broadcaster, String assetId, boolean includeHidden) {
String normalized = normalize(broadcaster);
return assetRepository.findById(assetId)
.filter(a -> normalized.equals(a.getBroadcaster()))
.filter(a -> includeHidden || !a.isHidden()) .filter(a -> includeHidden || !a.isHidden())
.flatMap(assetStorageService::loadPreviewSafely); .flatMap(assetStorageService::loadPreviewSafely);
} }

View File

@@ -36,6 +36,20 @@ public class VersionService {
} }
private String getGitVersionString() { private String getGitVersionString() {
try {
Process check = new ProcessBuilder("git", "--version")
.redirectErrorStream(true)
.start();
if (check.waitFor() != 0) {
LOG.info("git not found on PATH, skipping git version detection");
return null;
}
} catch (Exception e) {
LOG.info("git not found on PATH, skipping git version detection");
return null;
}
Process process = null; Process process = null;
try { try {
process = new ProcessBuilder("git", "describe", "--tags", "--always") process = new ProcessBuilder("git", "describe", "--tags", "--always")

View File

@@ -1,5 +1,7 @@
server: server:
port: ${SERVER_PORT:8080} port: ${SERVER_PORT:8080}
tomcat:
max-swallow-size: 0
ssl: ssl:
enabled: ${SSL_ENABLED:false} enabled: ${SSL_ENABLED:false}
key-store: ${SSL_KEYSTORE_PATH:classpath:keystore.p12} key-store: ${SSL_KEYSTORE_PATH:classpath:keystore.p12}
@@ -19,13 +21,14 @@ spring:
thymeleaf: thymeleaf:
cache: false cache: false
datasource: datasource:
url: jdbc:sqlite:${IMGFLOAT_DB_PATH:imgfloat.db}?busy_timeout=5000&journal_mode=WAL url: jdbc:sqlite:${IMGFLOAT_DB_PATH}?busy_timeout=5000&journal_mode=WAL
driver-class-name: org.sqlite.JDBC driver-class-name: org.sqlite.JDBC
hikari: hikari:
connection-init-sql: "PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;" connection-init-sql: "PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;"
maximum-pool-size: 1 maximum-pool-size: 1
minimum-idle: 1 minimum-idle: 1
jpa: jpa:
open-in-view: false
hibernate: hibernate:
ddl-auto: update ddl-auto: update
database-platform: org.hibernate.community.dialect.SQLiteDialect database-platform: org.hibernate.community.dialect.SQLiteDialect

View File

@@ -418,9 +418,7 @@ function connect() {
fetchAssets(); fetchAssets();
}, (error) => { }, (error) => {
console.warn('WebSocket connection issue', error); console.warn('WebSocket connection issue', error);
if (typeof showToast === 'function') {
setTimeout(() => showToast('Live updates connection interrupted. Retrying may be necessary.', 'warning'), 1000); setTimeout(() => showToast('Live updates connection interrupted. Retrying may be necessary.', 'warning'), 1000);
}
}); });
} }
@@ -433,11 +431,7 @@ function fetchAssets() {
return r.json(); return r.json();
}) })
.then(renderAssets) .then(renderAssets)
.catch(() => { .catch(() => showToast('Unable to load assets. Please refresh.', 'error'));
if (typeof showToast === 'function') {
showToast('Unable to load assets. Please refresh.', 'error');
}
});
} }
function fetchCanvasSettings() { function fetchCanvasSettings() {
@@ -454,9 +448,7 @@ function fetchCanvasSettings() {
}) })
.catch(() => { .catch(() => {
resizeCanvas(); resizeCanvas();
if (typeof showToast === 'function') {
showToast('Using default canvas size. Unable to load saved settings.', 'warning'); showToast('Using default canvas size. Unable to load saved settings.', 'warning');
}
}); });
} }
@@ -1968,24 +1960,15 @@ function updateVisibility(asset, hidden) {
if (updated.hidden) { if (updated.hidden) {
loopPlaybackState.set(updated.id, false); loopPlaybackState.set(updated.id, false);
stopAudio(updated.id); stopAudio(updated.id);
if (typeof showToast === 'function') {
showToast('Asset hidden from broadcast.', 'info'); showToast('Asset hidden from broadcast.', 'info');
}
} else if (isAudioAsset(updated)) { } else if (isAudioAsset(updated)) {
playAudioFromCanvas(updated, true); playAudioFromCanvas(updated, true);
if (typeof showToast === 'function') {
showToast('Asset is now visible and active.', 'success'); showToast('Asset is now visible and active.', 'success');
} }
} else if (typeof showToast === 'function') {
showToast('Asset is now visible.', 'success'); showToast('Asset is now visible.', 'success');
}
updateRenderState(updated); updateRenderState(updated);
drawAndList(); drawAndList();
}).catch(() => { }).catch(() => showToast('Unable to change visibility right now.', 'error'));
if (typeof showToast === 'function') {
showToast('Unable to change visibility right now.', 'error');
}
});
} }
function triggerAudioPlayback(asset, shouldPlay = true) { function triggerAudioPlayback(asset, shouldPlay = true) {
@@ -2016,15 +1999,9 @@ function deleteAsset(asset) {
selectedAssetId = null; selectedAssetId = null;
} }
drawAndList(); drawAndList();
if (typeof showToast === 'function') {
showToast('Asset deleted.', 'info'); showToast('Asset deleted.', 'info');
}
}) })
.catch(() => { .catch(() => showToast('Unable to delete asset. Please try again.', 'error'));
if (typeof showToast === 'function') {
showToast('Unable to delete asset. Please try again.', 'error');
}
});
} }
function handleFileSelection(input) { function handleFileSelection(input) {
@@ -2043,9 +2020,11 @@ function uploadAsset(file = null) {
const fileInput = document.getElementById('asset-file'); const fileInput = document.getElementById('asset-file');
const selectedFile = file || (fileInput?.files && fileInput.files.length ? fileInput.files[0] : null); const selectedFile = file || (fileInput?.files && fileInput.files.length ? fileInput.files[0] : null);
if (!selectedFile) { if (!selectedFile) {
if (typeof showToast === 'function') {
showToast('Choose an image, GIF, video, or audio file to upload.', 'info'); showToast('Choose an image, GIF, video, or audio file to upload.', 'info');
return;
} }
if (selectedFile.size > upload_limit_bytes) {
showToast(`File is too large. Maximum upload size is ${upload_limit_bytes / 1024 / 1024} MB.`, 'error');
return; return;
} }
@@ -2066,18 +2045,14 @@ function uploadAsset(file = null) {
fileInput.value = ''; fileInput.value = '';
handleFileSelection(fileInput); handleFileSelection(fileInput);
} }
if (typeof showToast === 'function') {
showToast('Upload received. Processing asset...', 'success'); showToast('Upload received. Processing asset...', 'success');
}
updatePendingUpload(pendingId, { status: 'processing' }); updatePendingUpload(pendingId, { status: 'processing' });
}).catch(() => { }).catch(() => {
if (fileNameLabel) { if (fileNameLabel) {
fileNameLabel.textContent = 'Upload failed'; fileNameLabel.textContent = 'Upload failed';
} }
removePendingUpload(pendingId); removePendingUpload(pendingId);
if (typeof showToast === 'function') {
showToast('Upload failed. Please try again with a supported file.', 'error'); showToast('Upload failed. Please try again with a supported file.', 'error');
}
}); });
} }
@@ -2142,7 +2117,7 @@ function persistTransform(asset, silent = false) {
drawAndList(); drawAndList();
} }
}).catch(() => { }).catch(() => {
if (!silent && typeof showToast === 'function') { if (!silent) {
showToast('Unable to save changes. Please retry.', 'error'); showToast('Unable to save changes. Please retry.', 'error');
} }
}); });

View File

@@ -208,6 +208,7 @@
<script th:inline="javascript"> <script th:inline="javascript">
const broadcaster = /*[[${broadcaster}]]*/ ''; const broadcaster = /*[[${broadcaster}]]*/ '';
const username = /*[[${username}]]*/ ''; const username = /*[[${username}]]*/ '';
const upload_limit_bytes = /*[[${uploadLimitBytes}]]*/ + 0;
</script> </script>
<script src="/js/toast.js"></script> <script src="/js/toast.js"></script>
<script src="/js/admin.js"></script> <script src="/js/admin.js"></script>

View File

@@ -56,12 +56,12 @@ 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(), 26214400L); AssetStorageService assetStorageService = new AssetStorageService(assetRoot.toString(), previewRoot.toString());
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();
service = new ChannelDirectoryService(channelRepository, assetRepository, messagingTemplate, service = new ChannelDirectoryService(channelRepository, assetRepository, messagingTemplate,
assetStorageService, mediaDetectionService, mediaOptimizationService, 26214400L); assetStorageService, mediaDetectionService, mediaOptimizationService);
} }
@Test @Test

View File

@@ -1,64 +0,0 @@
package dev.kruhlmann.imgfloat;
import dev.kruhlmann.imgfloat.config.TwitchCredentialsValidator;
import org.junit.jupiter.api.Test;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.context.ConfigurableApplicationContext;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class TwitchEnvironmentValidationTest {
@Test
void failsToStartWhenTwitchCredentialsMissing() {
assertThatThrownBy(() -> new SpringApplicationBuilder(ImgfloatApplication.class)
.properties("server.port=0")
.run())
.hasRootCauseInstanceOf(IllegalArgumentException.class)
.hasRootCauseMessage("Could not resolve placeholder 'TWITCH_CLIENT_ID' in value \"${TWITCH_CLIENT_ID}\"");
}
@Test
void loadsCredentialsFromDotEnvFile() {
ConfigurableApplicationContext context = null;
try {
context = new SpringApplicationBuilder(ImgfloatApplication.class)
.properties(
"server.port=0",
"spring.config.import=optional:file:src/test/resources/valid.env[.properties]")
.run();
ConfigurableApplicationContext finalContext = context;
assertThatCode(() -> finalContext.getBean(TwitchCredentialsValidator.class))
.doesNotThrowAnyException();
} finally {
if (context != null) {
context.close();
}
}
}
@Test
void stripsQuotesAndWhitespaceFromCredentials() {
ConfigurableApplicationContext context = null;
try {
context = new SpringApplicationBuilder(ImgfloatApplication.class)
.properties(
"server.port=0",
"TWITCH_CLIENT_ID=\" quoted-id \"",
"TWITCH_CLIENT_SECRET=' quoted-secret '"
)
.run();
var clientRegistrationRepository = context.getBean(org.springframework.security.oauth2.client.registration.ClientRegistrationRepository.class);
var twitch = clientRegistrationRepository.findByRegistrationId("twitch");
org.assertj.core.api.Assertions.assertThat(twitch.getClientId()).isEqualTo("quoted-id");
org.assertj.core.api.Assertions.assertThat(twitch.getClientSecret()).isEqualTo("quoted-secret");
} finally {
if (context != null) {
context.close();
}
}
}
}

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(), 26214400L); service = new AssetStorageService(assets.toString(), previews.toString());
} }
@Test @Test