Add asset restrictions

This commit is contained in:
2025-12-11 14:04:21 +01:00
parent 0f6e840807
commit 7418bca56b
9 changed files with 122 additions and 124 deletions

View File

@@ -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:

View File

@@ -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

View File

@@ -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)
);
}
}
}

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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");
} }
} }

View File

@@ -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:

View File

@@ -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

View File

@@ -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();
} }
} }