diff --git a/Makefile b/Makefile index 6528d7a..d2cdef6 100644 --- a/Makefile +++ b/Makefile @@ -6,23 +6,13 @@ IMGFLOAT_DB_PATH ?= ./imgfloat.db IMGFLOAT_ASSETS_PATH ?= ./assets IMGFLOAT_PREVIEWS_PATH ?= ./previews -IMGFLOAT_MAX_SPEED ?= 4.0 -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 +IMGFLOAT_INITIAL_TWITCH_USERNAME_SYSADMIN ?= gasolinebased SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE ?= 10MB SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE ?= 10MB RUNTIME_ENV = IMGFLOAT_ASSETS_PATH=$(IMGFLOAT_ASSETS_PATH) \ IMGFLOAT_PREVIEWS_PATH=$(IMGFLOAT_PREVIEWS_PATH) \ IMGFLOAT_DB_PATH=$(IMGFLOAT_DB_PATH) \ - IMGFLOAT_MAX_SPEED=$(IMGFLOAT_MAX_SPEED) \ - 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) \ + IMGFLOAT_INITIAL_TWITCH_USERNAME_SYSADMIN=$(IMGFLOAT_INITIAL_TWITCH_USERNAME_SYSADMIN) \ SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE=$(SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE) \ SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE=$(SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE) WATCHDIR = ./src/main @@ -37,7 +27,7 @@ run: .PHONY: watch watch: - mvn compile + -mvn compile while sleep 0.1; do find $(WATCHDIR) -type f | entr -d mvn compile; done .PHONY: test diff --git a/pom.xml b/pom.xml index 3424c7d..956c9fa 100644 --- a/pom.xml +++ b/pom.xml @@ -56,6 +56,10 @@ org.springframework.boot spring-boot-starter-security + + com.fasterxml.jackson.core + jackson-databind + org.springframework.boot spring-boot-starter-oauth2-client diff --git a/src/main/java/dev/kruhlmann/imgfloat/ImgfloatApplication.java b/src/main/java/dev/kruhlmann/imgfloat/ImgfloatApplication.java index 477fe67..9613365 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/ImgfloatApplication.java +++ b/src/main/java/dev/kruhlmann/imgfloat/ImgfloatApplication.java @@ -2,7 +2,9 @@ package dev.kruhlmann.imgfloat; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; +@EnableAsync @SpringBootApplication public class ImgfloatApplication { public static void main(String[] args) { diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/SystemEnvironmentValidator.java b/src/main/java/dev/kruhlmann/imgfloat/config/SystemEnvironmentValidator.java index 317ed7e..ebb39c1 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/config/SystemEnvironmentValidator.java +++ b/src/main/java/dev/kruhlmann/imgfloat/config/SystemEnvironmentValidator.java @@ -26,20 +26,10 @@ public class SystemEnvironmentValidator { private String assetsPath; @Value("${IMGFLOAT_PREVIEWS_PATH:#{null}}") private String previewsPath; - @Value("${IMGFLOAT_DB_PATH}") + @Value("${IMGFLOAT_DB_PATH:#{null}}") private String dbPath; - @Value("${IMGFLOAT_MAX_SPEED}") - private double maxSpeed; - @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; + @Value("${IMGFLOAT_INITIAL_TWITCH_USERNAME_SYSADMIN:#{null}}") + private String initialSysadmin; private long maxUploadBytes; private long maxRequestBytes; @@ -52,13 +42,8 @@ public class SystemEnvironmentValidator { maxRequestBytes = DataSize.parse(springMaxRequestSize).toBytes(); checkUnsignedNumeric(maxUploadBytes, "SPRING_SERVLET_MULTIPART_MAX_FILE_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(initialSysadmin, "IMGFLOAT_INITIAL_TWITCH_USERNAME_SYSADMIN", missing); checkString(dbPath, "IMGFLOAT_DB_PATH", missing); checkString(twitchClientSecret, "TWITCH_CLIENT_SECRET", missing); checkString(assetsPath, "IMGFLOAT_ASSETS_PATH", missing); @@ -78,15 +63,10 @@ public class SystemEnvironmentValidator { springMaxFileSize, maxUploadBytes); log.info(" - SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE: {} ({} bytes)", 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_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) { diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java index 53de6fb..e91d5b1 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java @@ -7,8 +7,10 @@ import dev.kruhlmann.imgfloat.model.PlaybackRequest; import dev.kruhlmann.imgfloat.model.TransformRequest; import dev.kruhlmann.imgfloat.model.TwitchUserProfile; import dev.kruhlmann.imgfloat.model.VisibilityRequest; +import dev.kruhlmann.imgfloat.model.OauthSessionUser; import dev.kruhlmann.imgfloat.service.ChannelDirectoryService; import dev.kruhlmann.imgfloat.service.TwitchUserLookupService; +import dev.kruhlmann.imgfloat.service.AuthorizationService; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import jakarta.validation.Valid; import org.slf4j.Logger; @@ -49,22 +51,27 @@ public class ChannelApiController { private final ChannelDirectoryService channelDirectoryService; private final OAuth2AuthorizedClientService authorizedClientService; private final TwitchUserLookupService twitchUserLookupService; + private final AuthorizationService authorizationService; - public ChannelApiController(ChannelDirectoryService channelDirectoryService, - OAuth2AuthorizedClientService authorizedClientService, - TwitchUserLookupService twitchUserLookupService) { + public ChannelApiController( + ChannelDirectoryService channelDirectoryService, + OAuth2AuthorizedClientService authorizedClientService, + TwitchUserLookupService twitchUserLookupService, + AuthorizationService authorizationService + ) { this.channelDirectoryService = channelDirectoryService; this.authorizedClientService = authorizedClientService; this.twitchUserLookupService = twitchUserLookupService; + this.authorizationService = authorizationService; } @PostMapping("/admins") public ResponseEntity addAdmin(@PathVariable("broadcaster") String broadcaster, @Valid @RequestBody AdminRequest request, - OAuth2AuthenticationToken authentication) { - String login = TwitchUser.from(authentication).login(); - ensureBroadcaster(broadcaster, login); - LOG.info("User {} adding admin {} to {}", login, request.getUsername(), broadcaster); + OAuth2AuthenticationToken oauthToken) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername); + LOG.info("User {} adding admin {} to {}", sessionUsername, request.getUsername(), broadcaster); boolean added = channelDirectoryService.addAdmin(broadcaster, request.getUsername()); if (!added) { LOG.info("User {} already admin for {} or could not be added", request.getUsername(), broadcaster); @@ -74,16 +81,16 @@ public class ChannelApiController { @GetMapping("/admins") public Collection listAdmins(@PathVariable("broadcaster") String broadcaster, - OAuth2AuthenticationToken authentication, + OAuth2AuthenticationToken oauthToken, @RegisteredOAuth2AuthorizedClient("twitch") OAuth2AuthorizedClient authorizedClient) { - String login = TwitchUser.from(authentication).login(); - ensureBroadcaster(broadcaster, login); - LOG.debug("Listing admins for {} by {}", broadcaster, login); + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername); + LOG.debug("Listing admins for {} by {}", broadcaster, sessionUsername); var channel = channelDirectoryService.getOrCreateChannel(broadcaster); List admins = channel.getAdmins().stream() .sorted(Comparator.naturalOrder()) .toList(); - authorizedClient = resolveAuthorizedClient(authentication, authorizedClient); + authorizedClient = resolveAuthorizedClient(oauthToken, authorizedClient); String accessToken = Optional.ofNullable(authorizedClient) .map(OAuth2AuthorizedClient::getAccessToken) .map(token -> token.getTokenValue()) @@ -97,16 +104,16 @@ public class ChannelApiController { @GetMapping("/admins/suggestions") public Collection listAdminSuggestions(@PathVariable("broadcaster") String broadcaster, - OAuth2AuthenticationToken authentication, + OAuth2AuthenticationToken oauthToken, @RegisteredOAuth2AuthorizedClient("twitch") OAuth2AuthorizedClient authorizedClient) { - String login = TwitchUser.from(authentication).login(); - ensureBroadcaster(broadcaster, login); - LOG.debug("Listing admin suggestions for {} by {}", broadcaster, login); + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername); + LOG.debug("Listing admin suggestions for {} by {}", broadcaster, sessionUsername); var channel = channelDirectoryService.getOrCreateChannel(broadcaster); - authorizedClient = resolveAuthorizedClient(authentication, authorizedClient); + authorizedClient = resolveAuthorizedClient(oauthToken, authorizedClient); 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(); } String accessToken = Optional.ofNullable(authorizedClient) @@ -118,7 +125,7 @@ public class ChannelApiController { .map(registration -> registration.getClientId()) .orElse(null); 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 twitchUserLookupService.fetchModerators(broadcaster, channel.getAdmins(), accessToken, clientId); @@ -127,24 +134,16 @@ public class ChannelApiController { @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); - LOG.info("User {} removing admin {} from {}", login, username, broadcaster); + OAuth2AuthenticationToken oauthToken) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername); + LOG.info("User {} removing admin {} from {}", sessionUsername, username, broadcaster); boolean removed = channelDirectoryService.removeAdmin(broadcaster, username); return ResponseEntity.ok().body(removed); } @GetMapping("/assets") - public Collection 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); + public Collection listAssets(@PathVariable("broadcaster") String broadcaster) { return channelDirectoryService.getAssetsForAdmin(broadcaster); } @@ -155,37 +154,36 @@ public class ChannelApiController { @GetMapping("/canvas") public CanvasSettingsRequest getCanvas(@PathVariable("broadcaster") String broadcaster) { - LOG.debug("Fetching canvas settings for {}", broadcaster); return channelDirectoryService.getCanvasSettings(broadcaster); } @PutMapping("/canvas") public CanvasSettingsRequest updateCanvas(@PathVariable("broadcaster") String broadcaster, @Valid @RequestBody CanvasSettingsRequest request, - OAuth2AuthenticationToken authentication) { - String login = TwitchUser.from(authentication).login(); - ensureBroadcaster(broadcaster, login); - LOG.info("Updating canvas for {} by {}: {}x{}", broadcaster, login, request.getWidth(), request.getHeight()); + OAuth2AuthenticationToken oauthToken) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername); + LOG.info("Updating canvas for {} by {}: {}x{}", broadcaster, sessionUsername, request.getWidth(), request.getHeight()); return channelDirectoryService.updateCanvasSettings(broadcaster, request); } @PostMapping(value = "/assets", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity createAsset(@PathVariable("broadcaster") String broadcaster, @org.springframework.web.bind.annotation.RequestPart("file") MultipartFile file, - OAuth2AuthenticationToken authentication) { - String login = TwitchUser.from(authentication).login(); - ensureAuthorized(broadcaster, login); + OAuth2AuthenticationToken oauthToken) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername); 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"); } 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) .map(ResponseEntity::ok) .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to read image")); } 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); } } @@ -194,14 +192,14 @@ public class ChannelApiController { public ResponseEntity transform(@PathVariable("broadcaster") String broadcaster, @PathVariable("assetId") String assetId, @Valid @RequestBody TransformRequest request, - OAuth2AuthenticationToken authentication) { - String login = TwitchUser.from(authentication).login(); - ensureAuthorized(broadcaster, login); - LOG.debug("Applying transform to asset {} on {} by {}", assetId, broadcaster, login); + OAuth2AuthenticationToken oauthToken) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername); + LOG.debug("Applying transform to asset {} on {} by {}", assetId, broadcaster, sessionUsername); return channelDirectoryService.updateTransform(broadcaster, assetId, request) .map(ResponseEntity::ok) .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"); }); } @@ -210,9 +208,10 @@ public class ChannelApiController { public ResponseEntity play(@PathVariable("broadcaster") String broadcaster, @PathVariable("assetId") String assetId, @RequestBody(required = false) PlaybackRequest request, - OAuth2AuthenticationToken authentication) { - String login = TwitchUser.from(authentication).login(); - ensureAuthorized(broadcaster, login); + OAuth2AuthenticationToken oauthToken) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername); + LOG.info("Triggering playback for asset {} on {} by {}", assetId, broadcaster, sessionUsername); return channelDirectoryService.triggerPlayback(broadcaster, assetId, request) .map(ResponseEntity::ok) .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found")); @@ -222,76 +221,41 @@ public class ChannelApiController { public ResponseEntity visibility(@PathVariable("broadcaster") String broadcaster, @PathVariable("assetId") String assetId, @RequestBody VisibilityRequest request, - OAuth2AuthenticationToken authentication) { - String login = TwitchUser.from(authentication).login(); - ensureAuthorized(broadcaster, login); - LOG.info("Updating visibility for asset {} on {} by {} to hidden={} ", assetId, broadcaster, login, request.isHidden()); + OAuth2AuthenticationToken oauthToken) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername); + LOG.info("Updating visibility for asset {} on {} by {} to hidden={} ", assetId, broadcaster, sessionUsername , request.isHidden()); return channelDirectoryService.updateVisibility(broadcaster, assetId, request) .map(ResponseEntity::ok) .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"); }); } @GetMapping("/assets/{assetId}/content") public ResponseEntity getAssetContent(@PathVariable("broadcaster") String broadcaster, - @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 asset {} for broadcaster {} to authenticated user {}", assetId, broadcaster, authentication.getName()); - return channelDirectoryService.getAssetContent(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(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")); + @PathVariable("assetId") String assetId) { + LOG.debug("Serving asset {} for broadcaster {}", assetId, broadcaster); + return channelDirectoryService.getAssetContent(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(NOT_FOUND, "Asset not found")); } @GetMapping("/assets/{assetId}/preview") public ResponseEntity getAssetPreview(@PathVariable("broadcaster") String broadcaster, - @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); - return channelDirectoryService.getAssetPreview(assetId, true) - .map(content -> ResponseEntity.ok() - .header("X-Content-Type-Options", "nosniff") - .contentType(MediaType.parseMediaType(content.mediaType())) - .body(content.bytes())) - .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")); + @PathVariable("assetId") String assetId) { + LOG.debug("Serving preview for asset {} for broadcaster {}", assetId, broadcaster); + return channelDirectoryService.getAssetPreview(assetId, true) + .map(content -> ResponseEntity.ok() + .header("X-Content-Type-Options", "nosniff") + .contentType(MediaType.parseMediaType(content.mediaType())) + .body(content.bytes())) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Preview not found")); } private String contentDispositionFor(String mediaType) { @@ -304,43 +268,26 @@ public class ChannelApiController { @DeleteMapping("/assets/{assetId}") public ResponseEntity delete(@PathVariable("broadcaster") String broadcaster, @PathVariable("assetId") String assetId, - OAuth2AuthenticationToken authentication) { - String login = TwitchUser.from(authentication).login(); - ensureAuthorized(broadcaster, login); + OAuth2AuthenticationToken oauthToken) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername); boolean removed = channelDirectoryService.deleteAsset(assetId); 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"); } - LOG.info("Asset {} deleted on {} by {}", assetId, broadcaster, login); + LOG.info("Asset {} deleted on {} by {}", assetId, broadcaster, sessionUsername); return ResponseEntity.ok().build(); } - private void ensureBroadcaster(String broadcaster, String login) { - 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, + private OAuth2AuthorizedClient resolveAuthorizedClient(OAuth2AuthenticationToken oauthToken, OAuth2AuthorizedClient authorizedClient) { if (authorizedClient != null) { return authorizedClient; } - if (authentication == null) { + if (oauthToken == null) { return null; } - return authorizedClientService.loadAuthorizedClient( - authentication.getAuthorizedClientRegistrationId(), - authentication.getName()); + return authorizedClientService.loadAuthorizedClient(oauthToken.getAuthorizedClientRegistrationId(), oauthToken.getName()); } } diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/SettingsApiController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/SettingsApiController.java new file mode 100644 index 0000000..76bf7e1 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/SettingsApiController.java @@ -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 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); + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java index 4d8af79..0f0ca2c 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java @@ -2,6 +2,13 @@ package dev.kruhlmann.imgfloat.controller; import dev.kruhlmann.imgfloat.service.ChannelDirectoryService; 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.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -13,51 +20,42 @@ import org.springframework.util.unit.DataSize; import org.springframework.web.server.ResponseStatusException; import static org.springframework.http.HttpStatus.FORBIDDEN; +import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; @Controller public class ViewController { private static final Logger LOG = LoggerFactory.getLogger(ViewController.class); private final ChannelDirectoryService channelDirectoryService; private final VersionService versionService; + private final SettingsService settingsService; + private final ObjectMapper objectMapper; + private final AuthorizationService authorizationService; @Autowired private long uploadLimitBytes; - private double maxSpeed; - private double minAudioSpeed; - private double maxAudioSpeed; - private double minAudioPitch; - private double maxAudioPitch; - private double maxAudioVolume; - public ViewController( ChannelDirectoryService channelDirectoryService, VersionService versionService, - @Value("${IMGFLOAT_MAX_SPEED}") double maxSpeed, - @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 + SettingsService settingsService, + ObjectMapper objectMapper, + AuthorizationService authorizationService ) { this.channelDirectoryService = channelDirectoryService; this.versionService = versionService; - this.maxSpeed = maxSpeed; - this.minAudioSpeed = minAudioSpeed; - this.maxAudioSpeed = maxAudioSpeed; - this.minAudioPitch = minAudioPitch; - this.maxAudioPitch = maxAudioPitch; - this.maxAudioVolume = maxAudioVolume; + this.settingsService = settingsService; + this.objectMapper = objectMapper; + this.authorizationService = authorizationService; } @org.springframework.web.bind.annotation.GetMapping("/") - public String home(OAuth2AuthenticationToken authentication, Model model) { - if (authentication != null) { - String login = TwitchUser.from(authentication).login(); - LOG.info("Rendering dashboard for {}", login); - model.addAttribute("username", login); - model.addAttribute("channel", login); - model.addAttribute("adminChannels", channelDirectoryService.adminChannelsFor(login)); + public String home(OAuth2AuthenticationToken oauthToken, Model model) { + if (oauthToken != null) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + LOG.info("Rendering dashboard for {}", sessionUsername); + model.addAttribute("username", sessionUsername); + model.addAttribute("channel", sessionUsername); + model.addAttribute("adminChannels", channelDirectoryService.adminChannelsFor(sessionUsername)); return "dashboard"; } model.addAttribute("version", versionService.getVersion()); @@ -72,25 +70,21 @@ public class ViewController { @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)) { - LOG.warn("Unauthorized admin console access attempt for {} by {}", broadcaster, login); - throw new ResponseStatusException(FORBIDDEN, "Not authorized for admin tools"); - } - LOG.info("Rendering admin console for {} (requested by {})", broadcaster, login); + OAuth2AuthenticationToken oauthToken, + Model model) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername); + LOG.info("Rendering admin console for {} (requested by {})", broadcaster, sessionUsername); + Settings settings = settingsService.get(); model.addAttribute("broadcaster", broadcaster.toLowerCase()); - model.addAttribute("username", login); + model.addAttribute("username", sessionUsername); model.addAttribute("uploadLimitBytes", uploadLimitBytes); - - model.addAttribute("maxSpeed", maxSpeed); - model.addAttribute("minAudioSpeed", minAudioSpeed); - model.addAttribute("maxAudioSpeed", maxAudioSpeed); - model.addAttribute("minAudioPitch", minAudioPitch); - model.addAttribute("maxAudioPitch", maxAudioPitch); - model.addAttribute("maxAudioVolume", maxAudioVolume); + try { + model.addAttribute("settingsJson", objectMapper.writeValueAsString(settings)); + } catch (JsonProcessingException e) { + LOG.error("Failed to serialize settings for admin view", e); + throw new ResponseStatusException(INTERNAL_SERVER_ERROR, "Failed to serialize settings"); + } return "admin"; } @@ -103,23 +97,3 @@ public class ViewController { return "broadcast"; } } - -record TwitchUser(String login, String displayName) { - static TwitchUser from(OAuth2AuthenticationToken authentication) { - if (authentication == null) { - throw new ResponseStatusException(FORBIDDEN, "Authentication required"); - } - String login = authentication.getPrincipal().getAttribute("preferred_username"); - if (login == null) { - login = authentication.getPrincipal().getAttribute("login"); - } - if (login == null) { - login = authentication.getPrincipal().getName(); - } - String displayName = authentication.getPrincipal().getAttribute("display_name"); - if (displayName == null) { - displayName = login; - } - return new TwitchUser(login, displayName); - } -} diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/OauthSessionUser.java b/src/main/java/dev/kruhlmann/imgfloat/model/OauthSessionUser.java new file mode 100644 index 0000000..4676f9c --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/model/OauthSessionUser.java @@ -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().getAttribute("preferred_username"); + if (login == null) { + login = authentication.getPrincipal().getAttribute("login"); + } + if (login == null) { + login = authentication.getPrincipal().getName(); + } + String displayName = authentication.getPrincipal().getAttribute("display_name"); + if (displayName == null) { + displayName = login; + } + return new OauthSessionUser(login, displayName); + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/Settings.java b/src/main/java/dev/kruhlmann/imgfloat/model/Settings.java new file mode 100644 index 0000000..a53e8ea --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/model/Settings.java @@ -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; + } + +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/SystemAdministrator.java b/src/main/java/dev/kruhlmann/imgfloat/model/SystemAdministrator.java new file mode 100644 index 0000000..ae992d3 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/model/SystemAdministrator.java @@ -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; + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/repository/SettingsRepository.java b/src/main/java/dev/kruhlmann/imgfloat/repository/SettingsRepository.java new file mode 100644 index 0000000..3c55518 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/repository/SettingsRepository.java @@ -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 { +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/repository/SystemAdministratorRepository.java b/src/main/java/dev/kruhlmann/imgfloat/repository/SystemAdministratorRepository.java new file mode 100644 index 0000000..ca51d65 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/repository/SystemAdministratorRepository.java @@ -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 { + boolean existsByTwitchUsername(String twitchUsername); + long deleteByTwitchUsername(String twitchUsername); +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/AssetCleanupService.java b/src/main/java/dev/kruhlmann/imgfloat/service/AssetCleanupService.java new file mode 100644 index 0000000..23af56a --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/service/AssetCleanupService.java @@ -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 referencedIds = assetRepository.findAll() + .stream() + .map(Asset::getId) + .collect(Collectors.toSet()); + + assetStorageService.deleteOrphanedAssets(referencedIds); + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/AssetStorageService.java b/src/main/java/dev/kruhlmann/imgfloat/service/AssetStorageService.java index 4f90798..22b1906 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/AssetStorageService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/AssetStorageService.java @@ -12,6 +12,8 @@ import java.nio.file.*; import java.util.Locale; import java.util.Map; import java.util.Optional; +import java.util.Set; +import java.util.stream.Stream; @Service public class AssetStorageService { @@ -137,17 +139,36 @@ public class AssetStorageService { } } - public void deletePreviewFile(String relativePath) { - if (relativePath == null || relativePath.isBlank()) return; + public void deleteOrphanedAssets(Set referencedAssetIds) { + 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 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 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) { if (value == null) throw new IllegalArgumentException("Broadcaster is null"); diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/AuthorizationService.java b/src/main/java/dev/kruhlmann/imgfloat/service/AuthorizationService.java new file mode 100644 index 0000000..02472e1 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/service/AuthorizationService.java @@ -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); + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java index 91dcfc5..4bb1906 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java @@ -7,10 +7,12 @@ import dev.kruhlmann.imgfloat.model.Channel; import dev.kruhlmann.imgfloat.model.AssetView; import dev.kruhlmann.imgfloat.model.CanvasSettingsRequest; import dev.kruhlmann.imgfloat.model.PlaybackRequest; +import dev.kruhlmann.imgfloat.model.Settings; import dev.kruhlmann.imgfloat.model.TransformRequest; import dev.kruhlmann.imgfloat.model.VisibilityRequest; import dev.kruhlmann.imgfloat.repository.AssetRepository; 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.MediaDetectionService; import dev.kruhlmann.imgfloat.service.media.MediaOptimizationService; @@ -43,30 +45,19 @@ public class ChannelDirectoryService { private final AssetStorageService assetStorageService; private final MediaDetectionService mediaDetectionService; private final MediaOptimizationService mediaOptimizationService; + private final SettingsService settingsService; @Autowired private long uploadLimitBytes; - private double maxSpeed; - private double minAudioSpeed; - private double maxAudioSpeed; - private double minAudioPitch; - private double maxAudioPitch; - private double maxAudioVolume; - public ChannelDirectoryService( - ChannelRepository channelRepository, - AssetRepository assetRepository, - SimpMessagingTemplate messagingTemplate, - AssetStorageService assetStorageService, - MediaDetectionService mediaDetectionService, - MediaOptimizationService mediaOptimizationService, - @Value("${IMGFLOAT_MAX_SPEED}") double maxSpeed, - @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 + ChannelRepository channelRepository, + AssetRepository assetRepository, + SimpMessagingTemplate messagingTemplate, + AssetStorageService assetStorageService, + MediaDetectionService mediaDetectionService, + MediaOptimizationService mediaOptimizationService, + SettingsService settingsService ) { this.channelRepository = channelRepository; this.assetRepository = assetRepository; @@ -74,12 +65,7 @@ public class ChannelDirectoryService { this.assetStorageService = assetStorageService; this.mediaDetectionService = mediaDetectionService; this.mediaOptimizationService = mediaOptimizationService; - this.maxSpeed = maxSpeed; - this.minAudioSpeed = minAudioSpeed; - this.maxAudioSpeed = maxAudioSpeed; - this.minAudioPitch = minAudioPitch; - this.maxAudioPitch = maxAudioPitch; - this.maxAudioVolume = maxAudioVolume; + this.settingsService = settingsService; } @@ -255,20 +241,31 @@ public class ChannelDirectoryService { } private void validateTransform(TransformRequest req) { - if (req.getWidth() <= 0) throw new ResponseStatusException(BAD_REQUEST, "Width must be > 0"); - if (req.getHeight() <= 0) throw new ResponseStatusException(BAD_REQUEST, "Height must be > 0"); - if (req.getSpeed() != null && (req.getSpeed() < 0 || req.getSpeed() > maxSpeed)) - throw new ResponseStatusException(BAD_REQUEST, "Speed must be between 0 and " + maxSpeed); + Settings settings = settingsService.get(); + double maxSpeed = settings.getMaxAssetPlaybackSpeedFraction(); + double minSpeed = settings.getMinAssetPlaybackSpeedFraction(); + 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) throw new ResponseStatusException(BAD_REQUEST, "zIndex must be >= 1"); if (req.getAudioDelayMillis() != null && req.getAudioDelayMillis() < 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"); - 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"); - if (req.getAudioVolume() != null && (req.getAudioVolume() < 0 || req.getAudioVolume() > maxAudioVolume)) - throw new ResponseStatusException(BAD_REQUEST, "Audio volume out of range"); + if (req.getAudioVolume() != null && (req.getAudioVolume() < minVolume || req.getAudioVolume() > maxVolume)) + throw new ResponseStatusException(BAD_REQUEST, "Audio volume out of range [" + minVolume + " to " + maxVolume + "]"); } public Optional triggerPlayback(String broadcaster, String assetId, PlaybackRequest req) { @@ -314,22 +311,12 @@ public class ChannelDirectoryService { return assetRepository.findById(assetId).flatMap(assetStorageService::loadAssetFileSafely); } - public Optional getVisibleAssetContent(String assetId) { - return assetRepository.findById(assetId) - .filter(a -> !a.isHidden()) - .flatMap(assetStorageService::loadAssetFileSafely); - } - public Optional getAssetPreview(String assetId, boolean includeHidden) { return assetRepository.findById(assetId) .filter(a -> includeHidden || !a.isHidden()) .flatMap(assetStorageService::loadPreviewSafely); } - public boolean isBroadcaster(String broadcaster, String username) { - return broadcaster != null && broadcaster.equalsIgnoreCase(username); - } - public boolean isAdmin(String broadcaster, String username) { return channelRepository.findById(normalize(broadcaster)) .map(Channel::getAdmins) diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/SettingsService.java b/src/main/java/dev/kruhlmann/imgfloat/service/SettingsService.java new file mode 100644 index 0000000..dca4ce9 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/service/SettingsService.java @@ -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); + } + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/SystemAdministratorService.java b/src/main/java/dev/kruhlmann/imgfloat/service/SystemAdministratorService.java new file mode 100644 index 0000000..5494a20 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/service/SystemAdministratorService.java @@ -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); + } +} diff --git a/src/main/resources/static/js/admin.js b/src/main/resources/static/js/admin.js index 540e030..7aa3d2f 100644 --- a/src/main/resources/static/js/admin.js +++ b/src/main/resources/static/js/admin.js @@ -1,4 +1,3 @@ -let stompClient; const canvas = document.getElementById('admin-canvas'); const ctx = canvas.getContext('2d'); const overlay = document.getElementById('admin-overlay'); @@ -6,7 +5,6 @@ let canvasSettings = { width: 1920, height: 1080 }; canvas.width = canvasSettings.width; canvas.height = canvasSettings.height; const assets = new Map(); -let pendingUploads = []; const mediaCache = new Map(); const renderStates = new Map(); const animatedCache = new Map(); @@ -15,21 +13,13 @@ const pendingAudioUnlock = new Set(); const loopPlaybackState = new Map(); const previewCache = new Map(); const previewImageCache = new Map(); -let drawPending = false; -let layerOrder = []; -let selectedAssetId = null; -let interactionState = null; -let lastSizeInputChanged = null; +const pendingTransformSaves = new Map(); const HANDLE_SIZE = 10; const ROTATE_HANDLE_OFFSET = 32; -const MAX_VOLUME = adminInputRestrictions.MAX_AUDIO_VOLUME; -const VOLUME_SLIDER_MAX = adminInputRestrictions.MAX_AUDIO_VOLUME * 100; +const VOLUME_SLIDER_MAX = SETTINGS.maxAssetVolumeFraction * 100; const VOLUME_CURVE_STRENGTH = -0.6; -const pendingTransformSaves = new Map(); const KEYBOARD_NUDGE_STEP = 5; const KEYBOARD_NUDGE_FAST_STEP = 20; - - const controlsPanel = document.getElementById('asset-controls'); const widthInput = document.getElementById('asset-width'); const heightInput = document.getElementById('asset-height'); @@ -68,6 +58,14 @@ const aspectLockState = new Map(); const commitSizeChange = debounce(() => applyTransformFromInputs(), 180); 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) => { window.addEventListener(eventName, () => { if (!pendingAudioUnlock.size) return; @@ -294,16 +292,16 @@ function clamp(value, min, max) { function sliderToVolume(sliderValue) { const normalized = clamp(sliderValue, 0, VOLUME_SLIDER_MAX) / VOLUME_SLIDER_MAX; 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) { - 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 high = VOLUME_SLIDER_MAX; for (let i = 0; i < 24; i += 1) { const mid = (low + high) / 2; - const midNormalized = sliderToVolume(mid) / MAX_VOLUME; + const midNormalized = sliderToVolume(mid) / SETTINGS.maxAssetVolumeFraction; if (midNormalized < target) { low = mid; } else { @@ -983,7 +981,7 @@ function applyAudioSettings(controller, asset, resetPosition = false) { const speed = Math.max(0.25, asset.audioSpeed || 1); const pitch = Math.max(0.5, asset.audioPitch || 1); 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; if (resetPosition) { controller.element.currentTime = 0; @@ -1058,7 +1056,7 @@ function ensureMedia(asset) { element.crossOrigin = 'anonymous'; if (isVideoElement(element)) { 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.volume = Math.min(volume, 1); element.playsInline = true; @@ -1164,7 +1162,7 @@ function applyMediaSettings(element, asset) { if (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.volume = Math.min(volume, 1); if (nextSpeed === 0) { @@ -2023,8 +2021,8 @@ function uploadAsset(file = null) { showToast('Choose an image, GIF, video, or audio file to upload.', 'info'); return; } - if (selectedFile.size > adminInputRestrictions.UPLOAD_MAX_BYTES) { - showToast(`File is too large. Maximum upload size is ${adminInputRestrictions.UPLOAD_MAX_BYTES / 1024 / 1024} MB.`, 'error'); + if (selectedFile.size > UPLOAD_LIMIT_BYTES) { + showToast(`File is too large. Maximum upload size is ${UPLOAD_MAX_BYTES / 1024 / 1024} MB.`, 'error'); return; } diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html index ae4aa27..71dbb32 100644 --- a/src/main/resources/templates/admin.html +++ b/src/main/resources/templates/admin.html @@ -207,15 +207,8 @@