mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Add Spring Boot Twitch overlay server with CI and Docker
This commit is contained in:
19
.github/workflows/ci.yml
vendored
Normal file
19
.github/workflows/ci.yml
vendored
Normal 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
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/target
|
||||||
|
/.idea
|
||||||
|
/.vscode
|
||||||
|
*.iml
|
||||||
|
local/
|
||||||
|
*.log
|
||||||
13
Dockerfile
Normal file
13
Dockerfile
Normal 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
21
LICENSE
Normal 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
23
Makefile
Normal 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"
|
||||||
53
README.md
53
README.md
@@ -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
100
pom.xml
Normal 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>
|
||||||
11
src/main/java/com/imgfloat/app/ImgfloatApplication.java
Normal file
11
src/main/java/com/imgfloat/app/ImgfloatApplication.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/main/java/com/imgfloat/app/config/SecurityConfig.java
Normal file
29
src/main/java/com/imgfloat/app/config/SecurityConfig.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/main/java/com/imgfloat/app/config/WebSocketConfig.java
Normal file
22
src/main/java/com/imgfloat/app/config/WebSocketConfig.java
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/main/java/com/imgfloat/app/model/AdminRequest.java
Normal file
16
src/main/java/com/imgfloat/app/model/AdminRequest.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/main/java/com/imgfloat/app/model/Channel.java
Normal file
38
src/main/java/com/imgfloat/app/model/Channel.java
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/main/java/com/imgfloat/app/model/ImageEvent.java
Normal file
66
src/main/java/com/imgfloat/app/model/ImageEvent.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
92
src/main/java/com/imgfloat/app/model/ImageLayer.java
Normal file
92
src/main/java/com/imgfloat/app/model/ImageLayer.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/main/java/com/imgfloat/app/model/ImageRequest.java
Normal file
39
src/main/java/com/imgfloat/app/model/ImageRequest.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/main/java/com/imgfloat/app/model/TransformRequest.java
Normal file
49
src/main/java/com/imgfloat/app/model/TransformRequest.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/main/java/com/imgfloat/app/model/VisibilityRequest.java
Normal file
13
src/main/java/com/imgfloat/app/model/VisibilityRequest.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/main/resources/application.yml
Normal file
35
src/main/resources/application.yml
Normal 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
|
||||||
84
src/main/resources/static/css/styles.css
Normal file
84
src/main/resources/static/css/styles.css
Normal 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;
|
||||||
|
}
|
||||||
91
src/main/resources/static/js/admin.js
Normal file
91
src/main/resources/static/js/admin.js
Normal 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();
|
||||||
57
src/main/resources/static/js/broadcast.js
Normal file
57
src/main/resources/static/js/broadcast.js
Normal 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();
|
||||||
48
src/main/resources/templates/admin.html
Normal file
48
src/main/resources/templates/admin.html
Normal 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>
|
||||||
17
src/main/resources/templates/broadcast.html
Normal file
17
src/main/resources/templates/broadcast.html
Normal 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>
|
||||||
21
src/main/resources/templates/dashboard.html
Normal file
21
src/main/resources/templates/dashboard.html
Normal 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>
|
||||||
15
src/main/resources/templates/index.html
Normal file
15
src/main/resources/templates/index.html
Normal 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>
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user