From 969e3028020d5a6676e7c98f76358dcd62ac0fb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Tue, 2 Dec 2025 16:32:19 +0100 Subject: [PATCH] Add Spring Boot Twitch overlay server with CI and Docker --- .github/workflows/ci.yml | 19 +++ .gitignore | 6 + Dockerfile | 13 ++ LICENSE | 21 +++ Makefile | 23 +++ README.md | 53 +++++++ pom.xml | 100 ++++++++++++ .../com/imgfloat/app/ImgfloatApplication.java | 11 ++ .../imgfloat/app/config/SecurityConfig.java | 29 ++++ .../imgfloat/app/config/WebSocketConfig.java | 22 +++ .../app/controller/ChannelApiController.java | 145 ++++++++++++++++++ .../app/controller/ViewController.java | 76 +++++++++ .../com/imgfloat/app/model/AdminRequest.java | 16 ++ .../java/com/imgfloat/app/model/Channel.java | 38 +++++ .../com/imgfloat/app/model/ImageEvent.java | 66 ++++++++ .../com/imgfloat/app/model/ImageLayer.java | 92 +++++++++++ .../com/imgfloat/app/model/ImageRequest.java | 39 +++++ .../imgfloat/app/model/TransformRequest.java | 49 ++++++ .../imgfloat/app/model/VisibilityRequest.java | 13 ++ .../app/service/ChannelDirectoryService.java | 114 ++++++++++++++ src/main/resources/application.yml | 35 +++++ src/main/resources/static/css/styles.css | 84 ++++++++++ src/main/resources/static/js/admin.js | 91 +++++++++++ src/main/resources/static/js/broadcast.js | 57 +++++++ src/main/resources/templates/admin.html | 48 ++++++ src/main/resources/templates/broadcast.html | 17 ++ src/main/resources/templates/dashboard.html | 21 +++ src/main/resources/templates/index.html | 15 ++ .../app/ChannelApiIntegrationTest.java | 86 +++++++++++ .../app/ChannelDirectoryServiceTest.java | 64 ++++++++ 30 files changed, 1463 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 pom.xml create mode 100644 src/main/java/com/imgfloat/app/ImgfloatApplication.java create mode 100644 src/main/java/com/imgfloat/app/config/SecurityConfig.java create mode 100644 src/main/java/com/imgfloat/app/config/WebSocketConfig.java create mode 100644 src/main/java/com/imgfloat/app/controller/ChannelApiController.java create mode 100644 src/main/java/com/imgfloat/app/controller/ViewController.java create mode 100644 src/main/java/com/imgfloat/app/model/AdminRequest.java create mode 100644 src/main/java/com/imgfloat/app/model/Channel.java create mode 100644 src/main/java/com/imgfloat/app/model/ImageEvent.java create mode 100644 src/main/java/com/imgfloat/app/model/ImageLayer.java create mode 100644 src/main/java/com/imgfloat/app/model/ImageRequest.java create mode 100644 src/main/java/com/imgfloat/app/model/TransformRequest.java create mode 100644 src/main/java/com/imgfloat/app/model/VisibilityRequest.java create mode 100644 src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java create mode 100644 src/main/resources/application.yml create mode 100644 src/main/resources/static/css/styles.css create mode 100644 src/main/resources/static/js/admin.js create mode 100644 src/main/resources/static/js/broadcast.js create mode 100644 src/main/resources/templates/admin.html create mode 100644 src/main/resources/templates/broadcast.html create mode 100644 src/main/resources/templates/dashboard.html create mode 100644 src/main/resources/templates/index.html create mode 100644 src/test/java/com/imgfloat/app/ChannelApiIntegrationTest.java create mode 100644 src/test/java/com/imgfloat/app/ChannelDirectoryServiceTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e7acc37 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..22be86b --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/target +/.idea +/.vscode +*.iml +local/ +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3e0e7f4 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8fa4be5 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..733a744 --- /dev/null +++ b/Makefile @@ -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" diff --git a/README.md b/README.md index e6de389..d6303f4 100644 --- a/README.md +++ b/README.md @@ -1 +1,54 @@ # 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 diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..fd5b22b --- /dev/null +++ b/pom.xml @@ -0,0 +1,100 @@ + + 4.0.0 + + com.imgfloat + imgfloat + 0.0.1-SNAPSHOT + Imgfloat + Livestream overlay with Twitch-authenticated channel admins and broadcasters. + jar + + + 17 + 3.2.5 + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring.boot.version} + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + org.springframework.boot + spring-boot-starter-websocket + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-oauth2-client + + + org.springframework.boot + spring-boot-starter-validation + + + + org.webjars + sockjs-client + 1.5.1 + + + org.webjars + stomp-websocket + 2.3.4 + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring.boot.version} + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + ${java.version} + ${java.version} + + -parameters + + + + + + diff --git a/src/main/java/com/imgfloat/app/ImgfloatApplication.java b/src/main/java/com/imgfloat/app/ImgfloatApplication.java new file mode 100644 index 0000000..e9f180b --- /dev/null +++ b/src/main/java/com/imgfloat/app/ImgfloatApplication.java @@ -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); + } +} diff --git a/src/main/java/com/imgfloat/app/config/SecurityConfig.java b/src/main/java/com/imgfloat/app/config/SecurityConfig.java new file mode 100644 index 0000000..f16940a --- /dev/null +++ b/src/main/java/com/imgfloat/app/config/SecurityConfig.java @@ -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(); + } +} diff --git a/src/main/java/com/imgfloat/app/config/WebSocketConfig.java b/src/main/java/com/imgfloat/app/config/WebSocketConfig.java new file mode 100644 index 0000000..f2879f3 --- /dev/null +++ b/src/main/java/com/imgfloat/app/config/WebSocketConfig.java @@ -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"); + } +} diff --git a/src/main/java/com/imgfloat/app/controller/ChannelApiController.java b/src/main/java/com/imgfloat/app/controller/ChannelApiController.java new file mode 100644 index 0000000..7e99d99 --- /dev/null +++ b/src/main/java/com/imgfloat/app/controller/ChannelApiController.java @@ -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 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 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 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 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 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 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"); + } + } +} diff --git a/src/main/java/com/imgfloat/app/controller/ViewController.java b/src/main/java/com/imgfloat/app/controller/ViewController.java new file mode 100644 index 0000000..7c8c80c --- /dev/null +++ b/src/main/java/com/imgfloat/app/controller/ViewController.java @@ -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().getAttribute("preferred_username"); + if (login == null) { + login = authentication.getPrincipal().getAttribute("login"); + } + if (login == null) { + login = authentication.getPrincipal().getName(); + } + String displayName = authentication.getPrincipal().getAttribute("display_name"); + if (displayName == null) { + displayName = login; + } + return new TwitchUser(login, displayName); + } +} diff --git a/src/main/java/com/imgfloat/app/model/AdminRequest.java b/src/main/java/com/imgfloat/app/model/AdminRequest.java new file mode 100644 index 0000000..edf2186 --- /dev/null +++ b/src/main/java/com/imgfloat/app/model/AdminRequest.java @@ -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; + } +} diff --git a/src/main/java/com/imgfloat/app/model/Channel.java b/src/main/java/com/imgfloat/app/model/Channel.java new file mode 100644 index 0000000..abf4dc3 --- /dev/null +++ b/src/main/java/com/imgfloat/app/model/Channel.java @@ -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 admins; + private final Map images; + + public Channel(String broadcaster) { + this.broadcaster = broadcaster.toLowerCase(); + this.admins = ConcurrentHashMap.newKeySet(); + this.images = new ConcurrentHashMap<>(); + } + + public String getBroadcaster() { + return broadcaster; + } + + public Set getAdmins() { + return Collections.unmodifiableSet(admins); + } + + public Map getImages() { + return images; + } + + public boolean addAdmin(String username) { + return admins.add(username.toLowerCase()); + } + + public boolean removeAdmin(String username) { + return admins.remove(username.toLowerCase()); + } +} diff --git a/src/main/java/com/imgfloat/app/model/ImageEvent.java b/src/main/java/com/imgfloat/app/model/ImageEvent.java new file mode 100644 index 0000000..97ca9f3 --- /dev/null +++ b/src/main/java/com/imgfloat/app/model/ImageEvent.java @@ -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; + } +} diff --git a/src/main/java/com/imgfloat/app/model/ImageLayer.java b/src/main/java/com/imgfloat/app/model/ImageLayer.java new file mode 100644 index 0000000..4aed97e --- /dev/null +++ b/src/main/java/com/imgfloat/app/model/ImageLayer.java @@ -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; + } +} diff --git a/src/main/java/com/imgfloat/app/model/ImageRequest.java b/src/main/java/com/imgfloat/app/model/ImageRequest.java new file mode 100644 index 0000000..17b8340 --- /dev/null +++ b/src/main/java/com/imgfloat/app/model/ImageRequest.java @@ -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; + } +} diff --git a/src/main/java/com/imgfloat/app/model/TransformRequest.java b/src/main/java/com/imgfloat/app/model/TransformRequest.java new file mode 100644 index 0000000..5e9774e --- /dev/null +++ b/src/main/java/com/imgfloat/app/model/TransformRequest.java @@ -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; + } +} diff --git a/src/main/java/com/imgfloat/app/model/VisibilityRequest.java b/src/main/java/com/imgfloat/app/model/VisibilityRequest.java new file mode 100644 index 0000000..04852a2 --- /dev/null +++ b/src/main/java/com/imgfloat/app/model/VisibilityRequest.java @@ -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; + } +} diff --git a/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java b/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java new file mode 100644 index 0000000..8f8ef48 --- /dev/null +++ b/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java @@ -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 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 getImagesForAdmin(String broadcaster) { + return getOrCreateChannel(broadcaster).getImages().values(); + } + + public Collection getVisibleImages(String broadcaster) { + return getOrCreateChannel(broadcaster).getImages().values().stream() + .filter(image -> !image.isHidden()) + .toList(); + } + + public Optional 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 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 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(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..65d22bd --- /dev/null +++ b/src/main/resources/application.yml @@ -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 diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css new file mode 100644 index 0000000..6abfa3a --- /dev/null +++ b/src/main/resources/static/css/styles.css @@ -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; +} diff --git a/src/main/resources/static/js/admin.js b/src/main/resources/static/js/admin.js new file mode 100644 index 0000000..b25f619 --- /dev/null +++ b/src/main/resources/static/js/admin.js @@ -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(); diff --git a/src/main/resources/static/js/broadcast.js b/src/main/resources/static/js/broadcast.js new file mode 100644 index 0000000..7eb35dd --- /dev/null +++ b/src/main/resources/static/js/broadcast.js @@ -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(); diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html new file mode 100644 index 0000000..5679697 --- /dev/null +++ b/src/main/resources/templates/admin.html @@ -0,0 +1,48 @@ + + + + + Imgfloat Admin + + + + + + +
+
+

