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
|
||||
run:
|
||||
test -f .env && . ./.env; mvn spring-boot:run
|
||||
test -f .env && . ./.env; IMGFLOAT_UPLOAD_MAX_BYTES=16777216 mvn spring-boot:run
|
||||
|
||||
.PHONY: watch
|
||||
watch:
|
||||
|
||||
83
README.md
83
README.md
@@ -1,86 +1,3 @@
|
||||
# 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.
|
||||
|
||||
## 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 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:
|
||||
|
||||
@@ -61,7 +61,7 @@ class ChannelDirectoryServiceTest {
|
||||
MediaOptimizationService mediaOptimizationService = new MediaOptimizationService(mediaPreviewService);
|
||||
MediaDetectionService mediaDetectionService = new MediaDetectionService();
|
||||
service = new ChannelDirectoryService(channelRepository, assetRepository, messagingTemplate,
|
||||
assetStorageService, mediaDetectionService, mediaOptimizationService);
|
||||
assetStorageService, mediaDetectionService, mediaOptimizationService, 26214400L);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -11,24 +11,24 @@ class MediaDetectionServiceTest {
|
||||
private final MediaDetectionService service = new MediaDetectionService();
|
||||
|
||||
@Test
|
||||
void prefersProvidedContentType() 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 {
|
||||
void acceptsMagicBytesOverDeclaredType() throws IOException {
|
||||
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
|
||||
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});
|
||||
|
||||
assertThat(service.detectMediaType(file, file.getBytes())).isEqualTo("application/octet-stream");
|
||||
assertThat(service.detectAllowedMediaType(file, file.getBytes())).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user