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:
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user