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() { public static OAuthTokenCipher fromEnvironment() {
String base64Key = System.getenv(KEY_ENV); return buildFromKeys(System.getenv(KEY_ENV), System.getenv(PREVIOUS_KEYS_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);
} }
public static OAuthTokenCipher fromEnvironment(Environment environment) { 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()) { if (base64Key == null || base64Key.isBlank()) {
throw new IllegalStateException(KEY_ENV + " is required to encrypt OAuth tokens"); throw new IllegalStateException(KEY_ENV + " is required to encrypt OAuth tokens");
} }
SecretKey primaryKey = decodeKey(base64Key, KEY_ENV); SecretKey primaryKey = decodeKey(base64Key, KEY_ENV);
List<SecretKey> keys = new ArrayList<>(); List<SecretKey> keys = new ArrayList<>();
keys.add(primaryKey); keys.add(primaryKey);
if (previousKeysRaw != null && !previousKeysRaw.isBlank()) {
String previousKeys = environment.getProperty(PREVIOUS_KEYS_ENV); for (String value : previousKeysRaw.split(",")) {
if (previousKeys != null && !previousKeys.isBlank()) {
for (String value : previousKeys.split(",")) {
String trimmed = value.trim(); String trimmed = value.trim();
if (!trimmed.isEmpty()) { if (!trimmed.isEmpty()) {
keys.add(decodeKey(trimmed, PREVIOUS_KEYS_ENV)); keys.add(decodeKey(trimmed, PREVIOUS_KEYS_ENV));
} }
} }
} }
return new OAuthTokenCipher(primaryKey, keys); return new OAuthTokenCipher(primaryKey, keys);
} }
@@ -76,7 +76,7 @@ public class SecurityConfig {
.permitAll() .permitAll()
.requestMatchers(HttpMethod.GET, "/api/channels/*/canvas") .requestMatchers(HttpMethod.GET, "/api/channels/*/canvas")
.permitAll() .permitAll()
.requestMatchers(HttpMethod.GET, "/api/channels/gasolinebased/script-assets/*/attachments/*/content") .requestMatchers(HttpMethod.GET, "/api/channels/*/script-assets/*/attachments/*/content")
.permitAll() .permitAll()
.requestMatchers(HttpMethod.GET, "/api/channels/*/assets/*/content") .requestMatchers(HttpMethod.GET, "/api/channels/*/assets/*/content")
.permitAll() .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) { public static AssetPatch fromVisibility(String assetId, boolean hidden) {
return new AssetPatch( return new AssetPatch(assetId, null, null, null, null, null, null, null, null, hidden, null, null, null, null, null);
assetId,
null,
null,
null,
null,
null,
null,
null,
null,
hidden,
null,
null,
null,
null,
null
);
} }
private static Double changed(double before, double after) { 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.OptimizedAsset;
import dev.kruhlmann.imgfloat.service.media.MediaTypeRegistry; import dev.kruhlmann.imgfloat.service.media.MediaTypeRegistry;
import dev.kruhlmann.imgfloat.util.AllowedDomainNormalizer; import dev.kruhlmann.imgfloat.util.AllowedDomainNormalizer;
import dev.kruhlmann.imgfloat.util.StringNormalizer;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
@@ -198,16 +199,7 @@ public class ChannelDirectoryService {
@Transactional(rollbackFor = IOException.class) @Transactional(rollbackFor = IOException.class)
public Optional<AssetView> createAsset(String broadcaster, MultipartFile file, String actor) throws IOException { public Optional<AssetView> createAsset(String broadcaster, MultipartFile file, String actor) throws IOException {
long fileSize = file.getSize(); long fileSize = file.getSize();
if (fileSize > uploadLimitBytes) { enforceUploadLimit(fileSize);
throw new ResponseStatusException(
PAYLOAD_TOO_LARGE,
String.format(
"Uploaded file is too large (%d bytes). Maximum allowed is %d bytes.",
fileSize,
uploadLimitBytes
)
);
}
Channel channel = getOrCreateChannel(broadcaster); Channel channel = getOrCreateChannel(broadcaster);
byte[] bytes = file.getBytes(); byte[] bytes = file.getBytes();
String mediaType = mediaDetectionService String mediaType = mediaDetectionService
@@ -746,23 +738,7 @@ public class ChannelDirectoryService {
EnumSet.of(AssetType.SCRIPT) EnumSet.of(AssetType.SCRIPT)
); );
if (!Objects.equals(beforeOrder, asset.getDisplayOrder())) { if (!Objects.equals(beforeOrder, asset.getDisplayOrder())) {
AssetPatch patch = new AssetPatch( AssetPatch patch = AssetPatch.forOrder(asset.getId(), asset.getDisplayOrder());
asset.getId(),
null,
null,
null,
null,
null,
null,
null,
asset.getDisplayOrder(),
null,
null,
null,
null,
null,
null
);
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, patch)); messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, patch));
auditLogService.recordEntry( auditLogService.recordEntry(
asset.getBroadcaster(), asset.getBroadcaster(),
@@ -1268,17 +1244,7 @@ public class ChannelDirectoryService {
MultipartFile file, MultipartFile file,
String actor String actor
) throws IOException { ) throws IOException {
long fileSize = file.getSize(); enforceUploadLimit(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
)
);
}
Asset asset = requireScriptAssetForBroadcaster(broadcaster, scriptAssetId); Asset asset = requireScriptAssetForBroadcaster(broadcaster, scriptAssetId);
byte[] bytes = file.getBytes(); byte[] bytes = file.getBytes();
@@ -1434,7 +1400,7 @@ public class ChannelDirectoryService {
} }
private String normalize(String value) { private String normalize(String value) {
return value == null ? null : value.toLowerCase(Locale.ROOT); return StringNormalizer.toLowerCaseRoot(value);
} }
private boolean isCodeMediaType(String mediaType) { private boolean isCodeMediaType(String mediaType) {
@@ -1640,23 +1606,7 @@ public class ChannelDirectoryService {
.filter((asset) -> asset.getDisplayOrder() != null) .filter((asset) -> asset.getDisplayOrder() != null)
.filter((asset) -> !asset.getId().equals(targetAssetId)) .filter((asset) -> !asset.getId().equals(targetAssetId))
.forEach((asset) -> { .forEach((asset) -> {
AssetPatch patch = new AssetPatch( AssetPatch patch = AssetPatch.forOrder(asset.getId(), asset.getDisplayOrder());
asset.getId(),
null,
null,
null,
null,
null,
null,
null,
asset.getDisplayOrder(),
null,
null,
null,
null,
null,
null
);
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, patch)); 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.stereotype.Service;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import dev.kruhlmann.imgfloat.util.StringNormalizer;
/** /**
* Manages per-channel canvas and script-overlay settings. * Manages per-channel canvas and script-overlay settings.
* Extracted from {@link ChannelDirectoryService} to give it a focused responsibility. * Extracted from {@link ChannelDirectoryService} to give it a focused responsibility.
@@ -198,6 +200,6 @@ public class ChannelSettingsService {
} }
private String normalize(String value) { 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);
}
}