Add Spring Boot Twitch overlay server with CI and Docker

This commit is contained in:
2025-12-02 16:32:19 +01:00
parent dbcca9002c
commit 969e302802
30 changed files with 1463 additions and 0 deletions

19
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,19 @@
name: CI
on:
push:
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
cache: maven
- name: Build and test
run: mvn -B verify

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
/target
/.idea
/.vscode
*.iml
local/
*.log

13
Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /app
COPY pom.xml ./
RUN mvn -B dependency:go-offline
COPY src ./src
RUN mvn -B package -DskipTests
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=build /app/target/imgfloat-0.0.1-SNAPSHOT.jar app.jar
EXPOSE 8080 8443
ENV JAVA_OPTS=""
ENTRYPOINT ["sh", "-c", "java $$JAVA_OPTS -jar app.jar"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Imgfloat
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

23
Makefile Normal file
View File

@@ -0,0 +1,23 @@
APP_NAME=imgfloat
.PHONY: run test package docker-build docker-run ssl
run:
mvn spring-boot:run
test:
mvn test
package:
mvn clean package
docker-build:
docker build -t $(APP_NAME):latest .
docker-run:
docker run --rm -p 8080:8080 -e TWITCH_CLIENT_ID=$${TWITCH_CLIENT_ID} -e TWITCH_CLIENT_SECRET=$${TWITCH_CLIENT_SECRET} $(APP_NAME):latest
ssl:
mkdir -p local
keytool -genkeypair -alias $(APP_NAME) -keyalg RSA -keystore local/keystore.p12 -storetype PKCS12 -storepass changeit -keypass changeit -dname "CN=localhost" -validity 365
echo "Use SSL_ENABLED=true SSL_KEYSTORE_PATH=file:$$PWD/local/keystore.p12"

View File

@@ -1 +1,54 @@
# 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.
## 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 image 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.
## 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 mvn spring-boot:run
```
The default server port is `8080`. Log in via `/oauth2/authorization/twitch`.
### 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.
- `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
```
### OAuth configuration
Spring Boot reads Twitch credentials from `TWITCH_CLIENT_ID` and `TWITCH_CLIENT_SECRET`. The redirect URI is `{baseUrl}/login/oauth2/code/twitch`.
### CI
GitHub Actions runs `mvn verify` on pushes and pull requests via `.github/workflows/ci.yml`.
### License
MIT

100
pom.xml Normal file
View File

@@ -0,0 +1,100 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.imgfloat</groupId>
<artifactId>imgfloat</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>Imgfloat</name>
<description>Livestream overlay with Twitch-authenticated channel admins and broadcasters.</description>
<packaging>jar</packaging>
<properties>
<java.version>17</java.version>
<spring.boot.version>3.2.5</spring.boot.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring.boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>sockjs-client</artifactId>
<version>1.5.1</version>
</dependency>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>stomp-websocket</artifactId>
<version>2.3.4</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring.boot.version}</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
<compilerArgs>
<arg>-parameters</arg>
</compilerArgs>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,11 @@
package com.imgfloat.app;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ImgfloatApplication {
public static void main(String[] args) {
SpringApplication.run(ImgfloatApplication.class, args);
}
}

View File

@@ -0,0 +1,29 @@
package com.imgfloat.app.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/css/**", "/js/**", "/webjars/**", "/actuator/health").permitAll()
.requestMatchers("/ws/**").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(Customizer.withDefaults())
.logout(logout -> logout.logoutSuccessUrl("/").permitAll())
.csrf(csrf -> csrf.ignoringRequestMatchers("/ws/**", "/api/**"));
return http.build();
}
}

View File

@@ -0,0 +1,22 @@
package com.imgfloat.app.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws").setAllowedOriginPatterns("*").withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic");
config.setApplicationDestinationPrefixes("/app");
}
}

View File

@@ -0,0 +1,145 @@
package com.imgfloat.app.controller;
import com.imgfloat.app.model.AdminRequest;
import com.imgfloat.app.model.ImageLayer;
import com.imgfloat.app.model.ImageRequest;
import com.imgfloat.app.model.TransformRequest;
import com.imgfloat.app.model.VisibilityRequest;
import com.imgfloat.app.service.ChannelDirectoryService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.Collection;
import static org.springframework.http.HttpStatus.FORBIDDEN;
import static org.springframework.http.HttpStatus.NOT_FOUND;
@RestController
@RequestMapping("/api/channels/{broadcaster}")
public class ChannelApiController {
private final ChannelDirectoryService channelDirectoryService;
public ChannelApiController(ChannelDirectoryService channelDirectoryService) {
this.channelDirectoryService = channelDirectoryService;
}
@PostMapping("/admins")
public ResponseEntity<?> addAdmin(@PathVariable("broadcaster") String broadcaster,
@Valid @RequestBody AdminRequest request,
OAuth2AuthenticationToken authentication) {
String login = TwitchUser.from(authentication).login();
ensureBroadcaster(broadcaster, login);
boolean added = channelDirectoryService.addAdmin(broadcaster, request.getUsername());
return ResponseEntity.ok().body(added);
}
@GetMapping("/admins")
public Collection<String> listAdmins(@PathVariable("broadcaster") String broadcaster,
OAuth2AuthenticationToken authentication) {
String login = TwitchUser.from(authentication).login();
ensureBroadcaster(broadcaster, login);
return channelDirectoryService.getOrCreateChannel(broadcaster).getAdmins();
}
@DeleteMapping("/admins/{username}")
public ResponseEntity<?> removeAdmin(@PathVariable("broadcaster") String broadcaster,
@PathVariable("username") String username,
OAuth2AuthenticationToken authentication) {
String login = TwitchUser.from(authentication).login();
ensureBroadcaster(broadcaster, login);
boolean removed = channelDirectoryService.removeAdmin(broadcaster, username);
return ResponseEntity.ok().body(removed);
}
@GetMapping("/images")
public Collection<ImageLayer> listImages(@PathVariable("broadcaster") String broadcaster,
OAuth2AuthenticationToken authentication) {
String login = TwitchUser.from(authentication).login();
if (!channelDirectoryService.isBroadcaster(broadcaster, login)
&& !channelDirectoryService.isAdmin(broadcaster, login)) {
throw new ResponseStatusException(FORBIDDEN, "Not authorized");
}
return channelDirectoryService.getImagesForAdmin(broadcaster);
}
@GetMapping("/images/visible")
public Collection<ImageLayer> listVisible(@PathVariable("broadcaster") String broadcaster,
OAuth2AuthenticationToken authentication) {
String login = TwitchUser.from(authentication).login();
if (!channelDirectoryService.isBroadcaster(broadcaster, login)) {
throw new ResponseStatusException(FORBIDDEN, "Only broadcaster can load public overlay");
}
return channelDirectoryService.getVisibleImages(broadcaster);
}
@PostMapping("/images")
public ResponseEntity<ImageLayer> createImage(@PathVariable("broadcaster") String broadcaster,
@Valid @RequestBody ImageRequest request,
OAuth2AuthenticationToken authentication) {
String login = TwitchUser.from(authentication).login();
ensureAuthorized(broadcaster, login);
return channelDirectoryService.createImage(broadcaster, request)
.map(ResponseEntity::ok)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Channel not found"));
}
@PutMapping("/images/{imageId}/transform")
public ResponseEntity<ImageLayer> transform(@PathVariable("broadcaster") String broadcaster,
@PathVariable("imageId") String imageId,
@Valid @RequestBody TransformRequest request,
OAuth2AuthenticationToken authentication) {
String login = TwitchUser.from(authentication).login();
ensureAuthorized(broadcaster, login);
return channelDirectoryService.updateTransform(broadcaster, imageId, request)
.map(ResponseEntity::ok)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Image not found"));
}
@PutMapping("/images/{imageId}/visibility")
public ResponseEntity<ImageLayer> visibility(@PathVariable("broadcaster") String broadcaster,
@PathVariable("imageId") String imageId,
@RequestBody VisibilityRequest request,
OAuth2AuthenticationToken authentication) {
String login = TwitchUser.from(authentication).login();
ensureAuthorized(broadcaster, login);
return channelDirectoryService.updateVisibility(broadcaster, imageId, request)
.map(ResponseEntity::ok)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Image not found"));
}
@DeleteMapping("/images/{imageId}")
public ResponseEntity<?> delete(@PathVariable("broadcaster") String broadcaster,
@PathVariable("imageId") String imageId,
OAuth2AuthenticationToken authentication) {
String login = TwitchUser.from(authentication).login();
ensureAuthorized(broadcaster, login);
boolean removed = channelDirectoryService.deleteImage(broadcaster, imageId);
if (!removed) {
throw new ResponseStatusException(NOT_FOUND, "Image not found");
}
return ResponseEntity.ok().build();
}
private void ensureBroadcaster(String broadcaster, String login) {
if (!channelDirectoryService.isBroadcaster(broadcaster, login)) {
throw new ResponseStatusException(FORBIDDEN, "Only broadcasters can manage admins");
}
}
private void ensureAuthorized(String broadcaster, String login) {
if (!channelDirectoryService.isBroadcaster(broadcaster, login)
&& !channelDirectoryService.isAdmin(broadcaster, login)) {
throw new ResponseStatusException(FORBIDDEN, "No permission for channel");
}
}
}

View File

@@ -0,0 +1,76 @@
package com.imgfloat.app.controller;
import com.imgfloat.app.service.ChannelDirectoryService;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.server.ResponseStatusException;
import static org.springframework.http.HttpStatus.FORBIDDEN;
@Controller
public class ViewController {
private final ChannelDirectoryService channelDirectoryService;
public ViewController(ChannelDirectoryService channelDirectoryService) {
this.channelDirectoryService = channelDirectoryService;
}
@org.springframework.web.bind.annotation.GetMapping("/")
public String home(OAuth2AuthenticationToken authentication, Model model) {
if (authentication != null) {
String login = TwitchUser.from(authentication).login();
model.addAttribute("username", login);
model.addAttribute("channel", login);
return "dashboard";
}
return "index";
}
@org.springframework.web.bind.annotation.GetMapping("/view/{broadcaster}/admin")
public String adminView(@org.springframework.web.bind.annotation.PathVariable("broadcaster") String broadcaster,
OAuth2AuthenticationToken authentication,
Model model) {
String login = TwitchUser.from(authentication).login();
if (!channelDirectoryService.isBroadcaster(broadcaster, login)
&& !channelDirectoryService.isAdmin(broadcaster, login)) {
throw new ResponseStatusException(FORBIDDEN, "Not authorized for admin tools");
}
model.addAttribute("broadcaster", broadcaster.toLowerCase());
model.addAttribute("username", login);
return "admin";
}
@org.springframework.web.bind.annotation.GetMapping("/view/{broadcaster}/broadcast")
public String broadcastView(@org.springframework.web.bind.annotation.PathVariable("broadcaster") String broadcaster,
OAuth2AuthenticationToken authentication,
Model model) {
String login = TwitchUser.from(authentication).login();
if (!channelDirectoryService.isBroadcaster(broadcaster, login)) {
throw new ResponseStatusException(FORBIDDEN, "Only the broadcaster can render this view");
}
model.addAttribute("broadcaster", broadcaster.toLowerCase());
model.addAttribute("username", login);
return "broadcast";
}
}
record TwitchUser(String login, String displayName) {
static TwitchUser from(OAuth2AuthenticationToken authentication) {
if (authentication == null) {
throw new ResponseStatusException(FORBIDDEN, "Authentication required");
}
String login = authentication.getPrincipal().<String>getAttribute("preferred_username");
if (login == null) {
login = authentication.getPrincipal().<String>getAttribute("login");
}
if (login == null) {
login = authentication.getPrincipal().getName();
}
String displayName = authentication.getPrincipal().<String>getAttribute("display_name");
if (displayName == null) {
displayName = login;
}
return new TwitchUser(login, displayName);
}
}

View File

@@ -0,0 +1,16 @@
package com.imgfloat.app.model;
import jakarta.validation.constraints.NotBlank;
public class AdminRequest {
@NotBlank
private String username;
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
}

View File

@@ -0,0 +1,38 @@
package com.imgfloat.app.model;
import java.util.Collections;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
public class Channel {
private final String broadcaster;
private final Set<String> admins;
private final Map<String, ImageLayer> images;
public Channel(String broadcaster) {
this.broadcaster = broadcaster.toLowerCase();
this.admins = ConcurrentHashMap.newKeySet();
this.images = new ConcurrentHashMap<>();
}
public String getBroadcaster() {
return broadcaster;
}
public Set<String> getAdmins() {
return Collections.unmodifiableSet(admins);
}
public Map<String, ImageLayer> getImages() {
return images;
}
public boolean addAdmin(String username) {
return admins.add(username.toLowerCase());
}
public boolean removeAdmin(String username) {
return admins.remove(username.toLowerCase());
}
}

View File

@@ -0,0 +1,66 @@
package com.imgfloat.app.model;
public class ImageEvent {
public enum Type {
CREATED,
UPDATED,
VISIBILITY,
DELETED
}
private Type type;
private String channel;
private ImageLayer payload;
private String imageId;
public static ImageEvent created(String channel, ImageLayer layer) {
ImageEvent event = new ImageEvent();
event.type = Type.CREATED;
event.channel = channel;
event.payload = layer;
event.imageId = layer.getId();
return event;
}
public static ImageEvent updated(String channel, ImageLayer layer) {
ImageEvent event = new ImageEvent();
event.type = Type.UPDATED;
event.channel = channel;
event.payload = layer;
event.imageId = layer.getId();
return event;
}
public static ImageEvent visibility(String channel, ImageLayer layer) {
ImageEvent event = new ImageEvent();
event.type = Type.VISIBILITY;
event.channel = channel;
event.payload = layer;
event.imageId = layer.getId();
return event;
}
public static ImageEvent deleted(String channel, String imageId) {
ImageEvent event = new ImageEvent();
event.type = Type.DELETED;
event.channel = channel;
event.imageId = imageId;
return event;
}
public Type getType() {
return type;
}
public String getChannel() {
return channel;
}
public ImageLayer getPayload() {
return payload;
}
public String getImageId() {
return imageId;
}
}

View File

@@ -0,0 +1,92 @@
package com.imgfloat.app.model;
import java.time.Instant;
import java.util.UUID;
public class ImageLayer {
private final String id;
private String url;
private double x;
private double y;
private double width;
private double height;
private double rotation;
private boolean hidden;
private final Instant createdAt;
public ImageLayer(String url, double width, double height) {
this.id = UUID.randomUUID().toString();
this.url = url;
this.width = width;
this.height = height;
this.x = 0;
this.y = 0;
this.rotation = 0;
this.hidden = true;
this.createdAt = Instant.now();
}
public String getId() {
return id;
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public double getX() {
return x;
}
public void setX(double x) {
this.x = x;
}
public double getY() {
return y;
}
public void setY(double y) {
this.y = y;
}
public double getWidth() {
return width;
}
public void setWidth(double width) {
this.width = width;
}
public double getHeight() {
return height;
}
public void setHeight(double height) {
this.height = height;
}
public double getRotation() {
return rotation;
}
public void setRotation(double rotation) {
this.rotation = rotation;
}
public boolean isHidden() {
return hidden;
}
public void setHidden(boolean hidden) {
this.hidden = hidden;
}
public Instant getCreatedAt() {
return createdAt;
}
}

View File

@@ -0,0 +1,39 @@
package com.imgfloat.app.model;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
public class ImageRequest {
@NotBlank
private String url;
@Min(1)
private double width;
@Min(1)
private double height;
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public double getWidth() {
return width;
}
public void setWidth(double width) {
this.width = width;
}
public double getHeight() {
return height;
}
public void setHeight(double height) {
this.height = height;
}
}

View File

@@ -0,0 +1,49 @@
package com.imgfloat.app.model;
public class TransformRequest {
private double x;
private double y;
private double width;
private double height;
private double rotation;
public double getX() {
return x;
}
public void setX(double x) {
this.x = x;
}
public double getY() {
return y;
}
public void setY(double y) {
this.y = y;
}
public double getWidth() {
return width;
}
public void setWidth(double width) {
this.width = width;
}
public double getHeight() {
return height;
}
public void setHeight(double height) {
this.height = height;
}
public double getRotation() {
return rotation;
}
public void setRotation(double rotation) {
this.rotation = rotation;
}
}

View File

@@ -0,0 +1,13 @@
package com.imgfloat.app.model;
public class VisibilityRequest {
private boolean hidden;
public boolean isHidden() {
return hidden;
}
public void setHidden(boolean hidden) {
this.hidden = hidden;
}
}

View File

@@ -0,0 +1,114 @@
package com.imgfloat.app.service;
import com.imgfloat.app.model.Channel;
import com.imgfloat.app.model.ImageEvent;
import com.imgfloat.app.model.ImageLayer;
import com.imgfloat.app.model.ImageRequest;
import com.imgfloat.app.model.TransformRequest;
import com.imgfloat.app.model.VisibilityRequest;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
import java.util.Collection;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class ChannelDirectoryService {
private final Map<String, Channel> channels = new ConcurrentHashMap<>();
private final SimpMessagingTemplate messagingTemplate;
public ChannelDirectoryService(SimpMessagingTemplate messagingTemplate) {
this.messagingTemplate = messagingTemplate;
}
public Channel getOrCreateChannel(String broadcaster) {
return channels.computeIfAbsent(broadcaster.toLowerCase(), Channel::new);
}
public boolean addAdmin(String broadcaster, String username) {
Channel channel = getOrCreateChannel(broadcaster);
boolean added = channel.addAdmin(username);
if (added) {
messagingTemplate.convertAndSend(topicFor(broadcaster), "Admin added: " + username);
}
return added;
}
public boolean removeAdmin(String broadcaster, String username) {
Channel channel = getOrCreateChannel(broadcaster);
boolean removed = channel.removeAdmin(username);
if (removed) {
messagingTemplate.convertAndSend(topicFor(broadcaster), "Admin removed: " + username);
}
return removed;
}
public Collection<ImageLayer> getImagesForAdmin(String broadcaster) {
return getOrCreateChannel(broadcaster).getImages().values();
}
public Collection<ImageLayer> getVisibleImages(String broadcaster) {
return getOrCreateChannel(broadcaster).getImages().values().stream()
.filter(image -> !image.isHidden())
.toList();
}
public Optional<ImageLayer> createImage(String broadcaster, ImageRequest request) {
Channel channel = getOrCreateChannel(broadcaster);
ImageLayer layer = new ImageLayer(request.getUrl(), request.getWidth(), request.getHeight());
channel.getImages().put(layer.getId(), layer);
messagingTemplate.convertAndSend(topicFor(broadcaster), ImageEvent.created(broadcaster, layer));
return Optional.of(layer);
}
public Optional<ImageLayer> updateTransform(String broadcaster, String imageId, TransformRequest request) {
Channel channel = getOrCreateChannel(broadcaster);
ImageLayer layer = channel.getImages().get(imageId);
if (layer == null) {
return Optional.empty();
}
layer.setX(request.getX());
layer.setY(request.getY());
layer.setWidth(request.getWidth());
layer.setHeight(request.getHeight());
layer.setRotation(request.getRotation());
messagingTemplate.convertAndSend(topicFor(broadcaster), ImageEvent.updated(broadcaster, layer));
return Optional.of(layer);
}
public Optional<ImageLayer> updateVisibility(String broadcaster, String imageId, VisibilityRequest request) {
Channel channel = getOrCreateChannel(broadcaster);
ImageLayer layer = channel.getImages().get(imageId);
if (layer == null) {
return Optional.empty();
}
layer.setHidden(request.isHidden());
messagingTemplate.convertAndSend(topicFor(broadcaster), ImageEvent.visibility(broadcaster, layer));
return Optional.of(layer);
}
public boolean deleteImage(String broadcaster, String imageId) {
Channel channel = getOrCreateChannel(broadcaster);
ImageLayer removed = channel.getImages().remove(imageId);
if (removed != null) {
messagingTemplate.convertAndSend(topicFor(broadcaster), ImageEvent.deleted(broadcaster, imageId));
return true;
}
return false;
}
public boolean isBroadcaster(String broadcaster, String username) {
return broadcaster != null && broadcaster.equalsIgnoreCase(username);
}
public boolean isAdmin(String broadcaster, String username) {
Channel channel = channels.get(broadcaster.toLowerCase());
return channel != null && channel.getAdmins().contains(username.toLowerCase());
}
private String topicFor(String broadcaster) {
return "/topic/channel/" + broadcaster.toLowerCase();
}
}

View File

@@ -0,0 +1,35 @@
server:
port: ${SERVER_PORT:8080}
ssl:
enabled: ${SSL_ENABLED:false}
key-store: ${SSL_KEYSTORE_PATH:classpath:keystore.p12}
key-store-password: ${SSL_KEYSTORE_PASSWORD:changeit}
key-store-type: ${SSL_KEYSTORE_TYPE:PKCS12}
spring:
application:
name: imgfloat
thymeleaf:
cache: false
security:
oauth2:
client:
registration:
twitch:
client-id: ${TWITCH_CLIENT_ID:changeme}
client-secret: ${TWITCH_CLIENT_SECRET:changeme}
redirect-uri: "{baseUrl}/login/oauth2/code/twitch"
authorization-grant-type: authorization_code
scope: ["user:read:email"]
provider:
twitch:
authorization-uri: https://id.twitch.tv/oauth2/authorize
token-uri: https://id.twitch.tv/oauth2/token
user-info-uri: https://api.twitch.tv/helix/users
user-name-attribute: preferred_username
management:
endpoints:
web:
exposure:
include: health,info

View File

@@ -0,0 +1,84 @@
body {
font-family: Arial, sans-serif;
background: #0f172a;
color: #e2e8f0;
margin: 0;
padding: 0;
}
.container {
max-width: 960px;
margin: 40px auto;
background: #111827;
padding: 24px;
border-radius: 12px;
box-shadow: 0 5px 16px rgba(0,0,0,0.3);
}
.button, button {
background: #7c3aed;
color: white;
padding: 10px 16px;
border: none;
border-radius: 8px;
cursor: pointer;
text-decoration: none;
}
.secondary {
background: #475569;
}
.admin-layout header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
}
.controls {
display: flex;
gap: 24px;
padding: 16px;
background: #0b1220;
}
.controls ul {
list-style: none;
padding: 0;
margin-top: 12px;
}
.controls li {
margin: 6px 0;
}
.overlay {
position: relative;
height: 600px;
background: black;
}
.overlay iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
}
.overlay canvas, .broadcast-body canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.broadcast-body {
margin: 0;
overflow: hidden;
background: transparent;
}

View File

@@ -0,0 +1,91 @@
let stompClient;
const canvas = document.getElementById('admin-canvas');
const ctx = canvas.getContext('2d');
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
const images = new Map();
function connect() {
const socket = new SockJS('/ws');
stompClient = Stomp.over(socket);
stompClient.connect({}, () => {
stompClient.subscribe(`/topic/channel/${broadcaster}`, (payload) => {
const body = JSON.parse(payload.body);
handleEvent(body);
});
fetchImages();
fetchAdmins();
});
}
function fetchImages() {
fetch(`/api/channels/${broadcaster}/images`).then(r => r.json()).then(renderImages);
}
function fetchAdmins() {
fetch(`/api/channels/${broadcaster}/admins`).then(r => r.json()).then(list => {
const adminList = document.getElementById('admin-list');
adminList.innerHTML = '';
list.forEach(a => {
const li = document.createElement('li');
li.textContent = a;
adminList.appendChild(li);
});
}).catch(() => {});
}
function renderImages(list) {
list.forEach(img => images.set(img.id, img));
draw();
}
function handleEvent(event) {
if (event.type === 'DELETED') {
images.delete(event.imageId);
} else if (event.payload) {
images.set(event.payload.id, event.payload);
}
draw();
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
images.forEach(img => {
ctx.save();
ctx.globalAlpha = img.hidden ? 0.35 : 1;
ctx.translate(img.x, img.y);
ctx.rotate(img.rotation * Math.PI / 180);
ctx.fillStyle = 'rgba(124, 58, 237, 0.25)';
ctx.fillRect(0, 0, img.width, img.height);
ctx.restore();
});
}
function uploadImage() {
const url = document.getElementById('image-url').value;
const width = parseFloat(document.getElementById('image-width').value);
const height = parseFloat(document.getElementById('image-height').value);
fetch(`/api/channels/${broadcaster}/images`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({url, width, height})
});
}
function addAdmin() {
const usernameInput = document.getElementById('new-admin');
const username = usernameInput.value;
fetch(`/api/channels/${broadcaster}/admins`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({username})
}).then(() => fetchAdmins());
}
window.addEventListener('resize', () => {
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
draw();
});
connect();

View File

@@ -0,0 +1,57 @@
const canvas = document.getElementById('broadcast-canvas');
const ctx = canvas.getContext('2d');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const images = new Map();
function connect() {
const socket = new SockJS('/ws');
const stompClient = Stomp.over(socket);
stompClient.connect({}, () => {
stompClient.subscribe(`/topic/channel/${broadcaster}`, (payload) => {
const body = JSON.parse(payload.body);
handleEvent(body);
});
fetch(`/api/channels/${broadcaster}/images/visible`).then(r => r.json()).then(renderImages);
});
}
function renderImages(list) {
list.forEach(img => images.set(img.id, img));
draw();
}
function handleEvent(event) {
if (event.type === 'DELETED') {
images.delete(event.imageId);
} else if (event.payload && !event.payload.hidden) {
images.set(event.payload.id, event.payload);
} else if (event.payload && event.payload.hidden) {
images.delete(event.payload.id);
}
draw();
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
images.forEach(img => {
ctx.save();
ctx.globalAlpha = 1;
ctx.translate(img.x, img.y);
ctx.rotate(img.rotation * Math.PI / 180);
const image = new Image();
image.src = img.url;
image.onload = () => {
ctx.drawImage(image, 0, 0, img.width, img.height);
};
ctx.restore();
});
}
window.addEventListener('resize', () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
draw();
});
connect();

View File

@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Imgfloat Admin</title>
<link rel="stylesheet" href="/css/styles.css" />
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script>
</head>
<body>
<div class="admin-layout">
<header>
<h2>Channel <span th:text="${broadcaster}"></span> overlay controls</h2>
<div class="actions">
<form th:action="@{/logout}" method="post">
<button class="secondary" type="submit">Logout</button>
</form>
</div>
</header>
<section class="controls">
<div>
<h3>Admins</h3>
<input id="new-admin" placeholder="Twitch username" />
<button onclick="addAdmin()">Add admin</button>
<ul id="admin-list"></ul>
</div>
<div>
<h3>Images</h3>
<input id="image-url" placeholder="Image URL" />
<input id="image-width" placeholder="Width" type="number" value="600" />
<input id="image-height" placeholder="Height" type="number" value="400" />
<button onclick="uploadImage()">Upload</button>
<ul id="image-list"></ul>
</div>
</section>
<section class="overlay">
<iframe th:src="${'https://player.twitch.tv/?channel=' + broadcaster + '&parent=localhost'}" allowfullscreen></iframe>
<canvas id="admin-canvas"></canvas>
</section>
</div>
<script th:inline="javascript">
const broadcaster = /*[[${broadcaster}]]*/ '';
const username = /*[[${username}]]*/ '';
</script>
<script src="/js/admin.js"></script>
</body>
</html>

View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Imgfloat Broadcast</title>
<link rel="stylesheet" href="/css/styles.css" />
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script>
</head>
<body class="broadcast-body">
<canvas id="broadcast-canvas"></canvas>
<script th:inline="javascript">
const broadcaster = /*[[${broadcaster}]]*/ '';
</script>
<script src="/js/broadcast.js"></script>
</body>
</html>

View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Imgfloat Dashboard</title>
<link rel="stylesheet" href="/css/styles.css" />
</head>
<body>
<div class="container">
<h1>Welcome, <span th:text="${username}">user</span></h1>
<p>Manage your overlay or invite channel admins.</p>
<div class="actions">
<a class="button" th:href="@{'/view/' + ${channel} + '/admin'}">Admin console</a>
<a class="button" th:href="@{'/view/' + ${channel} + '/broadcast'}">Broadcast overlay</a>
<form th:action="@{/logout}" method="post">
<button class="secondary" type="submit">Logout</button>
</form>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Imgfloat - Twitch overlay</title>
<link rel="stylesheet" href="/css/styles.css" />
</head>
<body>
<div class="container">
<h1>Imgfloat</h1>
<p>Authenticate with Twitch to manage your channel overlays and invite channel admins.</p>
<a class="button" href="/oauth2/authorization/twitch">Login with Twitch</a>
</div>
</body>
</html>

View File

@@ -0,0 +1,86 @@
package com.imgfloat.app;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imgfloat.app.model.ImageRequest;
import com.imgfloat.app.model.VisibilityRequest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.hamcrest.Matchers.hasSize;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oauth2Login;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
class ChannelApiIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
void broadcasterManagesAdminsAndImages() throws Exception {
String broadcaster = "caster";
mockMvc.perform(post("/api/channels/{broadcaster}/admins", broadcaster)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"helper\"}")
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster))))
.andExpect(status().isOk());
ImageRequest request = new ImageRequest();
request.setUrl("https://example.com/image.png");
request.setWidth(300);
request.setHeight(200);
String body = objectMapper.writeValueAsString(request);
String imageId = objectMapper.readTree(mockMvc.perform(post("/api/channels/{broadcaster}/images", broadcaster)
.contentType(MediaType.APPLICATION_JSON)
.content(body)
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster))))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString()).get("id").asText();
mockMvc.perform(get("/api/channels/{broadcaster}/images", broadcaster)
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster))))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(1)));
VisibilityRequest visibilityRequest = new VisibilityRequest();
visibilityRequest.setHidden(false);
mockMvc.perform(put("/api/channels/{broadcaster}/images/{id}/visibility", broadcaster, imageId)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(visibilityRequest))
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.hidden").value(false));
mockMvc.perform(get("/api/channels/{broadcaster}/images/visible", broadcaster)
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster))))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(1)));
mockMvc.perform(delete("/api/channels/{broadcaster}/images/{id}", broadcaster, imageId)
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster))))
.andExpect(status().isOk());
}
@Test
void rejectsAdminChangesFromNonBroadcaster() throws Exception {
mockMvc.perform(post("/api/channels/{broadcaster}/admins", "caster")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"helper\"}")
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", "intruder"))))
.andExpect(status().isForbidden());
}
}

View File

@@ -0,0 +1,64 @@
package com.imgfloat.app;
import com.imgfloat.app.model.ImageRequest;
import com.imgfloat.app.model.TransformRequest;
import com.imgfloat.app.model.VisibilityRequest;
import com.imgfloat.app.service.ChannelDirectoryService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class ChannelDirectoryServiceTest {
private ChannelDirectoryService service;
private SimpMessagingTemplate messagingTemplate;
@BeforeEach
void setup() {
messagingTemplate = mock(SimpMessagingTemplate.class);
service = new ChannelDirectoryService(messagingTemplate);
}
@Test
void createsImagesAndBroadcastsEvents() {
ImageRequest request = new ImageRequest();
request.setUrl("https://example.com/image.png");
request.setWidth(1200);
request.setHeight(800);
Optional<?> created = service.createImage("caster", request);
assertThat(created).isPresent();
ArgumentCaptor<Object> captor = ArgumentCaptor.forClass(Object.class);
verify(messagingTemplate).convertAndSend(org.mockito.ArgumentMatchers.contains("/topic/channel/caster"), captor.capture());
}
@Test
void updatesTransformAndVisibility() {
ImageRequest request = new ImageRequest();
request.setUrl("https://example.com/image.png");
request.setWidth(400);
request.setHeight(300);
String channel = "caster";
String id = service.createImage(channel, request).orElseThrow().getId();
TransformRequest transform = new TransformRequest();
transform.setX(10);
transform.setY(20);
transform.setWidth(200);
transform.setHeight(150);
transform.setRotation(45);
assertThat(service.updateTransform(channel, id, transform)).isPresent();
VisibilityRequest visibilityRequest = new VisibilityRequest();
visibilityRequest.setHidden(false);
assertThat(service.updateVisibility(channel, id, visibilityRequest)).isPresent();
}
}