mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 11:49:25 +00:00
Add asset restrictions
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
package dev.kruhlmann.imgfloat.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Component
|
||||
public class SystemEnvironmentValidator implements ApplicationRunner {
|
||||
|
||||
@Value("${IMGFLOAT_UPLOAD_MAX_BYTES:#{null}}")
|
||||
private Long maxUploadBytes;
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
List<String> missing = new ArrayList<>();
|
||||
|
||||
if (maxUploadBytes == null)
|
||||
missing.add("IMGFLOAT_UPLOAD_MAX_BYTES");
|
||||
|
||||
if (!missing.isEmpty()) {
|
||||
throw new IllegalStateException(
|
||||
"Missing required environment variables:\n - " +
|
||||
String.join("\n - ", missing)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,9 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import jakarta.validation.Valid;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
|
||||
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
|
||||
@@ -248,6 +249,8 @@ public class ChannelApiController {
|
||||
LOG.debug("Serving asset {} for broadcaster {} to authenticated user {}", assetId, broadcaster, authentication.getName());
|
||||
return channelDirectoryService.getAssetContent(broadcaster, assetId)
|
||||
.map(content -> ResponseEntity.ok()
|
||||
.header("X-Content-Type-Options", "nosniff")
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, contentDispositionFor(content.mediaType()))
|
||||
.contentType(MediaType.parseMediaType(content.mediaType()))
|
||||
.body(content.bytes()))
|
||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found"));
|
||||
@@ -255,6 +258,8 @@ public class ChannelApiController {
|
||||
|
||||
return channelDirectoryService.getVisibleAssetContent(broadcaster, assetId)
|
||||
.map(content -> ResponseEntity.ok()
|
||||
.header("X-Content-Type-Options", "nosniff")
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, contentDispositionFor(content.mediaType()))
|
||||
.contentType(MediaType.parseMediaType(content.mediaType()))
|
||||
.body(content.bytes()))
|
||||
.orElseThrow(() -> new ResponseStatusException(FORBIDDEN, "Asset not available"));
|
||||
@@ -275,6 +280,7 @@ public class ChannelApiController {
|
||||
LOG.debug("Serving preview for asset {} for broadcaster {}", assetId, broadcaster);
|
||||
return channelDirectoryService.getAssetPreview(broadcaster, assetId, true)
|
||||
.map(content -> ResponseEntity.ok()
|
||||
.header("X-Content-Type-Options", "nosniff")
|
||||
.contentType(MediaType.parseMediaType(content.mediaType()))
|
||||
.body(content.bytes()))
|
||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Preview not found"));
|
||||
@@ -282,11 +288,19 @@ public class ChannelApiController {
|
||||
|
||||
return channelDirectoryService.getAssetPreview(broadcaster, assetId, false)
|
||||
.map(content -> ResponseEntity.ok()
|
||||
.header("X-Content-Type-Options", "nosniff")
|
||||
.contentType(MediaType.parseMediaType(content.mediaType()))
|
||||
.body(content.bytes()))
|
||||
.orElseThrow(() -> new ResponseStatusException(FORBIDDEN, "Preview not available"));
|
||||
}
|
||||
|
||||
private String contentDispositionFor(String mediaType) {
|
||||
if (mediaType != null && dev.kruhlmann.imgfloat.service.media.MediaDetectionService.isInlineDisplayType(mediaType)) {
|
||||
return "inline";
|
||||
}
|
||||
return "attachment";
|
||||
}
|
||||
|
||||
@DeleteMapping("/assets/{assetId}")
|
||||
public ResponseEntity<?> delete(@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("assetId") String assetId,
|
||||
|
||||
@@ -13,6 +13,7 @@ import dev.kruhlmann.imgfloat.repository.AssetRepository;
|
||||
import dev.kruhlmann.imgfloat.repository.ChannelRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -32,6 +33,7 @@ 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.PAYLOAD_TOO_LARGE;
|
||||
|
||||
@Service
|
||||
public class ChannelDirectoryService {
|
||||
@@ -48,19 +50,22 @@ public class ChannelDirectoryService {
|
||||
private final AssetStorageService assetStorageService;
|
||||
private final MediaDetectionService mediaDetectionService;
|
||||
private final MediaOptimizationService mediaOptimizationService;
|
||||
private final long maxUploadBytes;
|
||||
|
||||
public ChannelDirectoryService(ChannelRepository channelRepository,
|
||||
AssetRepository assetRepository,
|
||||
SimpMessagingTemplate messagingTemplate,
|
||||
AssetStorageService assetStorageService,
|
||||
MediaDetectionService mediaDetectionService,
|
||||
MediaOptimizationService mediaOptimizationService) {
|
||||
MediaOptimizationService mediaOptimizationService,
|
||||
@Value("${IMGFLOAT_UPLOAD_MAX_BYTES:26214400}") long maxUploadBytes) {
|
||||
this.channelRepository = channelRepository;
|
||||
this.assetRepository = assetRepository;
|
||||
this.messagingTemplate = messagingTemplate;
|
||||
this.assetStorageService = assetStorageService;
|
||||
this.mediaDetectionService = mediaDetectionService;
|
||||
this.mediaOptimizationService = mediaOptimizationService;
|
||||
this.maxUploadBytes = maxUploadBytes;
|
||||
}
|
||||
|
||||
public Channel getOrCreateChannel(String broadcaster) {
|
||||
@@ -123,8 +128,18 @@ public class ChannelDirectoryService {
|
||||
|
||||
public Optional<AssetView> createAsset(String broadcaster, MultipartFile file) throws IOException {
|
||||
Channel channel = getOrCreateChannel(broadcaster);
|
||||
long reportedSize = file.getSize();
|
||||
if (reportedSize > 0 && reportedSize > maxUploadBytes) {
|
||||
throw new ResponseStatusException(PAYLOAD_TOO_LARGE, "Upload exceeds limit");
|
||||
}
|
||||
|
||||
byte[] bytes = file.getBytes();
|
||||
String mediaType = mediaDetectionService.detectMediaType(file, bytes);
|
||||
if (bytes.length > maxUploadBytes) {
|
||||
throw new ResponseStatusException(PAYLOAD_TOO_LARGE, "Upload exceeds limit");
|
||||
}
|
||||
|
||||
String mediaType = mediaDetectionService.detectAllowedMediaType(file, bytes)
|
||||
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unsupported media type"));
|
||||
|
||||
OptimizedAsset optimized = mediaOptimizationService.optimizeAsset(bytes, mediaType);
|
||||
if (optimized == null) {
|
||||
|
||||
@@ -8,41 +8,66 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.URLConnection;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class MediaDetectionService {
|
||||
private static final Logger logger = LoggerFactory.getLogger(MediaDetectionService.class);
|
||||
private static final Map<String, String> EXTENSION_TYPES = Map.ofEntries(
|
||||
Map.entry("png", "image/png"),
|
||||
Map.entry("jpg", "image/jpeg"),
|
||||
Map.entry("jpeg", "image/jpeg"),
|
||||
Map.entry("gif", "image/gif"),
|
||||
Map.entry("webp", "image/webp"),
|
||||
Map.entry("mp4", "video/mp4"),
|
||||
Map.entry("webm", "video/webm"),
|
||||
Map.entry("mov", "video/quicktime"),
|
||||
Map.entry("mp3", "audio/mpeg"),
|
||||
Map.entry("wav", "audio/wav"),
|
||||
Map.entry("ogg", "audio/ogg")
|
||||
);
|
||||
private static final Set<String> ALLOWED_MEDIA_TYPES = Set.copyOf(EXTENSION_TYPES.values());
|
||||
|
||||
public String detectMediaType(MultipartFile file, byte[] bytes) {
|
||||
String contentType = Optional.ofNullable(file.getContentType()).orElse("application/octet-stream");
|
||||
if (!"application/octet-stream".equals(contentType) && !contentType.isBlank()) {
|
||||
return contentType;
|
||||
public Optional<String> detectAllowedMediaType(MultipartFile file, byte[] bytes) {
|
||||
Optional<String> detected = detectMediaType(bytes)
|
||||
.filter(MediaDetectionService::isAllowedMediaType);
|
||||
|
||||
if (detected.isPresent()) {
|
||||
return detected;
|
||||
}
|
||||
|
||||
Optional<String> declared = Optional.ofNullable(file.getContentType())
|
||||
.filter(MediaDetectionService::isAllowedMediaType);
|
||||
if (declared.isPresent()) {
|
||||
return declared;
|
||||
}
|
||||
|
||||
return Optional.ofNullable(file.getOriginalFilename())
|
||||
.map(name -> name.replaceAll("^.*\\.", "").toLowerCase())
|
||||
.map(EXTENSION_TYPES::get)
|
||||
.filter(MediaDetectionService::isAllowedMediaType);
|
||||
}
|
||||
|
||||
private Optional<String> detectMediaType(byte[] bytes) {
|
||||
try (var stream = new ByteArrayInputStream(bytes)) {
|
||||
String guessed = URLConnection.guessContentTypeFromStream(stream);
|
||||
if (guessed != null && !guessed.isBlank()) {
|
||||
return guessed;
|
||||
return Optional.of(guessed);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
logger.warn("Unable to detect content type from stream", e);
|
||||
}
|
||||
|
||||
return Optional.ofNullable(file.getOriginalFilename())
|
||||
.map(name -> name.replaceAll("^.*\\.", "").toLowerCase())
|
||||
.map(ext -> switch (ext) {
|
||||
case "png" -> "image/png";
|
||||
case "jpg", "jpeg" -> "image/jpeg";
|
||||
case "gif" -> "image/gif";
|
||||
case "mp4" -> "video/mp4";
|
||||
case "webm" -> "video/webm";
|
||||
case "mov" -> "video/quicktime";
|
||||
case "mp3" -> "audio/mpeg";
|
||||
case "wav" -> "audio/wav";
|
||||
case "ogg" -> "audio/ogg";
|
||||
default -> "application/octet-stream";
|
||||
})
|
||||
.orElse("application/octet-stream");
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
public static boolean isAllowedMediaType(String mediaType) {
|
||||
return mediaType != null && ALLOWED_MEDIA_TYPES.contains(mediaType.toLowerCase());
|
||||
}
|
||||
|
||||
public static boolean isInlineDisplayType(String mediaType) {
|
||||
return mediaType != null && (mediaType.startsWith("image/") || mediaType.startsWith("video/") || mediaType.startsWith("audio/"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,10 +16,6 @@ spring:
|
||||
enabled: true
|
||||
livereload:
|
||||
enabled: true
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 256MB
|
||||
max-request-size: 256MB
|
||||
thymeleaf:
|
||||
cache: false
|
||||
datasource:
|
||||
|
||||
Reference in New Issue
Block a user