diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelAdminApiController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelAdminApiController.java new file mode 100644 index 0000000..52db81c --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelAdminApiController.java @@ -0,0 +1,188 @@ +package dev.kruhlmann.imgfloat.controller; + +import dev.kruhlmann.imgfloat.model.OauthSessionUser; +import dev.kruhlmann.imgfloat.model.api.response.TwitchUserProfile; +import dev.kruhlmann.imgfloat.model.api.request.AdminRequest; +import dev.kruhlmann.imgfloat.service.AuthorizationService; +import dev.kruhlmann.imgfloat.service.ChannelDirectoryService; +import dev.kruhlmann.imgfloat.service.TwitchUserLookupService; +import dev.kruhlmann.imgfloat.util.LogSanitizer; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import java.util.Collection; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.lang.Nullable; +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.registration.ClientRegistration; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; +import org.springframework.security.oauth2.core.AbstractOAuth2Token; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Manages channel admin membership (add, remove, list) and Twitch moderator suggestions. + * Extracted from {@link ChannelApiController} to reduce its surface area. + */ +@RestController +@RequestMapping("/api/channels/{broadcaster}/admins") +@SecurityRequirement(name = "twitchOAuth") +public class ChannelAdminApiController { + + private static final Logger LOG = LoggerFactory.getLogger(ChannelAdminApiController.class); + + private final ChannelDirectoryService channelDirectoryService; + private final TwitchUserLookupService twitchUserLookupService; + private final AuthorizationService authorizationService; + private final OAuth2AuthorizedClientService authorizedClientService; + private final OAuth2AuthorizedClientRepository authorizedClientRepository; + + public ChannelAdminApiController( + ChannelDirectoryService channelDirectoryService, + TwitchUserLookupService twitchUserLookupService, + AuthorizationService authorizationService, + OAuth2AuthorizedClientService authorizedClientService, + OAuth2AuthorizedClientRepository authorizedClientRepository + ) { + this.channelDirectoryService = channelDirectoryService; + this.twitchUserLookupService = twitchUserLookupService; + this.authorizationService = authorizationService; + this.authorizedClientService = authorizedClientService; + this.authorizedClientRepository = authorizedClientRepository; + } + + @PostMapping + public ResponseEntity addAdmin( + @PathVariable("broadcaster") String broadcaster, + @Valid @RequestBody AdminRequest request, + OAuth2AuthenticationToken oauthToken + ) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + String logBroadcaster = LogSanitizer.sanitize(broadcaster); + String logSessionUsername = LogSanitizer.sanitize(sessionUsername); + String logRequestUsername = LogSanitizer.sanitize(request.username()); + authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername); + LOG.info("User {} adding admin {} to {}", logSessionUsername, logRequestUsername, logBroadcaster); + boolean added = channelDirectoryService.addAdmin(broadcaster, request.username(), sessionUsername); + if (!added) { + LOG.info("User {} already admin for {} or could not be added", logRequestUsername, logBroadcaster); + } + return ResponseEntity.ok(added); + } + + @GetMapping + public Collection listAdmins( + @PathVariable("broadcaster") String broadcaster, + OAuth2AuthenticationToken oauthToken, + HttpServletRequest request + ) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + String logBroadcaster = LogSanitizer.sanitize(broadcaster); + String logSessionUsername = LogSanitizer.sanitize(sessionUsername); + authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername); + LOG.debug("Listing admins for {} by {}", logBroadcaster, logSessionUsername); + var channel = channelDirectoryService.getOrCreateChannel(broadcaster); + List admins = channel.getAdmins().stream().sorted(Comparator.naturalOrder()).toList(); + OAuth2AuthorizedClient authorizedClient = resolveAuthorizedClient(oauthToken, request); + String accessToken = Optional.ofNullable(authorizedClient) + .map(OAuth2AuthorizedClient::getAccessToken) + .map(AbstractOAuth2Token::getTokenValue) + .orElse(null); + String clientId = Optional.ofNullable(authorizedClient) + .map(OAuth2AuthorizedClient::getClientRegistration) + .map(ClientRegistration::getClientId) + .orElse(null); + return twitchUserLookupService.fetchProfiles(admins, accessToken, clientId); + } + + @GetMapping("/suggestions") + public Collection listAdminSuggestions( + @PathVariable("broadcaster") String broadcaster, + OAuth2AuthenticationToken oauthToken, + HttpServletRequest request + ) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + String logBroadcaster = LogSanitizer.sanitize(broadcaster); + String logSessionUsername = LogSanitizer.sanitize(sessionUsername); + authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername); + LOG.debug("Listing admin suggestions for {} by {}", logBroadcaster, logSessionUsername); + var channel = channelDirectoryService.getOrCreateChannel(broadcaster); + OAuth2AuthorizedClient authorizedClient = resolveAuthorizedClient(oauthToken, request); + + if (authorizedClient == null) { + LOG.warn( + "No authorized Twitch client found for {} while fetching admin suggestions for {}", + logSessionUsername, + logBroadcaster + ); + return List.of(); + } + String accessToken = Optional.of(authorizedClient) + .map(OAuth2AuthorizedClient::getAccessToken) + .map(AbstractOAuth2Token::getTokenValue) + .orElse(null); + String clientId = Optional.of(authorizedClient) + .map(OAuth2AuthorizedClient::getClientRegistration) + .map(ClientRegistration::getClientId) + .orElse(null); + if (accessToken == null || accessToken.isBlank() || clientId == null || clientId.isBlank()) { + LOG.warn( + "Missing Twitch credentials for {} while fetching admin suggestions for {}", + logSessionUsername, + logBroadcaster + ); + return List.of(); + } + return twitchUserLookupService.fetchModerators(broadcaster, channel.getAdmins(), accessToken, clientId); + } + + @DeleteMapping("/{username}") + public ResponseEntity removeAdmin( + @PathVariable("broadcaster") String broadcaster, + @PathVariable("username") String username, + OAuth2AuthenticationToken oauthToken + ) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + String logBroadcaster = LogSanitizer.sanitize(broadcaster); + String logSessionUsername = LogSanitizer.sanitize(sessionUsername); + String logUsername = LogSanitizer.sanitize(username); + authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername); + LOG.info("User {} removing admin {} from {}", logSessionUsername, logUsername, logBroadcaster); + boolean removed = channelDirectoryService.removeAdmin(broadcaster, username, sessionUsername); + return ResponseEntity.ok(removed); + } + + private OAuth2AuthorizedClient resolveAuthorizedClient( + @Nullable OAuth2AuthenticationToken oauthToken, + HttpServletRequest request + ) { + if (oauthToken == null) { + LOG.error("Attempt to resolve authorized client without oauth token"); + return null; + } + OAuth2AuthorizedClient sessionClient = authorizedClientRepository.loadAuthorizedClient( + oauthToken.getAuthorizedClientRegistrationId(), + oauthToken, + request + ); + if (sessionClient != null) { + return sessionClient; + } + return authorizedClientService.loadAuthorizedClient( + oauthToken.getAuthorizedClientRegistrationId(), + oauthToken.getName() + ); + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java index b3609de..6760713 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java @@ -3,187 +3,66 @@ package dev.kruhlmann.imgfloat.controller; import static org.springframework.http.HttpStatus.BAD_REQUEST; import static org.springframework.http.HttpStatus.NOT_FOUND; -import dev.kruhlmann.imgfloat.model.api.request.AdminRequest; -import dev.kruhlmann.imgfloat.model.api.response.AssetView; +import dev.kruhlmann.imgfloat.model.api.request.AssetOrderRequest; import dev.kruhlmann.imgfloat.model.api.request.CanvasSettingsRequest; import dev.kruhlmann.imgfloat.model.api.request.ChannelScriptSettingsRequest; import dev.kruhlmann.imgfloat.model.api.request.CodeAssetRequest; -import dev.kruhlmann.imgfloat.model.OauthSessionUser; -import dev.kruhlmann.imgfloat.model.api.request.AssetOrderRequest; import dev.kruhlmann.imgfloat.model.api.request.PlaybackRequest; -import dev.kruhlmann.imgfloat.model.api.response.ScriptAssetAttachmentView; import dev.kruhlmann.imgfloat.model.api.request.TransformRequest; -import dev.kruhlmann.imgfloat.model.api.response.TwitchUserProfile; import dev.kruhlmann.imgfloat.model.api.request.VisibilityRequest; +import dev.kruhlmann.imgfloat.model.api.response.AssetView; +import dev.kruhlmann.imgfloat.model.api.response.ScriptAssetAttachmentView; +import dev.kruhlmann.imgfloat.model.OauthSessionUser; import dev.kruhlmann.imgfloat.service.AuthorizationService; import dev.kruhlmann.imgfloat.service.ChannelDirectoryService; import dev.kruhlmann.imgfloat.service.ChannelSettingsService; -import dev.kruhlmann.imgfloat.service.TwitchUserLookupService; import dev.kruhlmann.imgfloat.util.LogSanitizer; import io.swagger.v3.oas.annotations.security.SecurityRequirement; -import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import java.io.IOException; import java.util.Collection; -import java.util.Comparator; -import java.util.List; -import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.lang.Nullable; -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.registration.ClientRegistration; -import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; -import org.springframework.security.oauth2.core.AbstractOAuth2Token; 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.RequestPart; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; +/** + * Manages assets, canvas settings, and script attachments for a specific broadcaster channel. + * Admin management has been extracted to {@link ChannelAdminApiController}. + */ @RestController @RequestMapping("/api/channels/{broadcaster}") @SecurityRequirement(name = "twitchOAuth") public class ChannelApiController { - // TODO: Code smell Controller surface area is very large, suggesting too many endpoint responsibilities in one type. - private static final Logger LOG = LoggerFactory.getLogger(ChannelApiController.class); private final ChannelDirectoryService channelDirectoryService; private final ChannelSettingsService channelSettingsService; - private final OAuth2AuthorizedClientService authorizedClientService; - private final OAuth2AuthorizedClientRepository authorizedClientRepository; - private final TwitchUserLookupService twitchUserLookupService; private final AuthorizationService authorizationService; public ChannelApiController( ChannelDirectoryService channelDirectoryService, ChannelSettingsService channelSettingsService, - OAuth2AuthorizedClientService authorizedClientService, - OAuth2AuthorizedClientRepository authorizedClientRepository, - TwitchUserLookupService twitchUserLookupService, AuthorizationService authorizationService ) { this.channelDirectoryService = channelDirectoryService; this.channelSettingsService = channelSettingsService; - this.authorizedClientService = authorizedClientService; - this.authorizedClientRepository = authorizedClientRepository; - this.twitchUserLookupService = twitchUserLookupService; this.authorizationService = authorizationService; } - @PostMapping("/admins") - public ResponseEntity addAdmin( - @PathVariable("broadcaster") String broadcaster, - @Valid @RequestBody AdminRequest request, - OAuth2AuthenticationToken oauthToken - ) { - String sessionUsername = OauthSessionUser.from(oauthToken).login(); - String logBroadcaster = LogSanitizer.sanitize(broadcaster); - String logSessionUsername = LogSanitizer.sanitize(sessionUsername); - String logRequestUsername = LogSanitizer.sanitize(request.username()); - authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername); - LOG.info("User {} adding admin {} to {}", logSessionUsername, logRequestUsername, logBroadcaster); - boolean added = channelDirectoryService.addAdmin(broadcaster, request.username(), sessionUsername); - if (!added) { - LOG.info("User {} already admin for {} or could not be added", logRequestUsername, logBroadcaster); - } - return ResponseEntity.ok(added); - } - - @GetMapping("/admins") - public Collection listAdmins( - @PathVariable("broadcaster") String broadcaster, - OAuth2AuthenticationToken oauthToken, - HttpServletRequest request - ) { - String sessionUsername = OauthSessionUser.from(oauthToken).login(); - String logBroadcaster = LogSanitizer.sanitize(broadcaster); - String logSessionUsername = LogSanitizer.sanitize(sessionUsername); - authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername); - LOG.debug("Listing admins for {} by {}", logBroadcaster, logSessionUsername); - var channel = channelDirectoryService.getOrCreateChannel(broadcaster); - List admins = channel.getAdmins().stream().sorted(Comparator.naturalOrder()).toList(); - OAuth2AuthorizedClient authorizedClient = resolveAuthorizedClient(oauthToken, request); - String accessToken = Optional.ofNullable(authorizedClient) - .map(OAuth2AuthorizedClient::getAccessToken) - .map(AbstractOAuth2Token::getTokenValue) - .orElse(null); - String clientId = Optional.ofNullable(authorizedClient) - .map(OAuth2AuthorizedClient::getClientRegistration) - .map(ClientRegistration::getClientId) - .orElse(null); - return twitchUserLookupService.fetchProfiles(admins, accessToken, clientId); - } - - @GetMapping("/admins/suggestions") - public Collection listAdminSuggestions( - @PathVariable("broadcaster") String broadcaster, - OAuth2AuthenticationToken oauthToken, - HttpServletRequest request - ) { - String sessionUsername = OauthSessionUser.from(oauthToken).login(); - String logBroadcaster = LogSanitizer.sanitize(broadcaster); - String logSessionUsername = LogSanitizer.sanitize(sessionUsername); - authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername); - LOG.debug("Listing admin suggestions for {} by {}", logBroadcaster, logSessionUsername); - var channel = channelDirectoryService.getOrCreateChannel(broadcaster); - OAuth2AuthorizedClient authorizedClient = resolveAuthorizedClient(oauthToken, request); - - if (authorizedClient == null) { - LOG.warn( - "No authorized Twitch client found for {} while fetching admin suggestions for {}", - logSessionUsername, - logBroadcaster - ); - return List.of(); - } - String accessToken = Optional.of(authorizedClient) - .map(OAuth2AuthorizedClient::getAccessToken) - .map(AbstractOAuth2Token::getTokenValue) - .orElse(null); - String clientId = Optional.of(authorizedClient) - .map(OAuth2AuthorizedClient::getClientRegistration) - .map(ClientRegistration::getClientId) - .orElse(null); - if (accessToken == null || accessToken.isBlank() || clientId == null || clientId.isBlank()) { - LOG.warn( - "Missing Twitch credentials for {} while fetching admin suggestions for {}", - logSessionUsername, - logBroadcaster - ); - return List.of(); - } - return twitchUserLookupService.fetchModerators(broadcaster, channel.getAdmins(), accessToken, clientId); - } - - @DeleteMapping("/admins/{username}") - public ResponseEntity removeAdmin( - @PathVariable("broadcaster") String broadcaster, - @PathVariable("username") String username, - OAuth2AuthenticationToken oauthToken - ) { - String sessionUsername = OauthSessionUser.from(oauthToken).login(); - String logBroadcaster = LogSanitizer.sanitize(broadcaster); - String logSessionUsername = LogSanitizer.sanitize(sessionUsername); - String logUsername = LogSanitizer.sanitize(username); - authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername); - LOG.info("User {} removing admin {} from {}", logSessionUsername, logUsername, logBroadcaster); - boolean removed = channelDirectoryService.removeAdmin(broadcaster, username, sessionUsername); - return ResponseEntity.ok(removed); - } - @GetMapping("/assets") public Collection listAssets(@PathVariable("broadcaster") String broadcaster) { return channelDirectoryService.getAssetsForAdmin(broadcaster); @@ -505,15 +384,6 @@ public class ChannelApiController { .orElseThrow(this::createAsset404); } - private String contentDispositionFor(String mediaType) { - if ( - dev.kruhlmann.imgfloat.service.media.MediaDetectionService.isInlineDisplayType(mediaType) - ) { - return "inline"; - } - return "attachment"; - } - @DeleteMapping("/assets/{assetId}") public ResponseEntity delete( @PathVariable("broadcaster") String broadcaster, @@ -637,29 +507,14 @@ public class ChannelApiController { return ResponseEntity.ok().build(); } + private String contentDispositionFor(String mediaType) { + if (dev.kruhlmann.imgfloat.service.media.MediaDetectionService.isInlineDisplayType(mediaType)) { + return "inline"; + } + return "attachment"; + } + private ResponseStatusException createAsset404() { return new ResponseStatusException(NOT_FOUND, "Asset not found"); } - - private OAuth2AuthorizedClient resolveAuthorizedClient( - @Nullable OAuth2AuthenticationToken oauthToken, - HttpServletRequest request - ) { - if (oauthToken == null) { - LOG.error("Attempt to resolve authorized client without oauth token"); - return null; - } - OAuth2AuthorizedClient sessionClient = authorizedClientRepository.loadAuthorizedClient( - oauthToken.getAuthorizedClientRegistrationId(), - oauthToken, - request - ); - if (sessionClient != null) { - return sessionClient; - } - return authorizedClientService.loadAuthorizedClient( - oauthToken.getAuthorizedClientRegistrationId(), - oauthToken.getName() - ); - } } diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java index c98aa1b..7c540aa 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java @@ -957,8 +957,8 @@ public class ChannelDirectoryService { } List ordered = new ArrayList<>(bucket); ordered.sort((a, b) -> { - int orderA = desiredOrder.getOrDefault(a.getId(), a.getDisplayOrder() != null ? a.getDisplayOrder() : bucket.size() - originalIndex.getOrDefault(a.getId(), bucket.size())); - int orderB = desiredOrder.getOrDefault(b.getId(), b.getDisplayOrder() != null ? b.getDisplayOrder() : bucket.size() - originalIndex.getOrDefault(b.getId(), bucket.size())); + int orderA = resolveOrderForSort(a, desiredOrder, originalIndex, bucket.size()); + int orderB = resolveOrderForSort(b, desiredOrder, originalIndex, bucket.size()); int cmp = Integer.compare(orderB, orderA); if (cmp != 0) { return cmp; @@ -1561,6 +1561,27 @@ public class ChannelDirectoryService { return value == null ? Integer.MIN_VALUE : value; } + /** + * Resolves the effective sort key for an asset during a bulk reorder operation. + * Assets with an explicit desired order use that value; others fall back to their + * current display order, or their inverse original-index position as a last resort. + */ + private int resolveOrderForSort( + Asset asset, + Map desiredOrder, + Map originalIndex, + int bucketSize + ) { + String id = asset.getId(); + if (desiredOrder.containsKey(id)) { + return desiredOrder.get(id); + } + if (asset.getDisplayOrder() != null) { + return asset.getDisplayOrder(); + } + return bucketSize - originalIndex.getOrDefault(id, bucketSize); + } + private List updateDisplayOrder(String broadcaster, Asset target, int desiredOrder, Set types) { if (target == null || types == null || types.isEmpty()) { return List.of();