mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 11:49:25 +00:00
Improve error reporting
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AssetContent> loadAssetFile(String relativePath, String mediaType) {
|
||||
if (relativePath == null || relativePath.isBlank()) return Optional.empty();
|
||||
|
||||
public Optional<AssetContent> 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<AssetContent> loadPreview(String relativePath) {
|
||||
if (relativePath == null || relativePath.isBlank()) return Optional.empty();
|
||||
|
||||
public Optional<AssetContent> 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<AssetContent> 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<AssetContent> 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.
|
||||
|
||||
@@ -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<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);
|
||||
|
||||
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<AssetContent> getAssetContent(String broadcaster, String assetId) {
|
||||
String normalized = normalize(broadcaster);
|
||||
public Optional<AssetContent> getAssetContent(String assetId) {
|
||||
return assetRepository.findById(assetId).flatMap(assetStorageService::loadAssetFileSafely);
|
||||
}
|
||||
|
||||
public Optional<AssetContent> getVisibleAssetContent(String assetId) {
|
||||
return assetRepository.findById(assetId)
|
||||
.filter(a -> normalized.equals(a.getBroadcaster()))
|
||||
.filter(a -> !a.isHidden())
|
||||
.flatMap(assetStorageService::loadAssetFileSafely);
|
||||
}
|
||||
|
||||
public Optional<AssetContent> getVisibleAssetContent(String broadcaster, String assetId) {
|
||||
String normalized = normalize(broadcaster);
|
||||
public Optional<AssetContent> getAssetPreview(String assetId, boolean includeHidden) {
|
||||
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())
|
||||
.flatMap(assetStorageService::loadPreviewSafely);
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user