Channel overlay controls

+
+
+ +
+
+
+
+
+

Admins

+ + +
    +
    +
    +

    Images

    + + + + +
      +
      +
      +
      + + +
      +
      + + + + diff --git a/src/main/resources/templates/broadcast.html b/src/main/resources/templates/broadcast.html new file mode 100644 index 0000000..18fab91 --- /dev/null +++ b/src/main/resources/templates/broadcast.html @@ -0,0 +1,17 @@ + + + + + Imgfloat Broadcast + + + + + + + + + + diff --git a/src/main/resources/templates/dashboard.html b/src/main/resources/templates/dashboard.html new file mode 100644 index 0000000..8533130 --- /dev/null +++ b/src/main/resources/templates/dashboard.html @@ -0,0 +1,21 @@ + + + + + Imgfloat Dashboard + + + +
      +

      Welcome, user

      +

      Manage your overlay or invite channel admins.

      +
      + Admin console + Broadcast overlay +
      + +
      +
      +
      + + diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html new file mode 100644 index 0000000..4d6cf74 --- /dev/null +++ b/src/main/resources/templates/index.html @@ -0,0 +1,15 @@ + + + + + Imgfloat - Twitch overlay + + + +
      +

      Imgfloat

      +

      Authenticate with Twitch to manage your channel overlays and invite channel admins.

      + Login with Twitch +
      + + diff --git a/src/test/java/com/imgfloat/app/ChannelApiIntegrationTest.java b/src/test/java/com/imgfloat/app/ChannelApiIntegrationTest.java new file mode 100644 index 0000000..e8f7804 --- /dev/null +++ b/src/test/java/com/imgfloat/app/ChannelApiIntegrationTest.java @@ -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()); + } +} diff --git a/src/test/java/com/imgfloat/app/ChannelDirectoryServiceTest.java b/src/test/java/com/imgfloat/app/ChannelDirectoryServiceTest.java new file mode 100644 index 0000000..ae5f557 --- /dev/null +++ b/src/test/java/com/imgfloat/app/ChannelDirectoryServiceTest.java @@ -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 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(); + } +}