mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Add settings
This commit is contained in:
16
Makefile
16
Makefile
@@ -6,23 +6,13 @@
|
|||||||
IMGFLOAT_DB_PATH ?= ./imgfloat.db
|
IMGFLOAT_DB_PATH ?= ./imgfloat.db
|
||||||
IMGFLOAT_ASSETS_PATH ?= ./assets
|
IMGFLOAT_ASSETS_PATH ?= ./assets
|
||||||
IMGFLOAT_PREVIEWS_PATH ?= ./previews
|
IMGFLOAT_PREVIEWS_PATH ?= ./previews
|
||||||
IMGFLOAT_MAX_SPEED ?= 4.0
|
IMGFLOAT_INITIAL_TWITCH_USERNAME_SYSADMIN ?= gasolinebased
|
||||||
IMGFLOAT_MIN_AUDIO_SPEED ?= 0.1
|
|
||||||
IMGFLOAT_MAX_AUDIO_SPEED ?= 4.0
|
|
||||||
IMGFLOAT_MIN_AUDIO_PITCH ?= 0.5
|
|
||||||
IMGFLOAT_MAX_AUDIO_PITCH ?= 2.0
|
|
||||||
IMGFLOAT_MAX_AUDIO_VOLUME ?= 2.0
|
|
||||||
SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE ?= 10MB
|
SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE ?= 10MB
|
||||||
SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE ?= 10MB
|
SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE ?= 10MB
|
||||||
RUNTIME_ENV = IMGFLOAT_ASSETS_PATH=$(IMGFLOAT_ASSETS_PATH) \
|
RUNTIME_ENV = IMGFLOAT_ASSETS_PATH=$(IMGFLOAT_ASSETS_PATH) \
|
||||||
IMGFLOAT_PREVIEWS_PATH=$(IMGFLOAT_PREVIEWS_PATH) \
|
IMGFLOAT_PREVIEWS_PATH=$(IMGFLOAT_PREVIEWS_PATH) \
|
||||||
IMGFLOAT_DB_PATH=$(IMGFLOAT_DB_PATH) \
|
IMGFLOAT_DB_PATH=$(IMGFLOAT_DB_PATH) \
|
||||||
IMGFLOAT_MAX_SPEED=$(IMGFLOAT_MAX_SPEED) \
|
IMGFLOAT_INITIAL_TWITCH_USERNAME_SYSADMIN=$(IMGFLOAT_INITIAL_TWITCH_USERNAME_SYSADMIN) \
|
||||||
IMGFLOAT_MIN_AUDIO_SPEED=$(IMGFLOAT_MIN_AUDIO_SPEED) \
|
|
||||||
IMGFLOAT_MAX_AUDIO_SPEED=$(IMGFLOAT_MAX_AUDIO_SPEED) \
|
|
||||||
IMGFLOAT_MIN_AUDIO_PITCH=$(IMGFLOAT_MIN_AUDIO_PITCH) \
|
|
||||||
IMGFLOAT_MAX_AUDIO_PITCH=$(IMGFLOAT_MAX_AUDIO_PITCH) \
|
|
||||||
IMGFLOAT_MAX_AUDIO_VOLUME=$(IMGFLOAT_MAX_AUDIO_VOLUME) \
|
|
||||||
SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE=$(SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE) \
|
SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE=$(SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE) \
|
||||||
SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE=$(SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE)
|
SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE=$(SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE)
|
||||||
WATCHDIR = ./src/main
|
WATCHDIR = ./src/main
|
||||||
@@ -37,7 +27,7 @@ run:
|
|||||||
|
|
||||||
.PHONY: watch
|
.PHONY: watch
|
||||||
watch:
|
watch:
|
||||||
mvn compile
|
-mvn compile
|
||||||
while sleep 0.1; do find $(WATCHDIR) -type f | entr -d mvn compile; done
|
while sleep 0.1; do find $(WATCHDIR) -type f | entr -d mvn compile; done
|
||||||
|
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
|
|||||||
4
pom.xml
4
pom.xml
@@ -56,6 +56,10 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-security</artifactId>
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fasterxml.jackson.core</groupId>
|
||||||
|
<artifactId>jackson-databind</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-oauth2-client</artifactId>
|
<artifactId>spring-boot-starter-oauth2-client</artifactId>
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package dev.kruhlmann.imgfloat;
|
|||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
|
||||||
|
@EnableAsync
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
public class ImgfloatApplication {
|
public class ImgfloatApplication {
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|||||||
@@ -26,20 +26,10 @@ public class SystemEnvironmentValidator {
|
|||||||
private String assetsPath;
|
private String assetsPath;
|
||||||
@Value("${IMGFLOAT_PREVIEWS_PATH:#{null}}")
|
@Value("${IMGFLOAT_PREVIEWS_PATH:#{null}}")
|
||||||
private String previewsPath;
|
private String previewsPath;
|
||||||
@Value("${IMGFLOAT_DB_PATH}")
|
@Value("${IMGFLOAT_DB_PATH:#{null}}")
|
||||||
private String dbPath;
|
private String dbPath;
|
||||||
@Value("${IMGFLOAT_MAX_SPEED}")
|
@Value("${IMGFLOAT_INITIAL_TWITCH_USERNAME_SYSADMIN:#{null}}")
|
||||||
private double maxSpeed;
|
private String initialSysadmin;
|
||||||
@Value("${IMGFLOAT_MIN_AUDIO_SPEED}")
|
|
||||||
private double minAudioSpeed;
|
|
||||||
@Value("${IMGFLOAT_MAX_AUDIO_SPEED}")
|
|
||||||
private double maxAudioSpeed;
|
|
||||||
@Value("${IMGFLOAT_MIN_AUDIO_PITCH}")
|
|
||||||
private double minAudioPitch;
|
|
||||||
@Value("${IMGFLOAT_MAX_AUDIO_PITCH}")
|
|
||||||
private double maxAudioPitch;
|
|
||||||
@Value("${IMGFLOAT_MAX_AUDIO_VOLUME}")
|
|
||||||
private double maxAudioVolume;
|
|
||||||
|
|
||||||
private long maxUploadBytes;
|
private long maxUploadBytes;
|
||||||
private long maxRequestBytes;
|
private long maxRequestBytes;
|
||||||
@@ -52,13 +42,8 @@ public class SystemEnvironmentValidator {
|
|||||||
maxRequestBytes = DataSize.parse(springMaxRequestSize).toBytes();
|
maxRequestBytes = DataSize.parse(springMaxRequestSize).toBytes();
|
||||||
checkUnsignedNumeric(maxUploadBytes, "SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE", missing);
|
checkUnsignedNumeric(maxUploadBytes, "SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE", missing);
|
||||||
checkUnsignedNumeric(maxRequestBytes, "SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE", missing);
|
checkUnsignedNumeric(maxRequestBytes, "SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE", missing);
|
||||||
checkUnsignedNumeric(maxSpeed, "IMGFLOAT_MAX_SPEED", missing);;
|
|
||||||
checkUnsignedNumeric(minAudioSpeed, "IMGFLOAT_MIN_AUDIO_SPEED", missing);;
|
|
||||||
checkUnsignedNumeric(maxAudioSpeed, "IMGFLOAT_MAX_AUDIO_SPEED", missing);;
|
|
||||||
checkUnsignedNumeric(minAudioPitch, "IMGFLOAT_MIN_AUDIO_PITCH", missing);;
|
|
||||||
checkUnsignedNumeric(maxAudioPitch, "IMGFLOAT_MAX_AUDIO_PITCH", missing);;
|
|
||||||
checkUnsignedNumeric(maxAudioVolume, "IMGFLOAT_MAX_AUDIO_VOLUME", missing);;
|
|
||||||
checkString(twitchClientId, "TWITCH_CLIENT_ID", missing);
|
checkString(twitchClientId, "TWITCH_CLIENT_ID", missing);
|
||||||
|
checkString(initialSysadmin, "IMGFLOAT_INITIAL_TWITCH_USERNAME_SYSADMIN", missing);
|
||||||
checkString(dbPath, "IMGFLOAT_DB_PATH", missing);
|
checkString(dbPath, "IMGFLOAT_DB_PATH", missing);
|
||||||
checkString(twitchClientSecret, "TWITCH_CLIENT_SECRET", missing);
|
checkString(twitchClientSecret, "TWITCH_CLIENT_SECRET", missing);
|
||||||
checkString(assetsPath, "IMGFLOAT_ASSETS_PATH", missing);
|
checkString(assetsPath, "IMGFLOAT_ASSETS_PATH", missing);
|
||||||
@@ -78,15 +63,10 @@ public class SystemEnvironmentValidator {
|
|||||||
springMaxFileSize, maxUploadBytes);
|
springMaxFileSize, maxUploadBytes);
|
||||||
log.info(" - SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE: {} ({} bytes)",
|
log.info(" - SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE: {} ({} bytes)",
|
||||||
springMaxRequestSize, maxRequestBytes);
|
springMaxRequestSize, maxRequestBytes);
|
||||||
|
log.info(" - IMGFLOAT_DB_PATH: {}", dbPath);
|
||||||
|
log.info(" - IMGFLOAT_INITIAL_TWITCH_USERNAME_SYSADMIN: {}", initialSysadmin);
|
||||||
log.info(" - IMGFLOAT_ASSETS_PATH: {}", assetsPath);
|
log.info(" - IMGFLOAT_ASSETS_PATH: {}", assetsPath);
|
||||||
log.info(" - IMGFLOAT_PREVIEWS_PATH: {}", previewsPath);
|
log.info(" - IMGFLOAT_PREVIEWS_PATH: {}", previewsPath);
|
||||||
log.info(" - IMGFLOAT_DB_PATH: {}", dbPath);
|
|
||||||
log.info(" - IMGFLOAT_MAX_SPEED: {}", maxSpeed);
|
|
||||||
log.info(" - IMGFLOAT_MIN_AUDIO_SPEED: {}", minAudioSpeed);
|
|
||||||
log.info(" - IMGFLOAT_MAX_AUDIO_SPEED: {}", maxAudioSpeed);
|
|
||||||
log.info(" - IMGFLOAT_MIN_AUDIO_PITCH: {}", minAudioPitch);
|
|
||||||
log.info(" - IMGFLOAT_MAX_AUDIO_PITCH: {}", maxAudioPitch);
|
|
||||||
log.info(" - IMGFLOAT_MAX_AUDIO_VOLUME: {}", maxAudioVolume);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void checkString(String value, String name, StringBuilder missing) {
|
private void checkString(String value, String name, StringBuilder missing) {
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import dev.kruhlmann.imgfloat.model.PlaybackRequest;
|
|||||||
import dev.kruhlmann.imgfloat.model.TransformRequest;
|
import dev.kruhlmann.imgfloat.model.TransformRequest;
|
||||||
import dev.kruhlmann.imgfloat.model.TwitchUserProfile;
|
import dev.kruhlmann.imgfloat.model.TwitchUserProfile;
|
||||||
import dev.kruhlmann.imgfloat.model.VisibilityRequest;
|
import dev.kruhlmann.imgfloat.model.VisibilityRequest;
|
||||||
|
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
|
||||||
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
|
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
|
||||||
import dev.kruhlmann.imgfloat.service.TwitchUserLookupService;
|
import dev.kruhlmann.imgfloat.service.TwitchUserLookupService;
|
||||||
|
import dev.kruhlmann.imgfloat.service.AuthorizationService;
|
||||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@@ -49,22 +51,27 @@ public class ChannelApiController {
|
|||||||
private final ChannelDirectoryService channelDirectoryService;
|
private final ChannelDirectoryService channelDirectoryService;
|
||||||
private final OAuth2AuthorizedClientService authorizedClientService;
|
private final OAuth2AuthorizedClientService authorizedClientService;
|
||||||
private final TwitchUserLookupService twitchUserLookupService;
|
private final TwitchUserLookupService twitchUserLookupService;
|
||||||
|
private final AuthorizationService authorizationService;
|
||||||
|
|
||||||
public ChannelApiController(ChannelDirectoryService channelDirectoryService,
|
public ChannelApiController(
|
||||||
|
ChannelDirectoryService channelDirectoryService,
|
||||||
OAuth2AuthorizedClientService authorizedClientService,
|
OAuth2AuthorizedClientService authorizedClientService,
|
||||||
TwitchUserLookupService twitchUserLookupService) {
|
TwitchUserLookupService twitchUserLookupService,
|
||||||
|
AuthorizationService authorizationService
|
||||||
|
) {
|
||||||
this.channelDirectoryService = channelDirectoryService;
|
this.channelDirectoryService = channelDirectoryService;
|
||||||
this.authorizedClientService = authorizedClientService;
|
this.authorizedClientService = authorizedClientService;
|
||||||
this.twitchUserLookupService = twitchUserLookupService;
|
this.twitchUserLookupService = twitchUserLookupService;
|
||||||
|
this.authorizationService = authorizationService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/admins")
|
@PostMapping("/admins")
|
||||||
public ResponseEntity<?> addAdmin(@PathVariable("broadcaster") String broadcaster,
|
public ResponseEntity<?> addAdmin(@PathVariable("broadcaster") String broadcaster,
|
||||||
@Valid @RequestBody AdminRequest request,
|
@Valid @RequestBody AdminRequest request,
|
||||||
OAuth2AuthenticationToken authentication) {
|
OAuth2AuthenticationToken oauthToken) {
|
||||||
String login = TwitchUser.from(authentication).login();
|
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||||
ensureBroadcaster(broadcaster, login);
|
authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername);
|
||||||
LOG.info("User {} adding admin {} to {}", login, request.getUsername(), broadcaster);
|
LOG.info("User {} adding admin {} to {}", sessionUsername, request.getUsername(), broadcaster);
|
||||||
boolean added = channelDirectoryService.addAdmin(broadcaster, request.getUsername());
|
boolean added = channelDirectoryService.addAdmin(broadcaster, request.getUsername());
|
||||||
if (!added) {
|
if (!added) {
|
||||||
LOG.info("User {} already admin for {} or could not be added", request.getUsername(), broadcaster);
|
LOG.info("User {} already admin for {} or could not be added", request.getUsername(), broadcaster);
|
||||||
@@ -74,16 +81,16 @@ public class ChannelApiController {
|
|||||||
|
|
||||||
@GetMapping("/admins")
|
@GetMapping("/admins")
|
||||||
public Collection<TwitchUserProfile> listAdmins(@PathVariable("broadcaster") String broadcaster,
|
public Collection<TwitchUserProfile> listAdmins(@PathVariable("broadcaster") String broadcaster,
|
||||||
OAuth2AuthenticationToken authentication,
|
OAuth2AuthenticationToken oauthToken,
|
||||||
@RegisteredOAuth2AuthorizedClient("twitch") OAuth2AuthorizedClient authorizedClient) {
|
@RegisteredOAuth2AuthorizedClient("twitch") OAuth2AuthorizedClient authorizedClient) {
|
||||||
String login = TwitchUser.from(authentication).login();
|
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||||
ensureBroadcaster(broadcaster, login);
|
authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername);
|
||||||
LOG.debug("Listing admins for {} by {}", broadcaster, login);
|
LOG.debug("Listing admins for {} by {}", broadcaster, sessionUsername);
|
||||||
var channel = channelDirectoryService.getOrCreateChannel(broadcaster);
|
var channel = channelDirectoryService.getOrCreateChannel(broadcaster);
|
||||||
List<String> admins = channel.getAdmins().stream()
|
List<String> admins = channel.getAdmins().stream()
|
||||||
.sorted(Comparator.naturalOrder())
|
.sorted(Comparator.naturalOrder())
|
||||||
.toList();
|
.toList();
|
||||||
authorizedClient = resolveAuthorizedClient(authentication, authorizedClient);
|
authorizedClient = resolveAuthorizedClient(oauthToken, authorizedClient);
|
||||||
String accessToken = Optional.ofNullable(authorizedClient)
|
String accessToken = Optional.ofNullable(authorizedClient)
|
||||||
.map(OAuth2AuthorizedClient::getAccessToken)
|
.map(OAuth2AuthorizedClient::getAccessToken)
|
||||||
.map(token -> token.getTokenValue())
|
.map(token -> token.getTokenValue())
|
||||||
@@ -97,16 +104,16 @@ public class ChannelApiController {
|
|||||||
|
|
||||||
@GetMapping("/admins/suggestions")
|
@GetMapping("/admins/suggestions")
|
||||||
public Collection<TwitchUserProfile> listAdminSuggestions(@PathVariable("broadcaster") String broadcaster,
|
public Collection<TwitchUserProfile> listAdminSuggestions(@PathVariable("broadcaster") String broadcaster,
|
||||||
OAuth2AuthenticationToken authentication,
|
OAuth2AuthenticationToken oauthToken,
|
||||||
@RegisteredOAuth2AuthorizedClient("twitch") OAuth2AuthorizedClient authorizedClient) {
|
@RegisteredOAuth2AuthorizedClient("twitch") OAuth2AuthorizedClient authorizedClient) {
|
||||||
String login = TwitchUser.from(authentication).login();
|
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||||
ensureBroadcaster(broadcaster, login);
|
authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername);
|
||||||
LOG.debug("Listing admin suggestions for {} by {}", broadcaster, login);
|
LOG.debug("Listing admin suggestions for {} by {}", broadcaster, sessionUsername);
|
||||||
var channel = channelDirectoryService.getOrCreateChannel(broadcaster);
|
var channel = channelDirectoryService.getOrCreateChannel(broadcaster);
|
||||||
authorizedClient = resolveAuthorizedClient(authentication, authorizedClient);
|
authorizedClient = resolveAuthorizedClient(oauthToken, authorizedClient);
|
||||||
|
|
||||||
if (authorizedClient == null) {
|
if (authorizedClient == null) {
|
||||||
LOG.warn("No authorized Twitch client found for {} while fetching admin suggestions for {}", login, broadcaster);
|
LOG.warn("No authorized Twitch client found for {} while fetching admin suggestions for {}", sessionUsername, broadcaster);
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
String accessToken = Optional.ofNullable(authorizedClient)
|
String accessToken = Optional.ofNullable(authorizedClient)
|
||||||
@@ -118,7 +125,7 @@ public class ChannelApiController {
|
|||||||
.map(registration -> registration.getClientId())
|
.map(registration -> registration.getClientId())
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
if (accessToken == null || accessToken.isBlank() || clientId == null || clientId.isBlank()) {
|
if (accessToken == null || accessToken.isBlank() || clientId == null || clientId.isBlank()) {
|
||||||
LOG.warn("Missing Twitch credentials for {} while fetching admin suggestions for {}", login, broadcaster);
|
LOG.warn("Missing Twitch credentials for {} while fetching admin suggestions for {}", sessionUsername, broadcaster);
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
return twitchUserLookupService.fetchModerators(broadcaster, channel.getAdmins(), accessToken, clientId);
|
return twitchUserLookupService.fetchModerators(broadcaster, channel.getAdmins(), accessToken, clientId);
|
||||||
@@ -127,24 +134,16 @@ public class ChannelApiController {
|
|||||||
@DeleteMapping("/admins/{username}")
|
@DeleteMapping("/admins/{username}")
|
||||||
public ResponseEntity<?> removeAdmin(@PathVariable("broadcaster") String broadcaster,
|
public ResponseEntity<?> removeAdmin(@PathVariable("broadcaster") String broadcaster,
|
||||||
@PathVariable("username") String username,
|
@PathVariable("username") String username,
|
||||||
OAuth2AuthenticationToken authentication) {
|
OAuth2AuthenticationToken oauthToken) {
|
||||||
String login = TwitchUser.from(authentication).login();
|
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||||
ensureBroadcaster(broadcaster, login);
|
authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername);
|
||||||
LOG.info("User {} removing admin {} from {}", login, username, broadcaster);
|
LOG.info("User {} removing admin {} from {}", sessionUsername, username, broadcaster);
|
||||||
boolean removed = channelDirectoryService.removeAdmin(broadcaster, username);
|
boolean removed = channelDirectoryService.removeAdmin(broadcaster, username);
|
||||||
return ResponseEntity.ok().body(removed);
|
return ResponseEntity.ok().body(removed);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/assets")
|
@GetMapping("/assets")
|
||||||
public Collection<AssetView> listAssets(@PathVariable("broadcaster") String broadcaster,
|
public Collection<AssetView> listAssets(@PathVariable("broadcaster") String broadcaster) {
|
||||||
OAuth2AuthenticationToken authentication) {
|
|
||||||
String login = TwitchUser.from(authentication).login();
|
|
||||||
if (!channelDirectoryService.isBroadcaster(broadcaster, login)
|
|
||||||
&& !channelDirectoryService.isAdmin(broadcaster, login)) {
|
|
||||||
LOG.warn("Unauthorized asset listing attempt for {} by {}", broadcaster, login);
|
|
||||||
throw new ResponseStatusException(FORBIDDEN, "Not authorized");
|
|
||||||
}
|
|
||||||
LOG.info("Listing assets for {} requested by {}", broadcaster, login);
|
|
||||||
return channelDirectoryService.getAssetsForAdmin(broadcaster);
|
return channelDirectoryService.getAssetsForAdmin(broadcaster);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,37 +154,36 @@ public class ChannelApiController {
|
|||||||
|
|
||||||
@GetMapping("/canvas")
|
@GetMapping("/canvas")
|
||||||
public CanvasSettingsRequest getCanvas(@PathVariable("broadcaster") String broadcaster) {
|
public CanvasSettingsRequest getCanvas(@PathVariable("broadcaster") String broadcaster) {
|
||||||
LOG.debug("Fetching canvas settings for {}", broadcaster);
|
|
||||||
return channelDirectoryService.getCanvasSettings(broadcaster);
|
return channelDirectoryService.getCanvasSettings(broadcaster);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/canvas")
|
@PutMapping("/canvas")
|
||||||
public CanvasSettingsRequest updateCanvas(@PathVariable("broadcaster") String broadcaster,
|
public CanvasSettingsRequest updateCanvas(@PathVariable("broadcaster") String broadcaster,
|
||||||
@Valid @RequestBody CanvasSettingsRequest request,
|
@Valid @RequestBody CanvasSettingsRequest request,
|
||||||
OAuth2AuthenticationToken authentication) {
|
OAuth2AuthenticationToken oauthToken) {
|
||||||
String login = TwitchUser.from(authentication).login();
|
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||||
ensureBroadcaster(broadcaster, login);
|
authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername);
|
||||||
LOG.info("Updating canvas for {} by {}: {}x{}", broadcaster, login, request.getWidth(), request.getHeight());
|
LOG.info("Updating canvas for {} by {}: {}x{}", broadcaster, sessionUsername, request.getWidth(), request.getHeight());
|
||||||
return channelDirectoryService.updateCanvasSettings(broadcaster, request);
|
return channelDirectoryService.updateCanvasSettings(broadcaster, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping(value = "/assets", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
@PostMapping(value = "/assets", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
public ResponseEntity<AssetView> createAsset(@PathVariable("broadcaster") String broadcaster,
|
public ResponseEntity<AssetView> createAsset(@PathVariable("broadcaster") String broadcaster,
|
||||||
@org.springframework.web.bind.annotation.RequestPart("file") MultipartFile file,
|
@org.springframework.web.bind.annotation.RequestPart("file") MultipartFile file,
|
||||||
OAuth2AuthenticationToken authentication) {
|
OAuth2AuthenticationToken oauthToken) {
|
||||||
String login = TwitchUser.from(authentication).login();
|
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||||
ensureAuthorized(broadcaster, login);
|
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername);
|
||||||
if (file == null || file.isEmpty()) {
|
if (file == null || file.isEmpty()) {
|
||||||
LOG.warn("User {} attempted to upload empty file to {}", login, broadcaster);
|
LOG.warn("User {} attempted to upload empty file to {}", sessionUsername, broadcaster);
|
||||||
throw new ResponseStatusException(BAD_REQUEST, "Asset file is required");
|
throw new ResponseStatusException(BAD_REQUEST, "Asset file is required");
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
LOG.info("User {} uploading asset {} to {}", login, file.getOriginalFilename(), broadcaster);
|
LOG.info("User {} uploading asset {} to {}", sessionUsername, file.getOriginalFilename(), broadcaster);
|
||||||
return channelDirectoryService.createAsset(broadcaster, file)
|
return channelDirectoryService.createAsset(broadcaster, file)
|
||||||
.map(ResponseEntity::ok)
|
.map(ResponseEntity::ok)
|
||||||
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to read image"));
|
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to read image"));
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
LOG.error("Failed to process asset upload for {} by {}", broadcaster, login, e);
|
LOG.error("Failed to process asset upload for {} by {}", broadcaster, sessionUsername, e);
|
||||||
throw new ResponseStatusException(BAD_REQUEST, "Failed to process image", e);
|
throw new ResponseStatusException(BAD_REQUEST, "Failed to process image", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -194,14 +192,14 @@ public class ChannelApiController {
|
|||||||
public ResponseEntity<AssetView> transform(@PathVariable("broadcaster") String broadcaster,
|
public ResponseEntity<AssetView> transform(@PathVariable("broadcaster") String broadcaster,
|
||||||
@PathVariable("assetId") String assetId,
|
@PathVariable("assetId") String assetId,
|
||||||
@Valid @RequestBody TransformRequest request,
|
@Valid @RequestBody TransformRequest request,
|
||||||
OAuth2AuthenticationToken authentication) {
|
OAuth2AuthenticationToken oauthToken) {
|
||||||
String login = TwitchUser.from(authentication).login();
|
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||||
ensureAuthorized(broadcaster, login);
|
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername);
|
||||||
LOG.debug("Applying transform to asset {} on {} by {}", assetId, broadcaster, login);
|
LOG.debug("Applying transform to asset {} on {} by {}", assetId, broadcaster, sessionUsername);
|
||||||
return channelDirectoryService.updateTransform(broadcaster, assetId, request)
|
return channelDirectoryService.updateTransform(broadcaster, assetId, request)
|
||||||
.map(ResponseEntity::ok)
|
.map(ResponseEntity::ok)
|
||||||
.orElseThrow(() -> {
|
.orElseThrow(() -> {
|
||||||
LOG.warn("Transform request for missing asset {} on {} by {}", assetId, broadcaster, login);
|
LOG.warn("Transform request for missing asset {} on {} by {}", assetId, broadcaster, sessionUsername);
|
||||||
return new ResponseStatusException(NOT_FOUND, "Asset not found");
|
return new ResponseStatusException(NOT_FOUND, "Asset not found");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -210,9 +208,10 @@ public class ChannelApiController {
|
|||||||
public ResponseEntity<AssetView> play(@PathVariable("broadcaster") String broadcaster,
|
public ResponseEntity<AssetView> play(@PathVariable("broadcaster") String broadcaster,
|
||||||
@PathVariable("assetId") String assetId,
|
@PathVariable("assetId") String assetId,
|
||||||
@RequestBody(required = false) PlaybackRequest request,
|
@RequestBody(required = false) PlaybackRequest request,
|
||||||
OAuth2AuthenticationToken authentication) {
|
OAuth2AuthenticationToken oauthToken) {
|
||||||
String login = TwitchUser.from(authentication).login();
|
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||||
ensureAuthorized(broadcaster, login);
|
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername);
|
||||||
|
LOG.info("Triggering playback for asset {} on {} by {}", assetId, broadcaster, sessionUsername);
|
||||||
return channelDirectoryService.triggerPlayback(broadcaster, assetId, request)
|
return channelDirectoryService.triggerPlayback(broadcaster, assetId, request)
|
||||||
.map(ResponseEntity::ok)
|
.map(ResponseEntity::ok)
|
||||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found"));
|
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found"));
|
||||||
@@ -222,31 +221,22 @@ public class ChannelApiController {
|
|||||||
public ResponseEntity<AssetView> visibility(@PathVariable("broadcaster") String broadcaster,
|
public ResponseEntity<AssetView> visibility(@PathVariable("broadcaster") String broadcaster,
|
||||||
@PathVariable("assetId") String assetId,
|
@PathVariable("assetId") String assetId,
|
||||||
@RequestBody VisibilityRequest request,
|
@RequestBody VisibilityRequest request,
|
||||||
OAuth2AuthenticationToken authentication) {
|
OAuth2AuthenticationToken oauthToken) {
|
||||||
String login = TwitchUser.from(authentication).login();
|
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||||
ensureAuthorized(broadcaster, login);
|
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername);
|
||||||
LOG.info("Updating visibility for asset {} on {} by {} to hidden={} ", assetId, broadcaster, login, request.isHidden());
|
LOG.info("Updating visibility for asset {} on {} by {} to hidden={} ", assetId, broadcaster, sessionUsername , request.isHidden());
|
||||||
return channelDirectoryService.updateVisibility(broadcaster, assetId, request)
|
return channelDirectoryService.updateVisibility(broadcaster, assetId, request)
|
||||||
.map(ResponseEntity::ok)
|
.map(ResponseEntity::ok)
|
||||||
.orElseThrow(() -> {
|
.orElseThrow(() -> {
|
||||||
LOG.warn("Visibility update for missing asset {} on {} by {}", assetId, broadcaster, login);
|
LOG.warn("Visibility update for missing asset {} on {} by {}", assetId, broadcaster, sessionUsername);
|
||||||
return new ResponseStatusException(NOT_FOUND, "Asset not found");
|
return new ResponseStatusException(NOT_FOUND, "Asset not found");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/assets/{assetId}/content")
|
@GetMapping("/assets/{assetId}/content")
|
||||||
public ResponseEntity<byte[]> getAssetContent(@PathVariable("broadcaster") String broadcaster,
|
public ResponseEntity<byte[]> getAssetContent(@PathVariable("broadcaster") String broadcaster,
|
||||||
@PathVariable("assetId") String assetId,
|
@PathVariable("assetId") String assetId) {
|
||||||
OAuth2AuthenticationToken authentication) {
|
LOG.debug("Serving asset {} for broadcaster {}", assetId, broadcaster);
|
||||||
boolean authorized = false;
|
|
||||||
if (authentication != null) {
|
|
||||||
String login = TwitchUser.from(authentication).login();
|
|
||||||
authorized = channelDirectoryService.isBroadcaster(broadcaster, login)
|
|
||||||
|| channelDirectoryService.isAdmin(broadcaster, login);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (authorized) {
|
|
||||||
LOG.debug("Serving asset {} for broadcaster {} to authenticated user {}", assetId, broadcaster, authentication.getName());
|
|
||||||
return channelDirectoryService.getAssetContent(assetId)
|
return channelDirectoryService.getAssetContent(assetId)
|
||||||
.map(content -> ResponseEntity.ok()
|
.map(content -> ResponseEntity.ok()
|
||||||
.header("X-Content-Type-Options", "nosniff")
|
.header("X-Content-Type-Options", "nosniff")
|
||||||
@@ -256,27 +246,9 @@ public class ChannelApiController {
|
|||||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found"));
|
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return channelDirectoryService.getVisibleAssetContent(assetId)
|
|
||||||
.map(content -> ResponseEntity.ok()
|
|
||||||
.header("X-Content-Type-Options", "nosniff")
|
|
||||||
.header(HttpHeaders.CONTENT_DISPOSITION, contentDispositionFor(content.mediaType()))
|
|
||||||
.contentType(MediaType.parseMediaType(content.mediaType()))
|
|
||||||
.body(content.bytes()))
|
|
||||||
.orElseThrow(() -> new ResponseStatusException(FORBIDDEN, "Asset not available"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/assets/{assetId}/preview")
|
@GetMapping("/assets/{assetId}/preview")
|
||||||
public ResponseEntity<byte[]> getAssetPreview(@PathVariable("broadcaster") String broadcaster,
|
public ResponseEntity<byte[]> getAssetPreview(@PathVariable("broadcaster") String broadcaster,
|
||||||
@PathVariable("assetId") String assetId,
|
@PathVariable("assetId") String assetId) {
|
||||||
OAuth2AuthenticationToken authentication) {
|
|
||||||
boolean authorized = false;
|
|
||||||
if (authentication != null) {
|
|
||||||
String login = TwitchUser.from(authentication).login();
|
|
||||||
authorized = channelDirectoryService.isBroadcaster(broadcaster, login)
|
|
||||||
|| channelDirectoryService.isAdmin(broadcaster, login);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (authorized) {
|
|
||||||
LOG.debug("Serving preview for asset {} for broadcaster {}", assetId, broadcaster);
|
LOG.debug("Serving preview for asset {} for broadcaster {}", assetId, broadcaster);
|
||||||
return channelDirectoryService.getAssetPreview(assetId, true)
|
return channelDirectoryService.getAssetPreview(assetId, true)
|
||||||
.map(content -> ResponseEntity.ok()
|
.map(content -> ResponseEntity.ok()
|
||||||
@@ -286,14 +258,6 @@ public class ChannelApiController {
|
|||||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Preview not found"));
|
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Preview not found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return channelDirectoryService.getAssetPreview(assetId, false)
|
|
||||||
.map(content -> ResponseEntity.ok()
|
|
||||||
.header("X-Content-Type-Options", "nosniff")
|
|
||||||
.contentType(MediaType.parseMediaType(content.mediaType()))
|
|
||||||
.body(content.bytes()))
|
|
||||||
.orElseThrow(() -> new ResponseStatusException(FORBIDDEN, "Preview not available"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private String contentDispositionFor(String mediaType) {
|
private String contentDispositionFor(String mediaType) {
|
||||||
if (mediaType != null && dev.kruhlmann.imgfloat.service.media.MediaDetectionService.isInlineDisplayType(mediaType)) {
|
if (mediaType != null && dev.kruhlmann.imgfloat.service.media.MediaDetectionService.isInlineDisplayType(mediaType)) {
|
||||||
return "inline";
|
return "inline";
|
||||||
@@ -304,43 +268,26 @@ public class ChannelApiController {
|
|||||||
@DeleteMapping("/assets/{assetId}")
|
@DeleteMapping("/assets/{assetId}")
|
||||||
public ResponseEntity<?> delete(@PathVariable("broadcaster") String broadcaster,
|
public ResponseEntity<?> delete(@PathVariable("broadcaster") String broadcaster,
|
||||||
@PathVariable("assetId") String assetId,
|
@PathVariable("assetId") String assetId,
|
||||||
OAuth2AuthenticationToken authentication) {
|
OAuth2AuthenticationToken oauthToken) {
|
||||||
String login = TwitchUser.from(authentication).login();
|
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||||
ensureAuthorized(broadcaster, login);
|
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername);
|
||||||
boolean removed = channelDirectoryService.deleteAsset(assetId);
|
boolean removed = channelDirectoryService.deleteAsset(assetId);
|
||||||
if (!removed) {
|
if (!removed) {
|
||||||
LOG.warn("Attempt to delete missing asset {} on {} by {}", assetId, broadcaster, login);
|
LOG.warn("Attempt to delete missing asset {} on {} by {}", assetId, broadcaster, sessionUsername);
|
||||||
throw new ResponseStatusException(NOT_FOUND, "Asset not found");
|
throw new ResponseStatusException(NOT_FOUND, "Asset not found");
|
||||||
}
|
}
|
||||||
LOG.info("Asset {} deleted on {} by {}", assetId, broadcaster, login);
|
LOG.info("Asset {} deleted on {} by {}", assetId, broadcaster, sessionUsername);
|
||||||
return ResponseEntity.ok().build();
|
return ResponseEntity.ok().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ensureBroadcaster(String broadcaster, String login) {
|
private OAuth2AuthorizedClient resolveAuthorizedClient(OAuth2AuthenticationToken oauthToken,
|
||||||
if (!channelDirectoryService.isBroadcaster(broadcaster, login)) {
|
|
||||||
LOG.warn("Access denied for broadcaster-only action on {} by {}", 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)) {
|
|
||||||
LOG.warn("Unauthorized access to channel {} by {}", broadcaster, login);
|
|
||||||
throw new ResponseStatusException(FORBIDDEN, "No permission for channel");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private OAuth2AuthorizedClient resolveAuthorizedClient(OAuth2AuthenticationToken authentication,
|
|
||||||
OAuth2AuthorizedClient authorizedClient) {
|
OAuth2AuthorizedClient authorizedClient) {
|
||||||
if (authorizedClient != null) {
|
if (authorizedClient != null) {
|
||||||
return authorizedClient;
|
return authorizedClient;
|
||||||
}
|
}
|
||||||
if (authentication == null) {
|
if (oauthToken == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return authorizedClientService.loadAuthorizedClient(
|
return authorizedClientService.loadAuthorizedClient(oauthToken.getAuthorizedClientRegistrationId(), oauthToken.getName());
|
||||||
authentication.getAuthorizedClientRegistrationId(),
|
|
||||||
authentication.getName());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package dev.kruhlmann.imgfloat.controller;
|
||||||
|
|
||||||
|
import dev.kruhlmann.imgfloat.model.Settings;
|
||||||
|
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
|
||||||
|
import dev.kruhlmann.imgfloat.service.SettingsService;
|
||||||
|
import dev.kruhlmann.imgfloat.service.AuthorizationService;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
|
||||||
|
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
|
||||||
|
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
|
||||||
|
import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient;
|
||||||
|
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 java.io.IOException;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.springframework.http.HttpStatus.FORBIDDEN;
|
||||||
|
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||||
|
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/settings")
|
||||||
|
@SecurityRequirement(name = "administrator")
|
||||||
|
public class SettingsApiController {
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(ChannelApiController.class);
|
||||||
|
|
||||||
|
private final SettingsService settingsService;
|
||||||
|
private final AuthorizationService authorizationService;
|
||||||
|
|
||||||
|
public SettingsApiController(SettingsService settingsService, AuthorizationService authorizationService) {
|
||||||
|
this.settingsService = settingsService;
|
||||||
|
this.authorizationService = authorizationService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/set")
|
||||||
|
public ResponseEntity<Settings> setSettings(@Valid @RequestBody Settings newSettings, OAuth2AuthenticationToken oauthToken) {
|
||||||
|
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||||
|
authorizationService.userIsSystemAdministratorOrThrowHttpError(sessionUsername);
|
||||||
|
|
||||||
|
Settings currentSettings = settingsService.get();
|
||||||
|
LOG.info("Sytem administrator settings change request");
|
||||||
|
settingsService.logSettings("From: ", currentSettings);
|
||||||
|
settingsService.logSettings("To: ", newSettings);
|
||||||
|
|
||||||
|
return ResponseEntity.ok().body(newSettings);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,13 @@ package dev.kruhlmann.imgfloat.controller;
|
|||||||
|
|
||||||
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
|
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
|
||||||
import dev.kruhlmann.imgfloat.service.VersionService;
|
import dev.kruhlmann.imgfloat.service.VersionService;
|
||||||
|
import dev.kruhlmann.imgfloat.service.SettingsService;
|
||||||
|
import dev.kruhlmann.imgfloat.service.AuthorizationService;
|
||||||
|
import dev.kruhlmann.imgfloat.model.Settings;
|
||||||
|
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
@@ -13,51 +20,42 @@ import org.springframework.util.unit.DataSize;
|
|||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import static org.springframework.http.HttpStatus.FORBIDDEN;
|
import static org.springframework.http.HttpStatus.FORBIDDEN;
|
||||||
|
import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
|
|
||||||
@Controller
|
@Controller
|
||||||
public class ViewController {
|
public class ViewController {
|
||||||
private static final Logger LOG = LoggerFactory.getLogger(ViewController.class);
|
private static final Logger LOG = LoggerFactory.getLogger(ViewController.class);
|
||||||
private final ChannelDirectoryService channelDirectoryService;
|
private final ChannelDirectoryService channelDirectoryService;
|
||||||
private final VersionService versionService;
|
private final VersionService versionService;
|
||||||
|
private final SettingsService settingsService;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final AuthorizationService authorizationService;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private long uploadLimitBytes;
|
private long uploadLimitBytes;
|
||||||
|
|
||||||
private double maxSpeed;
|
|
||||||
private double minAudioSpeed;
|
|
||||||
private double maxAudioSpeed;
|
|
||||||
private double minAudioPitch;
|
|
||||||
private double maxAudioPitch;
|
|
||||||
private double maxAudioVolume;
|
|
||||||
|
|
||||||
public ViewController(
|
public ViewController(
|
||||||
ChannelDirectoryService channelDirectoryService,
|
ChannelDirectoryService channelDirectoryService,
|
||||||
VersionService versionService,
|
VersionService versionService,
|
||||||
@Value("${IMGFLOAT_MAX_SPEED}") double maxSpeed,
|
SettingsService settingsService,
|
||||||
@Value("${IMGFLOAT_MIN_AUDIO_SPEED}") double minAudioSpeed,
|
ObjectMapper objectMapper,
|
||||||
@Value("${IMGFLOAT_MAX_AUDIO_SPEED}") double maxAudioSpeed,
|
AuthorizationService authorizationService
|
||||||
@Value("${IMGFLOAT_MIN_AUDIO_PITCH}") double minAudioPitch,
|
|
||||||
@Value("${IMGFLOAT_MAX_AUDIO_PITCH}") double maxAudioPitch,
|
|
||||||
@Value("${IMGFLOAT_MAX_AUDIO_VOLUME}") double maxAudioVolume
|
|
||||||
) {
|
) {
|
||||||
this.channelDirectoryService = channelDirectoryService;
|
this.channelDirectoryService = channelDirectoryService;
|
||||||
this.versionService = versionService;
|
this.versionService = versionService;
|
||||||
this.maxSpeed = maxSpeed;
|
this.settingsService = settingsService;
|
||||||
this.minAudioSpeed = minAudioSpeed;
|
this.objectMapper = objectMapper;
|
||||||
this.maxAudioSpeed = maxAudioSpeed;
|
this.authorizationService = authorizationService;
|
||||||
this.minAudioPitch = minAudioPitch;
|
|
||||||
this.maxAudioPitch = maxAudioPitch;
|
|
||||||
this.maxAudioVolume = maxAudioVolume;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@org.springframework.web.bind.annotation.GetMapping("/")
|
@org.springframework.web.bind.annotation.GetMapping("/")
|
||||||
public String home(OAuth2AuthenticationToken authentication, Model model) {
|
public String home(OAuth2AuthenticationToken oauthToken, Model model) {
|
||||||
if (authentication != null) {
|
if (oauthToken != null) {
|
||||||
String login = TwitchUser.from(authentication).login();
|
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||||
LOG.info("Rendering dashboard for {}", login);
|
LOG.info("Rendering dashboard for {}", sessionUsername);
|
||||||
model.addAttribute("username", login);
|
model.addAttribute("username", sessionUsername);
|
||||||
model.addAttribute("channel", login);
|
model.addAttribute("channel", sessionUsername);
|
||||||
model.addAttribute("adminChannels", channelDirectoryService.adminChannelsFor(login));
|
model.addAttribute("adminChannels", channelDirectoryService.adminChannelsFor(sessionUsername));
|
||||||
return "dashboard";
|
return "dashboard";
|
||||||
}
|
}
|
||||||
model.addAttribute("version", versionService.getVersion());
|
model.addAttribute("version", versionService.getVersion());
|
||||||
@@ -72,25 +70,21 @@ public class ViewController {
|
|||||||
|
|
||||||
@org.springframework.web.bind.annotation.GetMapping("/view/{broadcaster}/admin")
|
@org.springframework.web.bind.annotation.GetMapping("/view/{broadcaster}/admin")
|
||||||
public String adminView(@org.springframework.web.bind.annotation.PathVariable("broadcaster") String broadcaster,
|
public String adminView(@org.springframework.web.bind.annotation.PathVariable("broadcaster") String broadcaster,
|
||||||
OAuth2AuthenticationToken authentication,
|
OAuth2AuthenticationToken oauthToken,
|
||||||
Model model) {
|
Model model) {
|
||||||
String login = TwitchUser.from(authentication).login();
|
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||||
if (!channelDirectoryService.isBroadcaster(broadcaster, login)
|
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername);
|
||||||
&& !channelDirectoryService.isAdmin(broadcaster, login)) {
|
LOG.info("Rendering admin console for {} (requested by {})", broadcaster, sessionUsername);
|
||||||
LOG.warn("Unauthorized admin console access attempt for {} by {}", broadcaster, login);
|
Settings settings = settingsService.get();
|
||||||
throw new ResponseStatusException(FORBIDDEN, "Not authorized for admin tools");
|
|
||||||
}
|
|
||||||
LOG.info("Rendering admin console for {} (requested by {})", broadcaster, login);
|
|
||||||
model.addAttribute("broadcaster", broadcaster.toLowerCase());
|
model.addAttribute("broadcaster", broadcaster.toLowerCase());
|
||||||
model.addAttribute("username", login);
|
model.addAttribute("username", sessionUsername);
|
||||||
model.addAttribute("uploadLimitBytes", uploadLimitBytes);
|
model.addAttribute("uploadLimitBytes", uploadLimitBytes);
|
||||||
|
try {
|
||||||
model.addAttribute("maxSpeed", maxSpeed);
|
model.addAttribute("settingsJson", objectMapper.writeValueAsString(settings));
|
||||||
model.addAttribute("minAudioSpeed", minAudioSpeed);
|
} catch (JsonProcessingException e) {
|
||||||
model.addAttribute("maxAudioSpeed", maxAudioSpeed);
|
LOG.error("Failed to serialize settings for admin view", e);
|
||||||
model.addAttribute("minAudioPitch", minAudioPitch);
|
throw new ResponseStatusException(INTERNAL_SERVER_ERROR, "Failed to serialize settings");
|
||||||
model.addAttribute("maxAudioPitch", maxAudioPitch);
|
}
|
||||||
model.addAttribute("maxAudioVolume", maxAudioVolume);
|
|
||||||
|
|
||||||
return "admin";
|
return "admin";
|
||||||
}
|
}
|
||||||
@@ -103,23 +97,3 @@ public class ViewController {
|
|||||||
return "broadcast";
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package dev.kruhlmann.imgfloat.model;
|
||||||
|
|
||||||
|
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
|
||||||
|
|
||||||
|
public record OauthSessionUser(String login, String displayName) {
|
||||||
|
public static OauthSessionUser from(OAuth2AuthenticationToken authentication) {
|
||||||
|
if (authentication == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
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 OauthSessionUser(login, displayName);
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src/main/java/dev/kruhlmann/imgfloat/model/Settings.java
Normal file
121
src/main/java/dev/kruhlmann/imgfloat/model/Settings.java
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package dev.kruhlmann.imgfloat.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.PrePersist;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import jakarta.persistence.PreUpdate;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "settings")
|
||||||
|
public class Settings {
|
||||||
|
@Id
|
||||||
|
@Column(nullable = false)
|
||||||
|
private int id = 1;
|
||||||
|
@Column(nullable = false)
|
||||||
|
private double minAssetPlaybackSpeedFraction;
|
||||||
|
@Column(nullable = false)
|
||||||
|
private double maxAssetPlaybackSpeedFraction;
|
||||||
|
@Column(nullable = false)
|
||||||
|
private double minAssetAudioPitchFraction;
|
||||||
|
@Column(nullable = false)
|
||||||
|
private double maxAssetAudioPitchFraction;
|
||||||
|
@Column(nullable = false)
|
||||||
|
private double minAssetVolumeFraction;
|
||||||
|
@Column(nullable = false)
|
||||||
|
private double maxAssetVolumeFraction;
|
||||||
|
@Column(nullable = false)
|
||||||
|
private int maxCanvasSideLengthPixels;
|
||||||
|
@Column(nullable = false)
|
||||||
|
private int canvasFramesPerSecond;
|
||||||
|
|
||||||
|
protected Settings() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Settings defaults() {
|
||||||
|
Settings s = new Settings();
|
||||||
|
s.setMinAssetPlaybackSpeedFraction(0.1);
|
||||||
|
s.setMaxAssetPlaybackSpeedFraction(4.0);
|
||||||
|
s.setMinAssetAudioPitchFraction(0.1);
|
||||||
|
s.setMaxAssetAudioPitchFraction(4.0);
|
||||||
|
s.setMinAssetVolumeFraction(0.01);
|
||||||
|
s.setMaxAssetVolumeFraction(5.0);
|
||||||
|
s.setMaxCanvasSideLengthPixels(7680);
|
||||||
|
s.setCanvasFramesPerSecond(60);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(int id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getMinAssetPlaybackSpeedFraction() {
|
||||||
|
return minAssetPlaybackSpeedFraction;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMinAssetPlaybackSpeedFraction(double value) {
|
||||||
|
this.minAssetPlaybackSpeedFraction = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getMaxAssetPlaybackSpeedFraction() {
|
||||||
|
return maxAssetPlaybackSpeedFraction;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMaxAssetPlaybackSpeedFraction(double value) {
|
||||||
|
this.maxAssetPlaybackSpeedFraction = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getMinAssetAudioPitchFraction() {
|
||||||
|
return minAssetAudioPitchFraction;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMinAssetAudioPitchFraction(double value) {
|
||||||
|
this.minAssetAudioPitchFraction = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getMaxAssetAudioPitchFraction() {
|
||||||
|
return maxAssetAudioPitchFraction;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMaxAssetAudioPitchFraction(double value) {
|
||||||
|
this.maxAssetAudioPitchFraction = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getMinAssetVolumeFraction() {
|
||||||
|
return minAssetVolumeFraction;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMinAssetVolumeFraction(double value) {
|
||||||
|
this.minAssetVolumeFraction = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getMaxAssetVolumeFraction() {
|
||||||
|
return maxAssetVolumeFraction;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMaxAssetVolumeFraction(double value) {
|
||||||
|
this.maxAssetVolumeFraction = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getCanvasFramesPerSecond() {
|
||||||
|
return canvasFramesPerSecond;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getMaxCanvasSideLengthPixels() {
|
||||||
|
return maxCanvasSideLengthPixels;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMaxCanvasSideLengthPixels(int maxCanvasSideLengthPixels) {
|
||||||
|
this.maxCanvasSideLengthPixels = maxCanvasSideLengthPixels;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCanvasFramesPerSecond(int canvasFramesPerSecond) {
|
||||||
|
this.canvasFramesPerSecond = canvasFramesPerSecond;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package dev.kruhlmann.imgfloat.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.PrePersist;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import jakarta.persistence.PreUpdate;
|
||||||
|
import jakarta.persistence.UniqueConstraint;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(
|
||||||
|
name = "system_administrators",
|
||||||
|
uniqueConstraints = @UniqueConstraint(columnNames = "twitch_username")
|
||||||
|
)
|
||||||
|
public class SystemAdministrator {
|
||||||
|
@Id
|
||||||
|
private String id;
|
||||||
|
@Column(name = "twitch_username", nullable = false)
|
||||||
|
private String twitchUsername;
|
||||||
|
|
||||||
|
public SystemAdministrator() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public SystemAdministrator(String twitchUsername) {
|
||||||
|
this.twitchUsername = twitchUsername;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
@PreUpdate
|
||||||
|
public void prepare() {
|
||||||
|
if (this.id == null) {
|
||||||
|
this.id = UUID.randomUUID().toString();
|
||||||
|
}
|
||||||
|
twitchUsername = twitchUsername.toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public String getTwitchUsername() {
|
||||||
|
return twitchUsername;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTwitchUsername(String twitchUsername) {
|
||||||
|
this.twitchUsername = twitchUsername;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package dev.kruhlmann.imgfloat.repository;
|
||||||
|
|
||||||
|
import dev.kruhlmann.imgfloat.model.Settings;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface SettingsRepository extends JpaRepository<Settings, Integer> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package dev.kruhlmann.imgfloat.repository;
|
||||||
|
|
||||||
|
import dev.kruhlmann.imgfloat.model.SystemAdministrator;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface SystemAdministratorRepository extends JpaRepository<SystemAdministrator, String> {
|
||||||
|
boolean existsByTwitchUsername(String twitchUsername);
|
||||||
|
long deleteByTwitchUsername(String twitchUsername);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package dev.kruhlmann.imgfloat.service;
|
||||||
|
|
||||||
|
import dev.kruhlmann.imgfloat.model.Asset;
|
||||||
|
import dev.kruhlmann.imgfloat.repository.AssetRepository;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class AssetCleanupService {
|
||||||
|
|
||||||
|
private static final Logger logger =
|
||||||
|
LoggerFactory.getLogger(AssetCleanupService.class);
|
||||||
|
|
||||||
|
private final AssetRepository assetRepository;
|
||||||
|
private final AssetStorageService assetStorageService;
|
||||||
|
|
||||||
|
public AssetCleanupService(
|
||||||
|
AssetRepository assetRepository,
|
||||||
|
AssetStorageService assetStorageService
|
||||||
|
) {
|
||||||
|
this.assetRepository = assetRepository;
|
||||||
|
this.assetStorageService = assetStorageService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Async
|
||||||
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public void cleanup() {
|
||||||
|
logger.info("Collecting referenced assets");
|
||||||
|
|
||||||
|
Set<String> referencedIds = assetRepository.findAll()
|
||||||
|
.stream()
|
||||||
|
.map(Asset::getId)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
|
assetStorageService.deleteOrphanedAssets(referencedIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ import java.nio.file.*;
|
|||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class AssetStorageService {
|
public class AssetStorageService {
|
||||||
@@ -137,15 +139,34 @@ public class AssetStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void deletePreviewFile(String relativePath) {
|
public void deleteOrphanedAssets(Set<String> referencedAssetIds) {
|
||||||
if (relativePath == null || relativePath.isBlank()) return;
|
deleteOrphansUnder(assetRoot, referencedAssetIds);
|
||||||
|
deleteOrphansUnder(previewRoot, referencedAssetIds);
|
||||||
try {
|
|
||||||
Path file = safeJoin(previewRoot, relativePath);
|
|
||||||
Files.deleteIfExists(file);
|
|
||||||
} catch (Exception e) {
|
|
||||||
logger.warn("Failed to delete preview {}", relativePath, e);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void deleteOrphansUnder(Path root, Set<String> referencedAssetIds) {
|
||||||
|
try (var paths = Files.walk(root)) {
|
||||||
|
paths.filter(Files::isRegularFile)
|
||||||
|
.filter(p -> isOrphan(p, referencedAssetIds))
|
||||||
|
.forEach(p -> {
|
||||||
|
try {
|
||||||
|
Files.delete(p);
|
||||||
|
logger.warn("Deleted orphan file {}", p);
|
||||||
|
} catch (IOException e) {
|
||||||
|
logger.error("Failed to delete {}", p, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (IOException e) {
|
||||||
|
logger.error("Failed to walk {}", root, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isOrphan(Path file, Set<String> referencedAssetIds) {
|
||||||
|
String name = file.getFileName().toString();
|
||||||
|
int dot = name.indexOf('.');
|
||||||
|
if (dot == -1) return true;
|
||||||
|
String assetId = name.substring(0, dot);
|
||||||
|
return !referencedAssetIds.contains(assetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String sanitizeUserSegment(String value) {
|
private String sanitizeUserSegment(String value) {
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package dev.kruhlmann.imgfloat.service;
|
||||||
|
|
||||||
|
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
|
||||||
|
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
|
||||||
|
import dev.kruhlmann.imgfloat.service.SystemAdministratorService;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import static org.springframework.http.HttpStatus.UNAUTHORIZED;
|
||||||
|
import static org.springframework.http.HttpStatus.FORBIDDEN;
|
||||||
|
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class AuthorizationService {
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(AuthorizationService.class);
|
||||||
|
|
||||||
|
private final ChannelDirectoryService channelDirectoryService;
|
||||||
|
private final SystemAdministratorService systemAdministratorService;
|
||||||
|
|
||||||
|
public AuthorizationService(ChannelDirectoryService channelDirectoryService, SystemAdministratorService systemAdministratorService) {
|
||||||
|
this.channelDirectoryService = channelDirectoryService;
|
||||||
|
this.systemAdministratorService = systemAdministratorService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void userMatchesSessionUsernameOrThrowHttpError(String submittedUsername, String sessionUsername) {
|
||||||
|
if (sessionUsername == null) {
|
||||||
|
LOG.warn("Access denied for broadcaster-only action by unauthenticated user");
|
||||||
|
throw new ResponseStatusException(UNAUTHORIZED, "You must be logged in to manage your channel");
|
||||||
|
}
|
||||||
|
if (submittedUsername == null) {
|
||||||
|
LOG.warn("User match with oauth token failed: submitted username is null for user {}", sessionUsername);
|
||||||
|
throw new ResponseStatusException(NOT_FOUND, "You can only manage your own channel");
|
||||||
|
}
|
||||||
|
if (!sessionUsername.equals(submittedUsername)) {
|
||||||
|
LOG.warn("User match with oauth token failed: session user {} does not match submitted user {}", sessionUsername, submittedUsername);
|
||||||
|
throw new ResponseStatusException(FORBIDDEN, "You are not this user");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(String broadcaster, String sessionUsername) {
|
||||||
|
if (!userIsBroadcasterOrChannelAdminForBroadcaster(broadcaster, sessionUsername)) {
|
||||||
|
LOG.warn("Access denied for broadcaster/admin-only action by user {} on broadcaster {}", sessionUsername, broadcaster);
|
||||||
|
throw new ResponseStatusException(FORBIDDEN, "You do not have permission to manage this channel");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void userIsSystemAdministratorOrThrowHttpError(String sessionUsername) {
|
||||||
|
if (!userIsSystemAdministrator(sessionUsername)) {
|
||||||
|
LOG.warn("Access denied for system administrator-only action by user {}", sessionUsername);
|
||||||
|
throw new ResponseStatusException(FORBIDDEN, "You do not have permission to perform this action");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean userIsBroadcaster(String a, String b) {
|
||||||
|
if (a == null || b == null) {
|
||||||
|
LOG.warn("Broadcaster check failed: one or both usernames are null (a: {}, b: {})", a, b);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return a.equals(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean userIsChannelAdminForBroadcaster(String broadcaster, String sessionUsername) {
|
||||||
|
if (sessionUsername == null || broadcaster == null) {
|
||||||
|
LOG.warn("Channel admin check failed: broadcaster or session username is null (broadcaster: {}, sessionUsername: {})", broadcaster, sessionUsername);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return channelDirectoryService.isAdmin(broadcaster, sessionUsername);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean userIsBroadcasterOrChannelAdminForBroadcaster(String broadcaster, String sessionUser) {
|
||||||
|
return userIsBroadcaster(sessionUser, broadcaster) ||
|
||||||
|
userIsChannelAdminForBroadcaster(sessionUser, broadcaster);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean userIsSystemAdministrator(String sessionUsername) {
|
||||||
|
if (sessionUsername == null) {
|
||||||
|
LOG.warn("System administrator check failed: session username is null");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return systemAdministratorService.isSysadmin(sessionUsername);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,10 +7,12 @@ import dev.kruhlmann.imgfloat.model.Channel;
|
|||||||
import dev.kruhlmann.imgfloat.model.AssetView;
|
import dev.kruhlmann.imgfloat.model.AssetView;
|
||||||
import dev.kruhlmann.imgfloat.model.CanvasSettingsRequest;
|
import dev.kruhlmann.imgfloat.model.CanvasSettingsRequest;
|
||||||
import dev.kruhlmann.imgfloat.model.PlaybackRequest;
|
import dev.kruhlmann.imgfloat.model.PlaybackRequest;
|
||||||
|
import dev.kruhlmann.imgfloat.model.Settings;
|
||||||
import dev.kruhlmann.imgfloat.model.TransformRequest;
|
import dev.kruhlmann.imgfloat.model.TransformRequest;
|
||||||
import dev.kruhlmann.imgfloat.model.VisibilityRequest;
|
import dev.kruhlmann.imgfloat.model.VisibilityRequest;
|
||||||
import dev.kruhlmann.imgfloat.repository.AssetRepository;
|
import dev.kruhlmann.imgfloat.repository.AssetRepository;
|
||||||
import dev.kruhlmann.imgfloat.repository.ChannelRepository;
|
import dev.kruhlmann.imgfloat.repository.ChannelRepository;
|
||||||
|
import dev.kruhlmann.imgfloat.service.SettingsService;
|
||||||
import dev.kruhlmann.imgfloat.service.media.AssetContent;
|
import dev.kruhlmann.imgfloat.service.media.AssetContent;
|
||||||
import dev.kruhlmann.imgfloat.service.media.MediaDetectionService;
|
import dev.kruhlmann.imgfloat.service.media.MediaDetectionService;
|
||||||
import dev.kruhlmann.imgfloat.service.media.MediaOptimizationService;
|
import dev.kruhlmann.imgfloat.service.media.MediaOptimizationService;
|
||||||
@@ -43,17 +45,11 @@ public class ChannelDirectoryService {
|
|||||||
private final AssetStorageService assetStorageService;
|
private final AssetStorageService assetStorageService;
|
||||||
private final MediaDetectionService mediaDetectionService;
|
private final MediaDetectionService mediaDetectionService;
|
||||||
private final MediaOptimizationService mediaOptimizationService;
|
private final MediaOptimizationService mediaOptimizationService;
|
||||||
|
private final SettingsService settingsService;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private long uploadLimitBytes;
|
private long uploadLimitBytes;
|
||||||
|
|
||||||
private double maxSpeed;
|
|
||||||
private double minAudioSpeed;
|
|
||||||
private double maxAudioSpeed;
|
|
||||||
private double minAudioPitch;
|
|
||||||
private double maxAudioPitch;
|
|
||||||
private double maxAudioVolume;
|
|
||||||
|
|
||||||
public ChannelDirectoryService(
|
public ChannelDirectoryService(
|
||||||
ChannelRepository channelRepository,
|
ChannelRepository channelRepository,
|
||||||
AssetRepository assetRepository,
|
AssetRepository assetRepository,
|
||||||
@@ -61,12 +57,7 @@ public class ChannelDirectoryService {
|
|||||||
AssetStorageService assetStorageService,
|
AssetStorageService assetStorageService,
|
||||||
MediaDetectionService mediaDetectionService,
|
MediaDetectionService mediaDetectionService,
|
||||||
MediaOptimizationService mediaOptimizationService,
|
MediaOptimizationService mediaOptimizationService,
|
||||||
@Value("${IMGFLOAT_MAX_SPEED}") double maxSpeed,
|
SettingsService settingsService
|
||||||
@Value("${IMGFLOAT_MIN_AUDIO_SPEED}") double minAudioSpeed,
|
|
||||||
@Value("${IMGFLOAT_MAX_AUDIO_SPEED}") double maxAudioSpeed,
|
|
||||||
@Value("${IMGFLOAT_MIN_AUDIO_PITCH}") double minAudioPitch,
|
|
||||||
@Value("${IMGFLOAT_MAX_AUDIO_PITCH}") double maxAudioPitch,
|
|
||||||
@Value("${IMGFLOAT_MAX_AUDIO_VOLUME}") double maxAudioVolume
|
|
||||||
) {
|
) {
|
||||||
this.channelRepository = channelRepository;
|
this.channelRepository = channelRepository;
|
||||||
this.assetRepository = assetRepository;
|
this.assetRepository = assetRepository;
|
||||||
@@ -74,12 +65,7 @@ public class ChannelDirectoryService {
|
|||||||
this.assetStorageService = assetStorageService;
|
this.assetStorageService = assetStorageService;
|
||||||
this.mediaDetectionService = mediaDetectionService;
|
this.mediaDetectionService = mediaDetectionService;
|
||||||
this.mediaOptimizationService = mediaOptimizationService;
|
this.mediaOptimizationService = mediaOptimizationService;
|
||||||
this.maxSpeed = maxSpeed;
|
this.settingsService = settingsService;
|
||||||
this.minAudioSpeed = minAudioSpeed;
|
|
||||||
this.maxAudioSpeed = maxAudioSpeed;
|
|
||||||
this.minAudioPitch = minAudioPitch;
|
|
||||||
this.maxAudioPitch = maxAudioPitch;
|
|
||||||
this.maxAudioVolume = maxAudioVolume;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -255,20 +241,31 @@ public class ChannelDirectoryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void validateTransform(TransformRequest req) {
|
private void validateTransform(TransformRequest req) {
|
||||||
if (req.getWidth() <= 0) throw new ResponseStatusException(BAD_REQUEST, "Width must be > 0");
|
Settings settings = settingsService.get();
|
||||||
if (req.getHeight() <= 0) throw new ResponseStatusException(BAD_REQUEST, "Height must be > 0");
|
double maxSpeed = settings.getMaxAssetPlaybackSpeedFraction();
|
||||||
if (req.getSpeed() != null && (req.getSpeed() < 0 || req.getSpeed() > maxSpeed))
|
double minSpeed = settings.getMinAssetPlaybackSpeedFraction();
|
||||||
throw new ResponseStatusException(BAD_REQUEST, "Speed must be between 0 and " + maxSpeed);
|
double minPitch = settings.getMinAssetAudioPitchFraction();
|
||||||
|
double maxPitch = settings.getMaxAssetAudioPitchFraction();
|
||||||
|
double minVolume = settings.getMinAssetVolumeFraction();
|
||||||
|
double maxVolume = settings.getMaxAssetVolumeFraction();
|
||||||
|
int canvasMaxSizePixels = settings.getMaxCanvasSideLengthPixels();
|
||||||
|
|
||||||
|
if (req.getWidth() <= 0 || req.getWidth() > canvasMaxSizePixels)
|
||||||
|
throw new ResponseStatusException(BAD_REQUEST, "Canvas width out of range [0 to " + canvasMaxSizePixels + "]");
|
||||||
|
if (req.getHeight() <= 0)
|
||||||
|
throw new ResponseStatusException(BAD_REQUEST, "Canvas height out of range [0 to " + canvasMaxSizePixels + "]");
|
||||||
|
if (req.getSpeed() != null && (req.getSpeed() < minSpeed || req.getSpeed() > maxSpeed))
|
||||||
|
throw new ResponseStatusException(BAD_REQUEST, "Speed out of range [" + minSpeed + " to " + maxSpeed + "]");
|
||||||
if (req.getZIndex() != null && req.getZIndex() < 1)
|
if (req.getZIndex() != null && req.getZIndex() < 1)
|
||||||
throw new ResponseStatusException(BAD_REQUEST, "zIndex must be >= 1");
|
throw new ResponseStatusException(BAD_REQUEST, "zIndex must be >= 1");
|
||||||
if (req.getAudioDelayMillis() != null && req.getAudioDelayMillis() < 0)
|
if (req.getAudioDelayMillis() != null && req.getAudioDelayMillis() < 0)
|
||||||
throw new ResponseStatusException(BAD_REQUEST, "Audio delay >= 0");
|
throw new ResponseStatusException(BAD_REQUEST, "Audio delay >= 0");
|
||||||
if (req.getAudioSpeed() != null && (req.getAudioSpeed() < minAudioSpeed || req.getAudioSpeed() > maxAudioSpeed))
|
if (req.getAudioSpeed() != null && (req.getAudioSpeed() < minSpeed || req.getAudioSpeed() > maxSpeed))
|
||||||
throw new ResponseStatusException(BAD_REQUEST, "Audio speed out of range");
|
throw new ResponseStatusException(BAD_REQUEST, "Audio speed out of range");
|
||||||
if (req.getAudioPitch() != null && (req.getAudioPitch() < minAudioPitch || req.getAudioPitch() > maxAudioPitch))
|
if (req.getAudioPitch() != null && (req.getAudioPitch() < minPitch || req.getAudioPitch() > maxPitch))
|
||||||
throw new ResponseStatusException(BAD_REQUEST, "Audio pitch out of range");
|
throw new ResponseStatusException(BAD_REQUEST, "Audio pitch out of range");
|
||||||
if (req.getAudioVolume() != null && (req.getAudioVolume() < 0 || req.getAudioVolume() > maxAudioVolume))
|
if (req.getAudioVolume() != null && (req.getAudioVolume() < minVolume || req.getAudioVolume() > maxVolume))
|
||||||
throw new ResponseStatusException(BAD_REQUEST, "Audio volume out of range");
|
throw new ResponseStatusException(BAD_REQUEST, "Audio volume out of range [" + minVolume + " to " + maxVolume + "]");
|
||||||
}
|
}
|
||||||
|
|
||||||
public Optional<AssetView> triggerPlayback(String broadcaster, String assetId, PlaybackRequest req) {
|
public Optional<AssetView> triggerPlayback(String broadcaster, String assetId, PlaybackRequest req) {
|
||||||
@@ -314,22 +311,12 @@ public class ChannelDirectoryService {
|
|||||||
return assetRepository.findById(assetId).flatMap(assetStorageService::loadAssetFileSafely);
|
return assetRepository.findById(assetId).flatMap(assetStorageService::loadAssetFileSafely);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Optional<AssetContent> getVisibleAssetContent(String assetId) {
|
|
||||||
return assetRepository.findById(assetId)
|
|
||||||
.filter(a -> !a.isHidden())
|
|
||||||
.flatMap(assetStorageService::loadAssetFileSafely);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Optional<AssetContent> getAssetPreview(String assetId, boolean includeHidden) {
|
public Optional<AssetContent> getAssetPreview(String assetId, boolean includeHidden) {
|
||||||
return assetRepository.findById(assetId)
|
return assetRepository.findById(assetId)
|
||||||
.filter(a -> includeHidden || !a.isHidden())
|
.filter(a -> includeHidden || !a.isHidden())
|
||||||
.flatMap(assetStorageService::loadPreviewSafely);
|
.flatMap(assetStorageService::loadPreviewSafely);
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isBroadcaster(String broadcaster, String username) {
|
|
||||||
return broadcaster != null && broadcaster.equalsIgnoreCase(username);
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isAdmin(String broadcaster, String username) {
|
public boolean isAdmin(String broadcaster, String username) {
|
||||||
return channelRepository.findById(normalize(broadcaster))
|
return channelRepository.findById(normalize(broadcaster))
|
||||||
.map(Channel::getAdmins)
|
.map(Channel::getAdmins)
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package dev.kruhlmann.imgfloat.service;
|
||||||
|
|
||||||
|
import dev.kruhlmann.imgfloat.model.Settings;
|
||||||
|
import dev.kruhlmann.imgfloat.repository.SettingsRepository;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class SettingsService {
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(SettingsService.class);
|
||||||
|
|
||||||
|
private final SettingsRepository repo;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public SettingsService(SettingsRepository repo, ObjectMapper objectMapper) {
|
||||||
|
this.repo = repo;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
public void initDefaults() {
|
||||||
|
if (repo.existsById(1)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Settings s = Settings.defaults();
|
||||||
|
logSettings("Initializing default settings", s);
|
||||||
|
repo.save(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Settings get() {
|
||||||
|
return repo.findById(1).orElseThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Settings save(Settings settings) {
|
||||||
|
settings.setId(1);
|
||||||
|
logSettings("Saving settings", settings);
|
||||||
|
return repo.save(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void logSettings(String msg, Settings settings) {
|
||||||
|
try {
|
||||||
|
logger.info("{}:\n{}",
|
||||||
|
msg,
|
||||||
|
objectMapper
|
||||||
|
.writerWithDefaultPrettyPrinter()
|
||||||
|
.writeValueAsString(settings)
|
||||||
|
);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
logger.error("Failed to serialize settings", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package dev.kruhlmann.imgfloat.service;
|
||||||
|
|
||||||
|
import dev.kruhlmann.imgfloat.model.SystemAdministrator;
|
||||||
|
import dev.kruhlmann.imgfloat.repository.SystemAdministratorRepository;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class SystemAdministratorService {
|
||||||
|
|
||||||
|
private static final Logger logger =
|
||||||
|
LoggerFactory.getLogger(SystemAdministratorService.class);
|
||||||
|
|
||||||
|
private final SystemAdministratorRepository repo;
|
||||||
|
private final String initialSysadmin;
|
||||||
|
|
||||||
|
public SystemAdministratorService(
|
||||||
|
SystemAdministratorRepository repo,
|
||||||
|
@Value("${IMGFLOAT_INITIAL_TWITCH_USERNAME_SYSADMIN:#{null}}")
|
||||||
|
String initialSysadmin
|
||||||
|
) {
|
||||||
|
this.repo = repo;
|
||||||
|
this.initialSysadmin = initialSysadmin;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
public void initDefaults() {
|
||||||
|
if (repo.count() > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initialSysadmin == null || initialSysadmin.isBlank()) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"No system administrators exist and IMGFLOAT_INITIAL_TWITCH_USERNAME_SYSADMIN is not set"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
addSysadmin(initialSysadmin);
|
||||||
|
logger.info("Created initial system administrator '{}'", initialSysadmin);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addSysadmin(String twitchUsername) {
|
||||||
|
String normalized = normalize(twitchUsername);
|
||||||
|
|
||||||
|
if (repo.existsByTwitchUsername(normalized)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
repo.save(new SystemAdministrator(normalized));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void removeSysadmin(String twitchUsername) {
|
||||||
|
if (repo.count() <= 1) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Cannot remove the last system administrator"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
long deleted = repo.deleteByTwitchUsername(normalize(twitchUsername));
|
||||||
|
|
||||||
|
if (deleted == 0) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"System administrator does not exist"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isSysadmin(String twitchUsername) {
|
||||||
|
return repo.existsByTwitchUsername(normalize(twitchUsername));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalize(String username) {
|
||||||
|
return username.trim().toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
let stompClient;
|
|
||||||
const canvas = document.getElementById('admin-canvas');
|
const canvas = document.getElementById('admin-canvas');
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
const overlay = document.getElementById('admin-overlay');
|
const overlay = document.getElementById('admin-overlay');
|
||||||
@@ -6,7 +5,6 @@ let canvasSettings = { width: 1920, height: 1080 };
|
|||||||
canvas.width = canvasSettings.width;
|
canvas.width = canvasSettings.width;
|
||||||
canvas.height = canvasSettings.height;
|
canvas.height = canvasSettings.height;
|
||||||
const assets = new Map();
|
const assets = new Map();
|
||||||
let pendingUploads = [];
|
|
||||||
const mediaCache = new Map();
|
const mediaCache = new Map();
|
||||||
const renderStates = new Map();
|
const renderStates = new Map();
|
||||||
const animatedCache = new Map();
|
const animatedCache = new Map();
|
||||||
@@ -15,21 +13,13 @@ const pendingAudioUnlock = new Set();
|
|||||||
const loopPlaybackState = new Map();
|
const loopPlaybackState = new Map();
|
||||||
const previewCache = new Map();
|
const previewCache = new Map();
|
||||||
const previewImageCache = new Map();
|
const previewImageCache = new Map();
|
||||||
let drawPending = false;
|
const pendingTransformSaves = new Map();
|
||||||
let layerOrder = [];
|
|
||||||
let selectedAssetId = null;
|
|
||||||
let interactionState = null;
|
|
||||||
let lastSizeInputChanged = null;
|
|
||||||
const HANDLE_SIZE = 10;
|
const HANDLE_SIZE = 10;
|
||||||
const ROTATE_HANDLE_OFFSET = 32;
|
const ROTATE_HANDLE_OFFSET = 32;
|
||||||
const MAX_VOLUME = adminInputRestrictions.MAX_AUDIO_VOLUME;
|
const VOLUME_SLIDER_MAX = SETTINGS.maxAssetVolumeFraction * 100;
|
||||||
const VOLUME_SLIDER_MAX = adminInputRestrictions.MAX_AUDIO_VOLUME * 100;
|
|
||||||
const VOLUME_CURVE_STRENGTH = -0.6;
|
const VOLUME_CURVE_STRENGTH = -0.6;
|
||||||
const pendingTransformSaves = new Map();
|
|
||||||
const KEYBOARD_NUDGE_STEP = 5;
|
const KEYBOARD_NUDGE_STEP = 5;
|
||||||
const KEYBOARD_NUDGE_FAST_STEP = 20;
|
const KEYBOARD_NUDGE_FAST_STEP = 20;
|
||||||
|
|
||||||
|
|
||||||
const controlsPanel = document.getElementById('asset-controls');
|
const controlsPanel = document.getElementById('asset-controls');
|
||||||
const widthInput = document.getElementById('asset-width');
|
const widthInput = document.getElementById('asset-width');
|
||||||
const heightInput = document.getElementById('asset-height');
|
const heightInput = document.getElementById('asset-height');
|
||||||
@@ -68,6 +58,14 @@ const aspectLockState = new Map();
|
|||||||
const commitSizeChange = debounce(() => applyTransformFromInputs(), 180);
|
const commitSizeChange = debounce(() => applyTransformFromInputs(), 180);
|
||||||
const audioUnlockEvents = ['pointerdown', 'keydown', 'touchstart'];
|
const audioUnlockEvents = ['pointerdown', 'keydown', 'touchstart'];
|
||||||
|
|
||||||
|
let drawPending = false;
|
||||||
|
let layerOrder = [];
|
||||||
|
let pendingUploads = [];
|
||||||
|
let selectedAssetId = null;
|
||||||
|
let interactionState = null;
|
||||||
|
let lastSizeInputChanged = null;
|
||||||
|
let stompClient;
|
||||||
|
|
||||||
audioUnlockEvents.forEach((eventName) => {
|
audioUnlockEvents.forEach((eventName) => {
|
||||||
window.addEventListener(eventName, () => {
|
window.addEventListener(eventName, () => {
|
||||||
if (!pendingAudioUnlock.size) return;
|
if (!pendingAudioUnlock.size) return;
|
||||||
@@ -294,16 +292,16 @@ function clamp(value, min, max) {
|
|||||||
function sliderToVolume(sliderValue) {
|
function sliderToVolume(sliderValue) {
|
||||||
const normalized = clamp(sliderValue, 0, VOLUME_SLIDER_MAX) / VOLUME_SLIDER_MAX;
|
const normalized = clamp(sliderValue, 0, VOLUME_SLIDER_MAX) / VOLUME_SLIDER_MAX;
|
||||||
const curved = normalized + VOLUME_CURVE_STRENGTH * normalized * (1 - normalized) * (1 - 2 * normalized);
|
const curved = normalized + VOLUME_CURVE_STRENGTH * normalized * (1 - normalized) * (1 - 2 * normalized);
|
||||||
return clamp(curved * MAX_VOLUME, 0, MAX_VOLUME);
|
return clamp(curved * SETTINGS.maxAssetVolumeFraction, SETTINGS.minAssetVolumeFraction, SETTINGS.maxAssetVolumeFraction);
|
||||||
}
|
}
|
||||||
|
|
||||||
function volumeToSlider(volumeValue) {
|
function volumeToSlider(volumeValue) {
|
||||||
const target = clamp(volumeValue ?? 1, 0, MAX_VOLUME) / MAX_VOLUME;
|
const target = clamp(volumeValue ?? 1, SETTINGS.minAssetVolumeFraction, SETTINGS.maxAssetVolumeFraction) / SETTINGS.maxAssetVolumeFraction;
|
||||||
let low = 0;
|
let low = 0;
|
||||||
let high = VOLUME_SLIDER_MAX;
|
let high = VOLUME_SLIDER_MAX;
|
||||||
for (let i = 0; i < 24; i += 1) {
|
for (let i = 0; i < 24; i += 1) {
|
||||||
const mid = (low + high) / 2;
|
const mid = (low + high) / 2;
|
||||||
const midNormalized = sliderToVolume(mid) / MAX_VOLUME;
|
const midNormalized = sliderToVolume(mid) / SETTINGS.maxAssetVolumeFraction;
|
||||||
if (midNormalized < target) {
|
if (midNormalized < target) {
|
||||||
low = mid;
|
low = mid;
|
||||||
} else {
|
} else {
|
||||||
@@ -983,7 +981,7 @@ function applyAudioSettings(controller, asset, resetPosition = false) {
|
|||||||
const speed = Math.max(0.25, asset.audioSpeed || 1);
|
const speed = Math.max(0.25, asset.audioSpeed || 1);
|
||||||
const pitch = Math.max(0.5, asset.audioPitch || 1);
|
const pitch = Math.max(0.5, asset.audioPitch || 1);
|
||||||
controller.element.playbackRate = speed * pitch;
|
controller.element.playbackRate = speed * pitch;
|
||||||
const volume = clamp(asset.audioVolume ?? 1, 0, MAX_VOLUME);
|
const volume = clamp(asset.audioVolume ?? 1, SETTINGS.minAssetVolumeFraction, SETTINGS.maxAssetVolumeFraction);
|
||||||
controller.element.volume = volume;
|
controller.element.volume = volume;
|
||||||
if (resetPosition) {
|
if (resetPosition) {
|
||||||
controller.element.currentTime = 0;
|
controller.element.currentTime = 0;
|
||||||
@@ -1058,7 +1056,7 @@ function ensureMedia(asset) {
|
|||||||
element.crossOrigin = 'anonymous';
|
element.crossOrigin = 'anonymous';
|
||||||
if (isVideoElement(element)) {
|
if (isVideoElement(element)) {
|
||||||
element.loop = true;
|
element.loop = true;
|
||||||
const volume = clamp(asset.audioVolume ?? 1, 0, MAX_VOLUME);
|
const volume = clamp(asset.audioVolume ?? 1, SETTINGS.minAssetVolumeFraction, SETTINGS.maxAssetVolumeFraction);
|
||||||
element.muted = volume === 0;
|
element.muted = volume === 0;
|
||||||
element.volume = Math.min(volume, 1);
|
element.volume = Math.min(volume, 1);
|
||||||
element.playsInline = true;
|
element.playsInline = true;
|
||||||
@@ -1164,7 +1162,7 @@ function applyMediaSettings(element, asset) {
|
|||||||
if (element.playbackRate !== effectiveSpeed) {
|
if (element.playbackRate !== effectiveSpeed) {
|
||||||
element.playbackRate = effectiveSpeed;
|
element.playbackRate = effectiveSpeed;
|
||||||
}
|
}
|
||||||
const volume = clamp(asset.audioVolume ?? 1, 0, MAX_VOLUME);
|
const volume = clamp(asset.audioVolume ?? 1, SETTINGS.minAssetVolumeFraction, SETTINGS.maxAssetVolumeFraction);
|
||||||
element.muted = volume === 0;
|
element.muted = volume === 0;
|
||||||
element.volume = Math.min(volume, 1);
|
element.volume = Math.min(volume, 1);
|
||||||
if (nextSpeed === 0) {
|
if (nextSpeed === 0) {
|
||||||
@@ -2023,8 +2021,8 @@ function uploadAsset(file = null) {
|
|||||||
showToast('Choose an image, GIF, video, or audio file to upload.', 'info');
|
showToast('Choose an image, GIF, video, or audio file to upload.', 'info');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selectedFile.size > adminInputRestrictions.UPLOAD_MAX_BYTES) {
|
if (selectedFile.size > UPLOAD_LIMIT_BYTES) {
|
||||||
showToast(`File is too large. Maximum upload size is ${adminInputRestrictions.UPLOAD_MAX_BYTES / 1024 / 1024} MB.`, 'error');
|
showToast(`File is too large. Maximum upload size is ${UPLOAD_MAX_BYTES / 1024 / 1024} MB.`, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -207,15 +207,8 @@
|
|||||||
<script th:inline="javascript">
|
<script th:inline="javascript">
|
||||||
const broadcaster = /*[[${broadcaster}]]*/ '';
|
const broadcaster = /*[[${broadcaster}]]*/ '';
|
||||||
const username = /*[[${username}]]*/ '';
|
const username = /*[[${username}]]*/ '';
|
||||||
const adminInputRestrictions = {
|
const UPLOAD_LIMIT_BYTES = /*[[${uploadLimitBytes}]]*/ 0;
|
||||||
UPLOAD_MAX_BYTES: /*[[${uploadLimitBytes}]]*/ + 0,
|
const SETTINGS = /*[[${settingsJson}]]*/;
|
||||||
MAX_SPEED: /*[[${maxSpeed}]]*/ + 0.0,
|
|
||||||
MIN_AUDIO_SPEED: /*[[${minAudioSpeed}]]*/ + 0.0,
|
|
||||||
MAX_AUDIO_SPEED: /*[[${maxAudioSpeed}]]*/ + 0.0,
|
|
||||||
MIN_AUDIO_PITCH: /*[[${minAudioPitch}]]*/ + 0.0,
|
|
||||||
MAX_AUDIO_PITCH: /*[[${maxAudioPitch}]]*/ + 0.0,
|
|
||||||
MAX_AUDIO_VOLUME: /*[[${maxAudioVolume}]]*/ + 0.0,
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
<script src="/js/toast.js"></script>
|
<script src="/js/toast.js"></script>
|
||||||
<script src="/js/admin.js"></script>
|
<script src="/js/admin.js"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user