refactor: eliminate code duplication across AssetPatch, OAuthTokenCipher, upload guards, SecurityConfig, and normalize helpers

- Add AssetPatch.forOrder() and simplify AssetPatch.fromVisibility() factory
- Replace null-chain AssetPatch constructors in ChannelDirectoryService with forOrder()
- Extract OAuthTokenCipher.buildFromKeys() to deduplicate two fromEnvironment() overloads
- Replace inline upload-size guards in createAsset/createScriptAttachment with enforceUploadLimit()
- Make script attachment content endpoint public for all channels (not just hard-coded 'gasolinebased')
- Extract StringNormalizer.toLowerCaseRoot() utility and use it in ChannelDirectoryService/ChannelSettingsService
This commit is contained in:
2026-04-21 16:12:31 +02:00
parent f62d01c30a
commit b6ca67d96c
6 changed files with 44 additions and 100 deletions
@@ -32,46 +32,28 @@ public class OAuthTokenCipher {
}
public static OAuthTokenCipher fromEnvironment() {
String base64Key = System.getenv(KEY_ENV);
if (base64Key == null || base64Key.isBlank()) {
throw new IllegalStateException(KEY_ENV + " is required to encrypt OAuth tokens");
}
SecretKey primaryKey = decodeKey(base64Key, KEY_ENV);
List<SecretKey> keys = new ArrayList<>();
keys.add(primaryKey);
String previousKeys = System.getenv(PREVIOUS_KEYS_ENV);
if (previousKeys != null && !previousKeys.isBlank()) {
for (String value : previousKeys.split(",")) {
String trimmed = value.trim();
if (!trimmed.isEmpty()) {
keys.add(decodeKey(trimmed, PREVIOUS_KEYS_ENV));
}
}
}
return new OAuthTokenCipher(primaryKey, keys);
return buildFromKeys(System.getenv(KEY_ENV), System.getenv(PREVIOUS_KEYS_ENV));
}
public static OAuthTokenCipher fromEnvironment(Environment environment) {
String base64Key = environment.getProperty(KEY_ENV);
return buildFromKeys(environment.getProperty(KEY_ENV), environment.getProperty(PREVIOUS_KEYS_ENV));
}
private static OAuthTokenCipher buildFromKeys(String base64Key, String previousKeysRaw) {
if (base64Key == null || base64Key.isBlank()) {
throw new IllegalStateException(KEY_ENV + " is required to encrypt OAuth tokens");
}
SecretKey primaryKey = decodeKey(base64Key, KEY_ENV);
List<SecretKey> keys = new ArrayList<>();
keys.add(primaryKey);
String previousKeys = environment.getProperty(PREVIOUS_KEYS_ENV);
if (previousKeys != null && !previousKeys.isBlank()) {
for (String value : previousKeys.split(",")) {
if (previousKeysRaw != null && !previousKeysRaw.isBlank()) {
for (String value : previousKeysRaw.split(",")) {
String trimmed = value.trim();
if (!trimmed.isEmpty()) {
keys.add(decodeKey(trimmed, PREVIOUS_KEYS_ENV));
}
}
}
return new OAuthTokenCipher(primaryKey, keys);
}
@@ -76,7 +76,7 @@ public class SecurityConfig {
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/channels/*/canvas")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/channels/gasolinebased/script-assets/*/attachments/*/content")
.requestMatchers(HttpMethod.GET, "/api/channels/*/script-assets/*/attachments/*/content")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/channels/*/assets/*/content")
.permitAll()
@@ -76,24 +76,15 @@ public record AssetPatch(
);
}
/**
* Produces a patch carrying only a display-order update.
*/
public static AssetPatch forOrder(String assetId, int order) {
return new AssetPatch(assetId, null, null, null, null, null, null, null, order, null, null, null, null, null, null);
}
public static AssetPatch fromVisibility(String assetId, boolean hidden) {
return new AssetPatch(
assetId,
null,
null,
null,
null,
null,
null,
null,
null,
hidden,
null,
null,
null,
null,
null
);
return new AssetPatch(assetId, null, null, null, null, null, null, null, null, hidden, null, null, null, null, null);
}
private static Double changed(double before, double after) {
@@ -37,6 +37,7 @@ import dev.kruhlmann.imgfloat.service.media.MediaOptimizationService;
import dev.kruhlmann.imgfloat.service.media.OptimizedAsset;
import dev.kruhlmann.imgfloat.service.media.MediaTypeRegistry;
import dev.kruhlmann.imgfloat.util.AllowedDomainNormalizer;
import dev.kruhlmann.imgfloat.util.StringNormalizer;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -198,16 +199,7 @@ public class ChannelDirectoryService {
@Transactional(rollbackFor = IOException.class)
public Optional<AssetView> createAsset(String broadcaster, MultipartFile file, String actor) throws IOException {
long fileSize = file.getSize();
if (fileSize > uploadLimitBytes) {
throw new ResponseStatusException(
PAYLOAD_TOO_LARGE,
String.format(
"Uploaded file is too large (%d bytes). Maximum allowed is %d bytes.",
fileSize,
uploadLimitBytes
)
);
}
enforceUploadLimit(fileSize);
Channel channel = getOrCreateChannel(broadcaster);
byte[] bytes = file.getBytes();
String mediaType = mediaDetectionService
@@ -746,23 +738,7 @@ public class ChannelDirectoryService {
EnumSet.of(AssetType.SCRIPT)
);
if (!Objects.equals(beforeOrder, asset.getDisplayOrder())) {
AssetPatch patch = new AssetPatch(
asset.getId(),
null,
null,
null,
null,
null,
null,
null,
asset.getDisplayOrder(),
null,
null,
null,
null,
null,
null
);
AssetPatch patch = AssetPatch.forOrder(asset.getId(), asset.getDisplayOrder());
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, patch));
auditLogService.recordEntry(
asset.getBroadcaster(),
@@ -1268,17 +1244,7 @@ public class ChannelDirectoryService {
MultipartFile file,
String actor
) throws IOException {
long fileSize = file.getSize();
if (fileSize > uploadLimitBytes) {
throw new ResponseStatusException(
PAYLOAD_TOO_LARGE,
String.format(
"Uploaded file is too large (%d bytes). Maximum allowed is %d bytes.",
fileSize,
uploadLimitBytes
)
);
}
enforceUploadLimit(file.getSize());
Asset asset = requireScriptAssetForBroadcaster(broadcaster, scriptAssetId);
byte[] bytes = file.getBytes();
@@ -1434,7 +1400,7 @@ public class ChannelDirectoryService {
}
private String normalize(String value) {
return value == null ? null : value.toLowerCase(Locale.ROOT);
return StringNormalizer.toLowerCaseRoot(value);
}
private boolean isCodeMediaType(String mediaType) {
@@ -1640,23 +1606,7 @@ public class ChannelDirectoryService {
.filter((asset) -> asset.getDisplayOrder() != null)
.filter((asset) -> !asset.getId().equals(targetAssetId))
.forEach((asset) -> {
AssetPatch patch = new AssetPatch(
asset.getId(),
null,
null,
null,
null,
null,
null,
null,
asset.getDisplayOrder(),
null,
null,
null,
null,
null,
null
);
AssetPatch patch = AssetPatch.forOrder(asset.getId(), asset.getDisplayOrder());
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, patch));
});
}
@@ -16,6 +16,8 @@ import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import dev.kruhlmann.imgfloat.util.StringNormalizer;
/**
* Manages per-channel canvas and script-overlay settings.
* Extracted from {@link ChannelDirectoryService} to give it a focused responsibility.
@@ -198,6 +200,6 @@ public class ChannelSettingsService {
}
private String normalize(String value) {
return value == null ? null : value.toLowerCase(Locale.ROOT);
return StringNormalizer.toLowerCaseRoot(value);
}
}
@@ -0,0 +1,19 @@
package dev.kruhlmann.imgfloat.util;
import java.util.Locale;
/**
* Shared string normalization utilities. Centralises {@link Locale#ROOT} lowercase
* conversions so that individual classes do not duplicate the same one-liner.
*/
public final class StringNormalizer {
private StringNormalizer() {}
/**
* Returns {@code value.toLowerCase(Locale.ROOT)}, or {@code null} if {@code value} is {@code null}.
*/
public static String toLowerCaseRoot(String value) {
return value == null ? null : value.toLowerCase(Locale.ROOT);
}
}