From 05c315a56f1093bb9983c59965d61ece2a927932 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Mon, 15 Dec 2025 13:19:20 +0100 Subject: [PATCH] Improve error reporting --- Makefile | 7 +- pom.xml | 5 +- .../config/SystemEnvironmentValidator.java | 17 +++- .../imgfloat/config/UploadLimitsConfig.java | 40 ++++++++ .../controller/ChannelApiController.java | 10 +- .../imgfloat/controller/ViewController.java | 7 ++ .../imgfloat/service/AssetStorageService.java | 99 +++++++++---------- .../service/ChannelDirectoryService.java | 60 +++++------ .../imgfloat/service/VersionService.java | 14 +++ src/main/resources/application.yml | 5 +- src/main/resources/static/js/admin.js | 59 ++++------- src/main/resources/templates/admin.html | 1 + .../imgfloat/ChannelDirectoryServiceTest.java | 4 +- .../TwitchEnvironmentValidationTest.java | 64 ------------ .../service/AssetStorageServiceTest.java | 2 +- 15 files changed, 194 insertions(+), 200 deletions(-) create mode 100644 src/main/java/dev/kruhlmann/imgfloat/config/UploadLimitsConfig.java delete mode 100644 src/test/java/dev/kruhlmann/imgfloat/TwitchEnvironmentValidationTest.java diff --git a/Makefile b/Makefile index 23b2ad3..dab640b 100644 --- a/Makefile +++ b/Makefile @@ -3,12 +3,16 @@ .DEFAULT_GOAL := build +IMGFLOAT_DB_PATH ?= ./imgfloat.db IMGFLOAT_ASSETS_PATH ?= ./assets IMGFLOAT_PREVIEWS_PATH ?= ./previews +SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE ?= 10MB 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) + 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 .PHONY: build @@ -37,3 +41,4 @@ ssl: mkdir -p local 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" + diff --git a/pom.xml b/pom.xml index 1c48943..3424c7d 100644 --- a/pom.xml +++ b/pom.xml @@ -14,6 +14,8 @@ 17 3.2.5 6.4.4.Final + UTF-8 + UTF-8 @@ -140,8 +142,7 @@ maven-compiler-plugin 3.11.0 - ${java.version} - ${java.version} + 17 -parameters diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/SystemEnvironmentValidator.java b/src/main/java/dev/kruhlmann/imgfloat/config/SystemEnvironmentValidator.java index 6cd9f51..3a7dcff 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/config/SystemEnvironmentValidator.java +++ b/src/main/java/dev/kruhlmann/imgfloat/config/SystemEnvironmentValidator.java @@ -6,6 +6,7 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; +import org.springframework.util.unit.DataSize; import java.util.Locale; @@ -19,18 +20,28 @@ public class SystemEnvironmentValidator { private String twitchClientSecret; @Value("${spring.servlet.multipart.max-file-size:#{null}}") private String springMaxFileSize; + @Value("${spring.servlet.multipart.max-request-size:#{null}}") + private String springMaxRequestSize; @Value("${IMGFLOAT_ASSETS_PATH:#{null}}") private String assetsPath; @Value("${IMGFLOAT_PREVIEWS_PATH:#{null}}") private String previewsPath; + @Value("${IMGFLOAT_DB_PATH}") + private String dbPath; + + private long maxUploadBytes; + private long maxRequestBytes; @PostConstruct public void validate() { 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(maxRequestBytes, "SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE", missing); checkString(twitchClientId, "TWITCH_CLIENT_ID", missing); + checkString(dbPath, "IMGFLOAT_DB_PATH", missing); checkString(twitchClientSecret, "TWITCH_CLIENT_SECRET", missing); checkString(assetsPath, "IMGFLOAT_ASSETS_PATH", missing); checkString(previewsPath, "IMGFLOAT_PREVIEWS_PATH", missing); @@ -49,6 +60,10 @@ public class SystemEnvironmentValidator { springMaxFileSize, maxUploadBytes ); + log.info(" - SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE: {} ({} bytes)", + springMaxRequestSize, + maxRequestBytes + ); log.info(" - IMGFLOAT_ASSETS_PATH: {}", assetsPath); log.info(" - IMGFLOAT_PREVIEWS_PATH: {}", previewsPath); } diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/UploadLimitsConfig.java b/src/main/java/dev/kruhlmann/imgfloat/config/UploadLimitsConfig.java new file mode 100644 index 0000000..af11d10 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/config/UploadLimitsConfig.java @@ -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(); + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java index 5de93e4..53de6fb 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java @@ -247,7 +247,7 @@ public class ChannelApiController { if (authorized) { 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() .header("X-Content-Type-Options", "nosniff") .header(HttpHeaders.CONTENT_DISPOSITION, contentDispositionFor(content.mediaType())) @@ -256,7 +256,7 @@ public class ChannelApiController { .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found")); } - return channelDirectoryService.getVisibleAssetContent(broadcaster, assetId) + return channelDirectoryService.getVisibleAssetContent(assetId) .map(content -> ResponseEntity.ok() .header("X-Content-Type-Options", "nosniff") .header(HttpHeaders.CONTENT_DISPOSITION, contentDispositionFor(content.mediaType())) @@ -278,7 +278,7 @@ public class ChannelApiController { if (authorized) { 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() .header("X-Content-Type-Options", "nosniff") .contentType(MediaType.parseMediaType(content.mediaType())) @@ -286,7 +286,7 @@ public class ChannelApiController { .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Preview not found")); } - return channelDirectoryService.getAssetPreview(broadcaster, assetId, false) + return channelDirectoryService.getAssetPreview(assetId, false) .map(content -> ResponseEntity.ok() .header("X-Content-Type-Options", "nosniff") .contentType(MediaType.parseMediaType(content.mediaType())) @@ -307,7 +307,7 @@ public class ChannelApiController { OAuth2AuthenticationToken authentication) { String login = TwitchUser.from(authentication).login(); ensureAuthorized(broadcaster, login); - boolean removed = channelDirectoryService.deleteAsset(broadcaster, assetId); + boolean removed = channelDirectoryService.deleteAsset(assetId); if (!removed) { LOG.warn("Attempt to delete missing asset {} on {} by {}", assetId, broadcaster, login); throw new ResponseStatusException(NOT_FOUND, "Asset not found"); diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java index 86708f3..2364127 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java @@ -4,9 +4,11 @@ import dev.kruhlmann.imgfloat.service.ChannelDirectoryService; import dev.kruhlmann.imgfloat.service.VersionService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; +import org.springframework.util.unit.DataSize; import org.springframework.web.server.ResponseStatusException; import static org.springframework.http.HttpStatus.FORBIDDEN; @@ -17,6 +19,9 @@ public class ViewController { private final ChannelDirectoryService channelDirectoryService; private final VersionService versionService; + @Autowired + private long uploadLimitBytes; + public ViewController(ChannelDirectoryService channelDirectoryService, VersionService versionService) { this.channelDirectoryService = channelDirectoryService; this.versionService = versionService; @@ -55,6 +60,8 @@ public class ViewController { LOG.info("Rendering admin console for {} (requested by {})", broadcaster, login); model.addAttribute("broadcaster", broadcaster.toLowerCase()); model.addAttribute("username", login); + model.addAttribute("uploadLimitBytes", uploadLimitBytes); + return "admin"; } diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/AssetStorageService.java b/src/main/java/dev/kruhlmann/imgfloat/service/AssetStorageService.java index fbdca8d..4f90798 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/AssetStorageService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/AssetStorageService.java @@ -47,109 +47,93 @@ public class AssetStorageService { 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 { if (assetBytes == null || assetBytes.length == 0) { throw new IOException("Asset content is empty"); } - String safeUser = sanitizeUserSegment(broadcaster); - Path directory = safeJoin(assetRoot, safeUser); - Files.createDirectories(directory); - - String extension = resolveExtension(mediaType); - Path file = directory.resolve(assetId + extension); + Path file = assetPath(broadcaster, assetId, mediaType); + Files.createDirectories(file.getParent()); Files.write(file, assetBytes, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); - - return assetRoot.relativize(file).toString(); + logger.info("Wrote asset to {}", file.toString()); } - public String storePreview(String broadcaster, String assetId, byte[] previewBytes) + public void storePreview(String broadcaster, String assetId, byte[] previewBytes) throws IOException { - if (previewBytes == null || previewBytes.length == 0) { - return null; - } + if (previewBytes == null || previewBytes.length == 0) return; - String safeUser = sanitizeUserSegment(broadcaster); - Path directory = safeJoin(previewRoot, safeUser); - Files.createDirectories(directory); - - Path file = directory.resolve(assetId + ".png"); + Path file = previewPath(broadcaster, assetId); + Files.createDirectories(file.getParent()); Files.write(file, previewBytes, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); - - return previewRoot.relativize(file).toString(); + logger.info("Wrote asset to {}", file.toString()); } - public Optional loadAssetFile(String relativePath, String mediaType) { - if (relativePath == null || relativePath.isBlank()) return Optional.empty(); - + public Optional loadAssetFile(Asset asset) { try { - Path file = safeJoin(assetRoot, relativePath); + Path file = assetPath( + asset.getBroadcaster(), + asset.getId(), + asset.getMediaType() + ); 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); - return Optional.of(new AssetContent(bytes, resolved)); - + return Optional.of(new AssetContent(bytes, asset.getMediaType())); } catch (Exception e) { - logger.warn("Failed to load asset {}", relativePath, e); + logger.warn("Failed to load asset {}", asset.getId(), e); return Optional.empty(); } } - public Optional loadPreview(String relativePath) { - if (relativePath == null || relativePath.isBlank()) return Optional.empty(); - + public Optional loadPreview(Asset asset) { try { - Path file = safeJoin(previewRoot, relativePath); - + Path file = previewPath(asset.getBroadcaster(), asset.getId()); if (!Files.exists(file)) return Optional.empty(); byte[] bytes = Files.readAllBytes(file); return Optional.of(new AssetContent(bytes, "image/png")); - } catch (Exception e) { - logger.warn("Failed to load preview {}", relativePath, e); + logger.warn("Failed to load preview {}", asset.getId(), e); return Optional.empty(); } } public Optional loadAssetFileSafely(Asset asset) { - if (asset.getUrl() == null) return Optional.empty(); - return loadAssetFile(asset.getUrl(), asset.getMediaType()); + if (asset.getUrl() == null) { + return Optional.empty(); + } + return loadAssetFile(asset); } public Optional loadPreviewSafely(Asset asset) { - if (asset.getPreview() == null) return Optional.empty(); - return loadPreview(asset.getPreview()); + if (asset.getPreview() == null) { + return Optional.empty(); + } + return loadPreview(asset); } - public void deleteAssetFile(String relativePath) { - if (relativePath == null || relativePath.isBlank()) return; - + public void deleteAsset(Asset asset) { try { - Path file = safeJoin(assetRoot, relativePath); - Files.deleteIfExists(file); + Files.deleteIfExists( + assetPath(asset.getBroadcaster(), asset.getId(), asset.getMediaType()) + ); + Files.deleteIfExists( + previewPath(asset.getBroadcaster(), asset.getId()) + ); } 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); } + 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. * Accepts both "abc/123.png" (relative multi-level) and single components. diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java index 04f8ab5..5ec0816 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java @@ -18,6 +18,7 @@ import dev.kruhlmann.imgfloat.service.media.OptimizedAsset; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Service; @@ -49,6 +50,9 @@ public class ChannelDirectoryService { private final MediaDetectionService mediaDetectionService; private final MediaOptimizationService mediaOptimizationService; + @Autowired + private long uploadLimitBytes; + public ChannelDirectoryService( ChannelRepository channelRepository, AssetRepository assetRepository, @@ -130,13 +134,20 @@ public class ChannelDirectoryService { } public Optional 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); - - long reported = file.getSize(); - byte[] bytes = file.getBytes(); - String mediaType = mediaDetectionService.detectAllowedMediaType(file, bytes) .orElseThrow(() -> new ResponseStatusException( BAD_REQUEST, "Unsupported media type")); @@ -161,18 +172,18 @@ public class ChannelDirectoryService { asset.setOriginalMediaType(mediaType); asset.setMediaType(optimized.mediaType()); - asset.setUrl(assetStorageService.storeAsset( + assetStorageService.storeAsset( channel.getBroadcaster(), asset.getId(), optimized.bytes(), optimized.mediaType() - )); + ); - asset.setPreview(assetStorageService.storePreview( + assetStorageService.storePreview( channel.getBroadcaster(), asset.getId(), optimized.previewBytes() - )); + ); asset.setSpeed(1.0); asset.setMuted(optimized.mediaType().startsWith("video/")); @@ -274,39 +285,30 @@ public class ChannelDirectoryService { }); } - public boolean deleteAsset(String broadcaster, String assetId) { - String normalized = normalize(broadcaster); + public boolean deleteAsset(String assetId) { return assetRepository.findById(assetId) - .filter(a -> normalized.equals(a.getBroadcaster())) .map(asset -> { - assetStorageService.deleteAssetFile(asset.getUrl()); - assetStorageService.deletePreviewFile(asset.getPreview()); assetRepository.delete(asset); - messagingTemplate.convertAndSend(topicFor(broadcaster), - AssetEvent.deleted(broadcaster, assetId)); + assetStorageService.deleteAsset(asset); + messagingTemplate.convertAndSend(topicFor(asset.getBroadcaster()), + AssetEvent.deleted(asset.getBroadcaster(), assetId)); return true; }) .orElse(false); } - public Optional getAssetContent(String broadcaster, String assetId) { - String normalized = normalize(broadcaster); + public Optional getAssetContent(String assetId) { + return assetRepository.findById(assetId).flatMap(assetStorageService::loadAssetFileSafely); + } + + public Optional getVisibleAssetContent(String assetId) { return assetRepository.findById(assetId) - .filter(a -> normalized.equals(a.getBroadcaster())) + .filter(a -> !a.isHidden()) .flatMap(assetStorageService::loadAssetFileSafely); } - public Optional getVisibleAssetContent(String broadcaster, String assetId) { - String normalized = normalize(broadcaster); + public Optional getAssetPreview(String assetId, boolean includeHidden) { return assetRepository.findById(assetId) - .filter(a -> normalized.equals(a.getBroadcaster()) && !a.isHidden()) - .flatMap(assetStorageService::loadAssetFileSafely); - } - - public Optional getAssetPreview(String broadcaster, String assetId, boolean includeHidden) { - String normalized = normalize(broadcaster); - return assetRepository.findById(assetId) - .filter(a -> normalized.equals(a.getBroadcaster())) .filter(a -> includeHidden || !a.isHidden()) .flatMap(assetStorageService::loadPreviewSafely); } diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/VersionService.java b/src/main/java/dev/kruhlmann/imgfloat/service/VersionService.java index f429aa1..5576c6a 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/VersionService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/VersionService.java @@ -36,6 +36,20 @@ public class VersionService { } 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; try { process = new ProcessBuilder("git", "describe", "--tags", "--always") diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 19fd4b0..a3935ca 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,5 +1,7 @@ server: port: ${SERVER_PORT:8080} + tomcat: + max-swallow-size: 0 ssl: enabled: ${SSL_ENABLED:false} key-store: ${SSL_KEYSTORE_PATH:classpath:keystore.p12} @@ -19,13 +21,14 @@ spring: thymeleaf: cache: false 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 hikari: connection-init-sql: "PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;" maximum-pool-size: 1 minimum-idle: 1 jpa: + open-in-view: false hibernate: ddl-auto: update database-platform: org.hibernate.community.dialect.SQLiteDialect diff --git a/src/main/resources/static/js/admin.js b/src/main/resources/static/js/admin.js index 19e0b47..6410586 100644 --- a/src/main/resources/static/js/admin.js +++ b/src/main/resources/static/js/admin.js @@ -418,9 +418,7 @@ function connect() { fetchAssets(); }, (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(); }) .then(renderAssets) - .catch(() => { - if (typeof showToast === 'function') { - showToast('Unable to load assets. Please refresh.', 'error'); - } - }); + .catch(() => showToast('Unable to load assets. Please refresh.', 'error')); } function fetchCanvasSettings() { @@ -454,9 +448,7 @@ function fetchCanvasSettings() { }) .catch(() => { 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) { loopPlaybackState.set(updated.id, false); stopAudio(updated.id); - if (typeof showToast === 'function') { - showToast('Asset hidden from broadcast.', 'info'); - } + showToast('Asset hidden from broadcast.', 'info'); } else if (isAudioAsset(updated)) { playAudioFromCanvas(updated, true); - if (typeof showToast === 'function') { - showToast('Asset is now visible and active.', 'success'); - } - } else if (typeof showToast === 'function') { - showToast('Asset is now visible.', 'success'); + showToast('Asset is now visible and active.', 'success'); } + showToast('Asset is now visible.', 'success'); updateRenderState(updated); drawAndList(); - }).catch(() => { - if (typeof showToast === 'function') { - showToast('Unable to change visibility right now.', 'error'); - } - }); + }).catch(() => showToast('Unable to change visibility right now.', 'error')); } function triggerAudioPlayback(asset, shouldPlay = true) { @@ -2016,15 +1999,9 @@ function deleteAsset(asset) { selectedAssetId = null; } drawAndList(); - if (typeof showToast === 'function') { - showToast('Asset deleted.', 'info'); - } + showToast('Asset deleted.', 'info'); }) - .catch(() => { - if (typeof showToast === 'function') { - showToast('Unable to delete asset. Please try again.', 'error'); - } - }); + .catch(() => showToast('Unable to delete asset. Please try again.', 'error')); } function handleFileSelection(input) { @@ -2043,9 +2020,11 @@ function uploadAsset(file = null) { const fileInput = document.getElementById('asset-file'); const selectedFile = file || (fileInput?.files && fileInput.files.length ? fileInput.files[0] : null); 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; } @@ -2066,18 +2045,14 @@ function uploadAsset(file = null) { fileInput.value = ''; handleFileSelection(fileInput); } - if (typeof showToast === 'function') { - showToast('Upload received. Processing asset...', 'success'); - } + showToast('Upload received. Processing asset...', 'success'); updatePendingUpload(pendingId, { status: 'processing' }); }).catch(() => { if (fileNameLabel) { fileNameLabel.textContent = 'Upload failed'; } 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(); } }).catch(() => { - if (!silent && typeof showToast === 'function') { + if (!silent) { showToast('Unable to save changes. Please retry.', 'error'); } }); diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html index 6a07988..5620b7d 100644 --- a/src/main/resources/templates/admin.html +++ b/src/main/resources/templates/admin.html @@ -208,6 +208,7 @@ diff --git a/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java b/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java index 5732106..65d6c9d 100644 --- a/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java +++ b/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java @@ -56,12 +56,12 @@ class ChannelDirectoryServiceTest { setupInMemoryPersistence(); Path assetRoot = Files.createTempDirectory("imgfloat-assets-test"); Path previewRoot = Files.createTempDirectory("imgfloat-previews-test"); - AssetStorageService assetStorageService = new AssetStorageService(assetRoot.toString(), previewRoot.toString(), 26214400L); + AssetStorageService assetStorageService = new AssetStorageService(assetRoot.toString(), previewRoot.toString()); MediaPreviewService mediaPreviewService = new MediaPreviewService(); MediaOptimizationService mediaOptimizationService = new MediaOptimizationService(mediaPreviewService); MediaDetectionService mediaDetectionService = new MediaDetectionService(); service = new ChannelDirectoryService(channelRepository, assetRepository, messagingTemplate, - assetStorageService, mediaDetectionService, mediaOptimizationService, 26214400L); + assetStorageService, mediaDetectionService, mediaOptimizationService); } @Test diff --git a/src/test/java/dev/kruhlmann/imgfloat/TwitchEnvironmentValidationTest.java b/src/test/java/dev/kruhlmann/imgfloat/TwitchEnvironmentValidationTest.java deleted file mode 100644 index 7c0198e..0000000 --- a/src/test/java/dev/kruhlmann/imgfloat/TwitchEnvironmentValidationTest.java +++ /dev/null @@ -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(); - } - } - } -} diff --git a/src/test/java/dev/kruhlmann/imgfloat/service/AssetStorageServiceTest.java b/src/test/java/dev/kruhlmann/imgfloat/service/AssetStorageServiceTest.java index 0362f8b..b3fc7c2 100644 --- a/src/test/java/dev/kruhlmann/imgfloat/service/AssetStorageServiceTest.java +++ b/src/test/java/dev/kruhlmann/imgfloat/service/AssetStorageServiceTest.java @@ -20,7 +20,7 @@ class AssetStorageServiceTest { void setUp() throws IOException { assets = Files.createTempDirectory("asset-storage-service"); previews = Files.createTempDirectory("preview-storage-service"); - service = new AssetStorageService(assets.toString(), previews.toString(), 26214400L); + service = new AssetStorageService(assets.toString(), previews.toString()); } @Test