mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Add asset restrictions
This commit is contained in:
2
Makefile
2
Makefile
@@ -11,7 +11,7 @@ build:
|
|||||||
|
|
||||||
.PHONY: run
|
.PHONY: run
|
||||||
run:
|
run:
|
||||||
test -f .env && . ./.env; mvn spring-boot:run
|
test -f .env && . ./.env; IMGFLOAT_UPLOAD_MAX_BYTES=16777216 mvn spring-boot:run
|
||||||
|
|
||||||
.PHONY: watch
|
.PHONY: watch
|
||||||
watch:
|
watch:
|
||||||
|
|||||||
83
README.md
83
README.md
@@ -1,86 +1,3 @@
|
|||||||
# Imgfloat
|
# Imgfloat
|
||||||
|
|
||||||
A Spring Boot overlay server for Twitch broadcasters and their channel admins. Broadcasters can authorize via Twitch OAuth and invite channel admins to manage images that float over a transparent canvas. Updates are pushed in real time over WebSockets so OBS browser sources stay in sync.
|
A Spring Boot overlay server for Twitch broadcasters and their channel admins. Broadcasters can authorize via Twitch OAuth and invite channel admins to manage images that float over a transparent canvas. Updates are pushed in real time over WebSockets so OBS browser sources stay in sync.
|
||||||
|
|
||||||
## Features
|
|
||||||
- Twitch OAuth (OAuth2 login) with broadcaster and channel admin access controls.
|
|
||||||
- Admin console with Twitch player embed and canvas preview.
|
|
||||||
- Broadcaster overlay view optimized for OBS browser sources.
|
|
||||||
- Real-time asset creation, movement, resize, rotation, visibility toggles, and deletion via STOMP/WebSockets.
|
|
||||||
- In-memory channel directory optimized with lock-free collections for fast updates.
|
|
||||||
- Optional SSL with local self-signed keystore support.
|
|
||||||
- Dockerfile, Makefile, CI workflow, and Maven build.
|
|
||||||
- OpenAPI/Swagger UI docs available at `/swagger-ui.html`.
|
|
||||||
|
|
||||||
## Getting started
|
|
||||||
### Prerequisites
|
|
||||||
- Java 17+
|
|
||||||
- Maven 3.9+
|
|
||||||
- Twitch Developer credentials (Client ID/Secret)
|
|
||||||
|
|
||||||
### Local run
|
|
||||||
```bash
|
|
||||||
TWITCH_CLIENT_ID=your_id TWITCH_CLIENT_SECRET=your_secret \
|
|
||||||
TWITCH_REDIRECT_URI=http://localhost:8080/login/oauth2/code/twitch mvn spring-boot:run
|
|
||||||
```
|
|
||||||
The default server port is `8080`. Log in via `/oauth2/authorization/twitch`. The redirect URI above is what Twitch should be configured to call for local development.
|
|
||||||
|
|
||||||
### Hot reload during development
|
|
||||||
- The project includes Spring Boot DevTools so Java and Thymeleaf changes trigger a restart automatically when you run `make run` (which now forks the Spring Boot process so devtools can watch the classpath).
|
|
||||||
- Static assets under `src/main/resources/static` are refreshed through the built-in LiveReload server from DevTools; install a LiveReload browser extension to automatically reload the overlay or dashboard when CSS/JS files change.
|
|
||||||
|
|
||||||
### Enable TLS locally
|
|
||||||
```bash
|
|
||||||
make ssl
|
|
||||||
SSL_ENABLED=true SSL_KEYSTORE_PATH=file:$(pwd)/local/keystore.p12 \
|
|
||||||
TWITCH_CLIENT_ID=your_id TWITCH_CLIENT_SECRET=your_secret \
|
|
||||||
mvn spring-boot:run -Dspring-boot.run.arguments="--server.port=8443"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Make targets
|
|
||||||
- `make run` – start the dev server (exports `TWITCH_REDIRECT_URI` to `http://localhost:8080/login/oauth2/code/twitch` if unset).
|
|
||||||
- `make test` – run unit/integration tests.
|
|
||||||
- `make package` – build the runnable jar.
|
|
||||||
- `make docker-build` / `make docker-run` – containerize and run the service.
|
|
||||||
- `make ssl` – create a self-signed PKCS12 keystore in `./local`.
|
|
||||||
|
|
||||||
### Docker
|
|
||||||
```bash
|
|
||||||
make docker-build
|
|
||||||
TWITCH_CLIENT_ID=your_id TWITCH_CLIENT_SECRET=your_secret docker run -p 8080:8080 imgfloat:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
### Data storage configuration
|
|
||||||
- `IMGFLOAT_DB_PATH` – filesystem location for the SQLite database (default: `imgfloat.db`).
|
|
||||||
- `IMGFLOAT_ASSETS_PATH` – root directory where uploaded assets are persisted (default: `assets`).
|
|
||||||
- `IMGFLOAT_PREVIEWS_PATH` – root directory for generated preview images (default: `previews`).
|
|
||||||
|
|
||||||
### Docker Compose example with persistent storage
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
imgfloat:
|
|
||||||
image: imgfloat:latest
|
|
||||||
environment:
|
|
||||||
TWITCH_CLIENT_ID: your_id
|
|
||||||
TWITCH_CLIENT_SECRET: your_secret
|
|
||||||
TWITCH_REDIRECT_URI: https://yourdomain/login/oauth2/code/twitch
|
|
||||||
IMGFLOAT_DB_PATH: /var/lib/imgfloat/imgfloat.db
|
|
||||||
IMGFLOAT_ASSETS_PATH: /var/lib/imgfloat/assets
|
|
||||||
IMGFLOAT_PREVIEWS_PATH: /var/lib/imgfloat/previews
|
|
||||||
ports:
|
|
||||||
- "8080:8080"
|
|
||||||
volumes:
|
|
||||||
- ./data/db:/var/lib/imgfloat
|
|
||||||
- ./data/assets:/var/lib/imgfloat/assets
|
|
||||||
- ./data/previews:/var/lib/imgfloat/previews
|
|
||||||
```
|
|
||||||
The `volumes` map local folders into the container so database and uploaded files survive container restarts.
|
|
||||||
|
|
||||||
### OAuth configuration
|
|
||||||
Spring Boot reads Twitch credentials from `TWITCH_CLIENT_ID` and `TWITCH_CLIENT_SECRET`. The redirect URI comes from `TWITCH_REDIRECT_URI` (defaulting to `{baseUrl}/login/oauth2/code/twitch`).
|
|
||||||
|
|
||||||
### CI
|
|
||||||
GitHub Actions runs `mvn verify` on pushes and pull requests via `.github/workflows/ci.yml`.
|
|
||||||
|
|
||||||
### License
|
|
||||||
MIT
|
|
||||||
|
|||||||
@@ -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 jakarta.validation.Valid;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
|
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
|
||||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
|
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
|
||||||
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
|
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());
|
LOG.debug("Serving asset {} for broadcaster {} to authenticated user {}", assetId, broadcaster, authentication.getName());
|
||||||
return channelDirectoryService.getAssetContent(broadcaster, assetId)
|
return channelDirectoryService.getAssetContent(broadcaster, assetId)
|
||||||
.map(content -> ResponseEntity.ok()
|
.map(content -> ResponseEntity.ok()
|
||||||
|
.header("X-Content-Type-Options", "nosniff")
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, contentDispositionFor(content.mediaType()))
|
||||||
.contentType(MediaType.parseMediaType(content.mediaType()))
|
.contentType(MediaType.parseMediaType(content.mediaType()))
|
||||||
.body(content.bytes()))
|
.body(content.bytes()))
|
||||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found"));
|
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found"));
|
||||||
@@ -255,6 +258,8 @@ public class ChannelApiController {
|
|||||||
|
|
||||||
return channelDirectoryService.getVisibleAssetContent(broadcaster, assetId)
|
return channelDirectoryService.getVisibleAssetContent(broadcaster, assetId)
|
||||||
.map(content -> ResponseEntity.ok()
|
.map(content -> ResponseEntity.ok()
|
||||||
|
.header("X-Content-Type-Options", "nosniff")
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, contentDispositionFor(content.mediaType()))
|
||||||
.contentType(MediaType.parseMediaType(content.mediaType()))
|
.contentType(MediaType.parseMediaType(content.mediaType()))
|
||||||
.body(content.bytes()))
|
.body(content.bytes()))
|
||||||
.orElseThrow(() -> new ResponseStatusException(FORBIDDEN, "Asset not available"));
|
.orElseThrow(() -> new ResponseStatusException(FORBIDDEN, "Asset not available"));
|
||||||
@@ -275,6 +280,7 @@ public class ChannelApiController {
|
|||||||
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(broadcaster, assetId, true)
|
||||||
.map(content -> ResponseEntity.ok()
|
.map(content -> ResponseEntity.ok()
|
||||||
|
.header("X-Content-Type-Options", "nosniff")
|
||||||
.contentType(MediaType.parseMediaType(content.mediaType()))
|
.contentType(MediaType.parseMediaType(content.mediaType()))
|
||||||
.body(content.bytes()))
|
.body(content.bytes()))
|
||||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Preview not found"));
|
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Preview not found"));
|
||||||
@@ -282,11 +288,19 @@ public class ChannelApiController {
|
|||||||
|
|
||||||
return channelDirectoryService.getAssetPreview(broadcaster, assetId, false)
|
return channelDirectoryService.getAssetPreview(broadcaster, assetId, false)
|
||||||
.map(content -> ResponseEntity.ok()
|
.map(content -> ResponseEntity.ok()
|
||||||
|
.header("X-Content-Type-Options", "nosniff")
|
||||||
.contentType(MediaType.parseMediaType(content.mediaType()))
|
.contentType(MediaType.parseMediaType(content.mediaType()))
|
||||||
.body(content.bytes()))
|
.body(content.bytes()))
|
||||||
.orElseThrow(() -> new ResponseStatusException(FORBIDDEN, "Preview not available"));
|
.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}")
|
@DeleteMapping("/assets/{assetId}")
|
||||||
public ResponseEntity<?> delete(@PathVariable("broadcaster") String broadcaster,
|
public ResponseEntity<?> delete(@PathVariable("broadcaster") String broadcaster,
|
||||||
@PathVariable("assetId") String assetId,
|
@PathVariable("assetId") String assetId,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import dev.kruhlmann.imgfloat.repository.AssetRepository;
|
|||||||
import dev.kruhlmann.imgfloat.repository.ChannelRepository;
|
import dev.kruhlmann.imgfloat.repository.ChannelRepository;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
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 dev.kruhlmann.imgfloat.service.media.OptimizedAsset;
|
||||||
|
|
||||||
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
||||||
|
import static org.springframework.http.HttpStatus.PAYLOAD_TOO_LARGE;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class ChannelDirectoryService {
|
public class ChannelDirectoryService {
|
||||||
@@ -48,19 +50,22 @@ public class ChannelDirectoryService {
|
|||||||
private final AssetStorageService assetStorageService;
|
private final AssetStorageService assetStorageService;
|
||||||
private final MediaDetectionService mediaDetectionService;
|
private final MediaDetectionService mediaDetectionService;
|
||||||
private final MediaOptimizationService mediaOptimizationService;
|
private final MediaOptimizationService mediaOptimizationService;
|
||||||
|
private final long maxUploadBytes;
|
||||||
|
|
||||||
public ChannelDirectoryService(ChannelRepository channelRepository,
|
public ChannelDirectoryService(ChannelRepository channelRepository,
|
||||||
AssetRepository assetRepository,
|
AssetRepository assetRepository,
|
||||||
SimpMessagingTemplate messagingTemplate,
|
SimpMessagingTemplate messagingTemplate,
|
||||||
AssetStorageService assetStorageService,
|
AssetStorageService assetStorageService,
|
||||||
MediaDetectionService mediaDetectionService,
|
MediaDetectionService mediaDetectionService,
|
||||||
MediaOptimizationService mediaOptimizationService) {
|
MediaOptimizationService mediaOptimizationService,
|
||||||
|
@Value("${IMGFLOAT_UPLOAD_MAX_BYTES:26214400}") long maxUploadBytes) {
|
||||||
this.channelRepository = channelRepository;
|
this.channelRepository = channelRepository;
|
||||||
this.assetRepository = assetRepository;
|
this.assetRepository = assetRepository;
|
||||||
this.messagingTemplate = messagingTemplate;
|
this.messagingTemplate = messagingTemplate;
|
||||||
this.assetStorageService = assetStorageService;
|
this.assetStorageService = assetStorageService;
|
||||||
this.mediaDetectionService = mediaDetectionService;
|
this.mediaDetectionService = mediaDetectionService;
|
||||||
this.mediaOptimizationService = mediaOptimizationService;
|
this.mediaOptimizationService = mediaOptimizationService;
|
||||||
|
this.maxUploadBytes = maxUploadBytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Channel getOrCreateChannel(String broadcaster) {
|
public Channel getOrCreateChannel(String broadcaster) {
|
||||||
@@ -123,8 +128,18 @@ public class ChannelDirectoryService {
|
|||||||
|
|
||||||
public Optional<AssetView> createAsset(String broadcaster, MultipartFile file) throws IOException {
|
public Optional<AssetView> createAsset(String broadcaster, MultipartFile file) throws IOException {
|
||||||
Channel channel = getOrCreateChannel(broadcaster);
|
Channel channel = getOrCreateChannel(broadcaster);
|
||||||
|
long reportedSize = file.getSize();
|
||||||
|
if (reportedSize > 0 && reportedSize > maxUploadBytes) {
|
||||||
|
throw new ResponseStatusException(PAYLOAD_TOO_LARGE, "Upload exceeds limit");
|
||||||
|
}
|
||||||
|
|
||||||
byte[] bytes = file.getBytes();
|
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);
|
OptimizedAsset optimized = mediaOptimizationService.optimizeAsset(bytes, mediaType);
|
||||||
if (optimized == null) {
|
if (optimized == null) {
|
||||||
|
|||||||
@@ -8,41 +8,66 @@ import org.springframework.web.multipart.MultipartFile;
|
|||||||
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayInputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.URLConnection;
|
import java.net.URLConnection;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class MediaDetectionService {
|
public class MediaDetectionService {
|
||||||
private static final Logger logger = LoggerFactory.getLogger(MediaDetectionService.class);
|
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) {
|
public Optional<String> detectAllowedMediaType(MultipartFile file, byte[] bytes) {
|
||||||
String contentType = Optional.ofNullable(file.getContentType()).orElse("application/octet-stream");
|
Optional<String> detected = detectMediaType(bytes)
|
||||||
if (!"application/octet-stream".equals(contentType) && !contentType.isBlank()) {
|
.filter(MediaDetectionService::isAllowedMediaType);
|
||||||
return contentType;
|
|
||||||
|
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)) {
|
try (var stream = new ByteArrayInputStream(bytes)) {
|
||||||
String guessed = URLConnection.guessContentTypeFromStream(stream);
|
String guessed = URLConnection.guessContentTypeFromStream(stream);
|
||||||
if (guessed != null && !guessed.isBlank()) {
|
if (guessed != null && !guessed.isBlank()) {
|
||||||
return guessed;
|
return Optional.of(guessed);
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
logger.warn("Unable to detect content type from stream", e);
|
logger.warn("Unable to detect content type from stream", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Optional.ofNullable(file.getOriginalFilename())
|
return Optional.empty();
|
||||||
.map(name -> name.replaceAll("^.*\\.", "").toLowerCase())
|
}
|
||||||
.map(ext -> switch (ext) {
|
|
||||||
case "png" -> "image/png";
|
public static boolean isAllowedMediaType(String mediaType) {
|
||||||
case "jpg", "jpeg" -> "image/jpeg";
|
return mediaType != null && ALLOWED_MEDIA_TYPES.contains(mediaType.toLowerCase());
|
||||||
case "gif" -> "image/gif";
|
}
|
||||||
case "mp4" -> "video/mp4";
|
|
||||||
case "webm" -> "video/webm";
|
public static boolean isInlineDisplayType(String mediaType) {
|
||||||
case "mov" -> "video/quicktime";
|
return mediaType != null && (mediaType.startsWith("image/") || mediaType.startsWith("video/") || mediaType.startsWith("audio/"));
|
||||||
case "mp3" -> "audio/mpeg";
|
|
||||||
case "wav" -> "audio/wav";
|
|
||||||
case "ogg" -> "audio/ogg";
|
|
||||||
default -> "application/octet-stream";
|
|
||||||
})
|
|
||||||
.orElse("application/octet-stream");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,6 @@ spring:
|
|||||||
enabled: true
|
enabled: true
|
||||||
livereload:
|
livereload:
|
||||||
enabled: true
|
enabled: true
|
||||||
servlet:
|
|
||||||
multipart:
|
|
||||||
max-file-size: 256MB
|
|
||||||
max-request-size: 256MB
|
|
||||||
thymeleaf:
|
thymeleaf:
|
||||||
cache: false
|
cache: false
|
||||||
datasource:
|
datasource:
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ class ChannelDirectoryServiceTest {
|
|||||||
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);
|
assetStorageService, mediaDetectionService, mediaOptimizationService, 26214400L);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -11,24 +11,24 @@ class MediaDetectionServiceTest {
|
|||||||
private final MediaDetectionService service = new MediaDetectionService();
|
private final MediaDetectionService service = new MediaDetectionService();
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void prefersProvidedContentType() throws IOException {
|
void acceptsMagicBytesOverDeclaredType() throws IOException {
|
||||||
MockMultipartFile file = new MockMultipartFile("file", "image.png", "image/png", new byte[]{1, 2, 3});
|
|
||||||
|
|
||||||
assertThat(service.detectMediaType(file, file.getBytes())).isEqualTo("image/png");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void fallsBackToFilenameAndStream() throws IOException {
|
|
||||||
byte[] png = new byte[]{(byte) 0x89, 0x50, 0x4E, 0x47};
|
byte[] png = new byte[]{(byte) 0x89, 0x50, 0x4E, 0x47};
|
||||||
MockMultipartFile file = new MockMultipartFile("file", "picture.png", null, png);
|
MockMultipartFile file = new MockMultipartFile("file", "image.png", "text/plain", png);
|
||||||
|
|
||||||
assertThat(service.detectMediaType(file, file.getBytes())).isEqualTo("image/png");
|
assertThat(service.detectAllowedMediaType(file, file.getBytes())).contains("image/png");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void returnsOctetStreamForUnknownType() throws IOException {
|
void fallsBackToFilenameAllowlist() throws IOException {
|
||||||
|
MockMultipartFile file = new MockMultipartFile("file", "picture.png", null, new byte[]{1, 2, 3});
|
||||||
|
|
||||||
|
assertThat(service.detectAllowedMediaType(file, file.getBytes())).contains("image/png");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsUnknownTypes() throws IOException {
|
||||||
MockMultipartFile file = new MockMultipartFile("file", "unknown.bin", null, new byte[]{1, 2, 3});
|
MockMultipartFile file = new MockMultipartFile("file", "unknown.bin", null, new byte[]{1, 2, 3});
|
||||||
|
|
||||||
assertThat(service.detectMediaType(file, file.getBytes())).isEqualTo("application/octet-stream");
|
assertThat(service.detectAllowedMediaType(file, file.getBytes())).isEmpty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user