mirror of
https://github.com/imgfloat/server.git
synced 2026-05-08 10:19:35 +00:00
Extract AllowedDomainNormalizer utility; remove @Autowired, dead constants, and inline VISUAL_ASSET_TYPES duplication
This commit is contained in:
@@ -39,8 +39,8 @@ import dev.kruhlmann.imgfloat.service.media.MediaDetectionService;
|
|||||||
import dev.kruhlmann.imgfloat.service.media.MediaOptimizationService;
|
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 java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.URI;
|
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
@@ -51,7 +51,6 @@ import java.util.concurrent.atomic.AtomicReference;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.dao.DataAccessException;
|
import org.springframework.dao.DataAccessException;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -59,14 +58,11 @@ import org.springframework.web.multipart.MultipartFile;
|
|||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
// TODO: Code smell God class; this service mixes admin management, asset CRUD, media processing, websocket publishing, and marketplace concerns.
|
|
||||||
public class ChannelDirectoryService {
|
public class ChannelDirectoryService {
|
||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(ChannelDirectoryService.class);
|
private static final Logger logger = LoggerFactory.getLogger(ChannelDirectoryService.class);
|
||||||
private static final Pattern SAFE_FILENAME = Pattern.compile("[^a-zA-Z0-9._ -]");
|
private static final Pattern SAFE_FILENAME = Pattern.compile("[^a-zA-Z0-9._ -]");
|
||||||
private static final String DEFAULT_CODE_MEDIA_TYPE = "application/javascript";
|
private static final String DEFAULT_CODE_MEDIA_TYPE = "application/javascript";
|
||||||
private static final int MAX_ALLOWED_SCRIPT_DOMAINS = 32;
|
|
||||||
private static final Pattern ALLOWED_DOMAIN_PATTERN = Pattern.compile("^[a-z0-9.-]+(?::[0-9]{1,5})?$");
|
|
||||||
private static final EnumSet<AssetType> VISUAL_ASSET_TYPES = EnumSet.of(
|
private static final EnumSet<AssetType> VISUAL_ASSET_TYPES = EnumSet.of(
|
||||||
AssetType.IMAGE,
|
AssetType.IMAGE,
|
||||||
AssetType.VIDEO,
|
AssetType.VIDEO,
|
||||||
@@ -74,7 +70,6 @@ public class ChannelDirectoryService {
|
|||||||
AssetType.OTHER
|
AssetType.OTHER
|
||||||
);
|
);
|
||||||
|
|
||||||
// TODO: Code smell Constructor has too many dependencies, indicating high coupling and too many responsibilities.
|
|
||||||
private final ChannelRepository channelRepository;
|
private final ChannelRepository channelRepository;
|
||||||
private final AssetRepository assetRepository;
|
private final AssetRepository assetRepository;
|
||||||
private final VisualAssetRepository visualAssetRepository;
|
private final VisualAssetRepository visualAssetRepository;
|
||||||
@@ -92,7 +87,6 @@ public class ChannelDirectoryService {
|
|||||||
private final MarketplaceScriptSeedLoader marketplaceScriptSeedLoader;
|
private final MarketplaceScriptSeedLoader marketplaceScriptSeedLoader;
|
||||||
private final AuditLogService auditLogService;
|
private final AuditLogService auditLogService;
|
||||||
|
|
||||||
@Autowired
|
|
||||||
public ChannelDirectoryService(
|
public ChannelDirectoryService(
|
||||||
ChannelRepository channelRepository,
|
ChannelRepository channelRepository,
|
||||||
AssetRepository assetRepository,
|
AssetRepository assetRepository,
|
||||||
@@ -187,13 +181,7 @@ public class ChannelDirectoryService {
|
|||||||
List<Asset> assets = assetRepository.findByBroadcaster(normalized);
|
List<Asset> assets = assetRepository.findByBroadcaster(normalized);
|
||||||
List<String> visualIds = assets
|
List<String> visualIds = assets
|
||||||
.stream()
|
.stream()
|
||||||
.filter(
|
.filter((asset) -> VISUAL_ASSET_TYPES.contains(asset.getAssetType()))
|
||||||
(asset) ->
|
|
||||||
asset.getAssetType() == AssetType.IMAGE ||
|
|
||||||
asset.getAssetType() == AssetType.VIDEO ||
|
|
||||||
asset.getAssetType() == AssetType.MODEL ||
|
|
||||||
asset.getAssetType() == AssetType.OTHER
|
|
||||||
)
|
|
||||||
.map(Asset::getId)
|
.map(Asset::getId)
|
||||||
.toList();
|
.toList();
|
||||||
Map<String, Asset> assetById = assets.stream().collect(Collectors.toMap(Asset::getId, (asset) -> asset));
|
Map<String, Asset> assetById = assets.stream().collect(Collectors.toMap(Asset::getId, (asset) -> asset));
|
||||||
@@ -1851,58 +1839,11 @@ public class ChannelDirectoryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private List<String> normalizeAllowedDomains(List<String> requestedDomains) {
|
private List<String> normalizeAllowedDomains(List<String> requestedDomains) {
|
||||||
if (requestedDomains == null || requestedDomains.isEmpty()) {
|
return AllowedDomainNormalizer.normalize(requestedDomains);
|
||||||
return List.of();
|
|
||||||
}
|
|
||||||
List<String> normalized = new ArrayList<>();
|
|
||||||
for (String raw : requestedDomains) {
|
|
||||||
if (raw == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
String candidate = raw.trim();
|
|
||||||
if (candidate.isEmpty()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
String withScheme = candidate.contains("://") ? candidate : "https://" + candidate;
|
|
||||||
URI uri;
|
|
||||||
try {
|
|
||||||
uri = URI.create(withScheme);
|
|
||||||
} catch (IllegalArgumentException ex) {
|
|
||||||
throw new ResponseStatusException(BAD_REQUEST, "Invalid allowed domain: " + candidate, ex);
|
|
||||||
}
|
|
||||||
String host = uri.getHost();
|
|
||||||
if (host == null || host.isBlank()) {
|
|
||||||
throw new ResponseStatusException(BAD_REQUEST, "Invalid allowed domain: " + candidate);
|
|
||||||
}
|
|
||||||
String domain = host.toLowerCase(Locale.ROOT);
|
|
||||||
int port = uri.getPort();
|
|
||||||
if (port > 0) {
|
|
||||||
domain = domain + ":" + port;
|
|
||||||
}
|
|
||||||
if (!ALLOWED_DOMAIN_PATTERN.matcher(domain).matches()) {
|
|
||||||
throw new ResponseStatusException(BAD_REQUEST, "Invalid allowed domain: " + candidate);
|
|
||||||
}
|
|
||||||
if (normalized.contains(domain)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (normalized.size() >= MAX_ALLOWED_SCRIPT_DOMAINS) {
|
|
||||||
throw new ResponseStatusException(
|
|
||||||
BAD_REQUEST,
|
|
||||||
"A maximum of 32 allowed domains are supported per script asset"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
normalized.add(domain);
|
|
||||||
}
|
|
||||||
return new ArrayList<>(normalized);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<String> normalizeAllowedDomainsLenient(List<String> requestedDomains) {
|
private List<String> normalizeAllowedDomainsLenient(List<String> requestedDomains) {
|
||||||
try {
|
return AllowedDomainNormalizer.normalizeLenient(requestedDomains);
|
||||||
return normalizeAllowedDomains(requestedDomains);
|
|
||||||
} catch (ResponseStatusException ex) {
|
|
||||||
logger.warn("Ignoring invalid allowed domains: {}", ex.getReason());
|
|
||||||
return List.of();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void removeScriptAssetFileIfOrphaned(String fileId) {
|
private void removeScriptAssetFileIfOrphaned(String fileId) {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ package dev.kruhlmann.imgfloat.service;
|
|||||||
|
|
||||||
import dev.kruhlmann.imgfloat.model.api.response.ScriptMarketplaceEntry;
|
import dev.kruhlmann.imgfloat.model.api.response.ScriptMarketplaceEntry;
|
||||||
import dev.kruhlmann.imgfloat.service.media.AssetContent;
|
import dev.kruhlmann.imgfloat.service.media.AssetContent;
|
||||||
|
import dev.kruhlmann.imgfloat.util.AllowedDomainNormalizer;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.URI;
|
|
||||||
import java.nio.file.DirectoryStream;
|
import java.nio.file.DirectoryStream;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
@@ -289,43 +289,7 @@ public class MarketplaceScriptSeedLoader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private List<String> normalizeAllowedDomains(List<String> requestedDomains) {
|
private List<String> normalizeAllowedDomains(List<String> requestedDomains) {
|
||||||
if (requestedDomains == null || requestedDomains.isEmpty()) {
|
return AllowedDomainNormalizer.normalizeLenient(requestedDomains);
|
||||||
return List.of();
|
|
||||||
}
|
|
||||||
List<String> normalized = new ArrayList<>();
|
|
||||||
for (String raw : requestedDomains) {
|
|
||||||
if (raw == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
String candidate = raw.trim();
|
|
||||||
if (candidate.isEmpty()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
String withScheme = candidate.contains("://") ? candidate : "https://" + candidate;
|
|
||||||
try {
|
|
||||||
URI uri = URI.create(withScheme);
|
|
||||||
String host = uri.getHost();
|
|
||||||
if (host == null || host.isBlank()) {
|
|
||||||
logger.warn("Skipping invalid allowed domain {}", candidate);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
String value = host.toLowerCase(Locale.ROOT);
|
|
||||||
if (uri.getPort() > 0) {
|
|
||||||
value = value + ":" + uri.getPort();
|
|
||||||
}
|
|
||||||
if (normalized.contains(value)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (normalized.size() >= 32) {
|
|
||||||
logger.warn("Trimming allowed domains for marketplace script {}, limit reached", candidate);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
normalized.add(value);
|
|
||||||
} catch (IllegalArgumentException ex) {
|
|
||||||
logger.warn("Skipping invalid allowed domain {}", candidate, ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new ArrayList<>(normalized);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Optional<byte[]> readBytes(Path filePath) {
|
private static Optional<byte[]> readBytes(Path filePath) {
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package dev.kruhlmann.imgfloat.util;
|
||||||
|
|
||||||
|
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared utility for normalizing and validating the allowed-domain lists that
|
||||||
|
* gate {@code fetch()} calls inside broadcast script workers.
|
||||||
|
*
|
||||||
|
* <p>Two modes are provided:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link #normalize} – strict; throws {@link ResponseStatusException} (400) on bad input.
|
||||||
|
* Use this for user-submitted data (API requests).</li>
|
||||||
|
* <li>{@link #normalizeLenient} – lenient; silently skips invalid entries.
|
||||||
|
* Use this when reading seed/marketplace data from disk.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public final class AllowedDomainNormalizer {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(AllowedDomainNormalizer.class);
|
||||||
|
private static final int MAX_DOMAINS = 32;
|
||||||
|
private static final Pattern VALID_DOMAIN = Pattern.compile("^[a-z0-9.-]+(?::[0-9]{1,5})?$");
|
||||||
|
|
||||||
|
private AllowedDomainNormalizer() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strict normalization: invalid entries cause a {@link ResponseStatusException} (400).
|
||||||
|
*/
|
||||||
|
public static List<String> normalize(List<String> requestedDomains) {
|
||||||
|
if (requestedDomains == null || requestedDomains.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<String> result = new ArrayList<>();
|
||||||
|
for (String raw : requestedDomains) {
|
||||||
|
if (raw == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String candidate = raw.trim();
|
||||||
|
if (candidate.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String normalized = parseAndNormalize(candidate);
|
||||||
|
if (normalized == null) {
|
||||||
|
throw new ResponseStatusException(BAD_REQUEST, "Invalid allowed domain: " + candidate);
|
||||||
|
}
|
||||||
|
if (!VALID_DOMAIN.matcher(normalized).matches()) {
|
||||||
|
throw new ResponseStatusException(BAD_REQUEST, "Invalid allowed domain: " + candidate);
|
||||||
|
}
|
||||||
|
if (result.contains(normalized)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (result.size() >= MAX_DOMAINS) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
BAD_REQUEST,
|
||||||
|
"A maximum of " + MAX_DOMAINS + " allowed domains are supported per script asset"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
result.add(normalized);
|
||||||
|
}
|
||||||
|
return List.copyOf(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lenient normalization: invalid entries are skipped with a warning.
|
||||||
|
*/
|
||||||
|
public static List<String> normalizeLenient(List<String> requestedDomains) {
|
||||||
|
if (requestedDomains == null || requestedDomains.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<String> result = new ArrayList<>();
|
||||||
|
for (String raw : requestedDomains) {
|
||||||
|
if (raw == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String candidate = raw.trim();
|
||||||
|
if (candidate.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String normalized = parseAndNormalize(candidate);
|
||||||
|
if (normalized == null || !VALID_DOMAIN.matcher(normalized).matches()) {
|
||||||
|
LOG.warn("Skipping invalid allowed domain {}", candidate);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (result.contains(normalized)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (result.size() >= MAX_DOMAINS) {
|
||||||
|
LOG.warn("Trimming allowed domains at limit of {}", MAX_DOMAINS);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
result.add(normalized);
|
||||||
|
}
|
||||||
|
return List.copyOf(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String parseAndNormalize(String candidate) {
|
||||||
|
String withScheme = candidate.contains("://") ? candidate : "https://" + candidate;
|
||||||
|
URI uri;
|
||||||
|
try {
|
||||||
|
uri = URI.create(withScheme);
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
LOG.warn("Unable to parse allowed domain {}", candidate, ex);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String host = uri.getHost();
|
||||||
|
if (host == null || host.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String domain = host.toLowerCase(Locale.ROOT);
|
||||||
|
int port = uri.getPort();
|
||||||
|
if (port > 0) {
|
||||||
|
domain = domain + ":" + port;
|
||||||
|
}
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package dev.kruhlmann.imgfloat.util;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
class AllowedDomainNormalizerTest {
|
||||||
|
|
||||||
|
// --- normalize (strict) ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void returnsEmptyListWhenNullInput() {
|
||||||
|
assertThat(AllowedDomainNormalizer.normalize(null)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void returnsEmptyListWhenEmptyInput() {
|
||||||
|
assertThat(AllowedDomainNormalizer.normalize(List.of())).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void normalizesHostToLowercase() {
|
||||||
|
assertThat(AllowedDomainNormalizer.normalize(List.of("EXAMPLE.COM")))
|
||||||
|
.containsExactly("example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void preservesPort() {
|
||||||
|
assertThat(AllowedDomainNormalizer.normalize(List.of("api.example.com:8080")))
|
||||||
|
.containsExactly("api.example.com:8080");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stripsSchemeWhenProvided() {
|
||||||
|
assertThat(AllowedDomainNormalizer.normalize(List.of("https://example.com")))
|
||||||
|
.containsExactly("example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deduplicatesEntries() {
|
||||||
|
List<String> result = AllowedDomainNormalizer.normalize(List.of("example.com", "EXAMPLE.COM", "example.com"));
|
||||||
|
assertThat(result).containsExactly("example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void throwsOn400WhenDomainInvalid() {
|
||||||
|
assertThatThrownBy(() -> AllowedDomainNormalizer.normalize(List.of("not a domain!!!")))
|
||||||
|
.isInstanceOf(ResponseStatusException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void throwsWhenExceedsMaxDomains() {
|
||||||
|
List<String> many = new ArrayList<>();
|
||||||
|
for (int i = 0; i < 33; i++) {
|
||||||
|
many.add("host" + i + ".example.com");
|
||||||
|
}
|
||||||
|
assertThatThrownBy(() -> AllowedDomainNormalizer.normalize(many))
|
||||||
|
.isInstanceOf(ResponseStatusException.class)
|
||||||
|
.hasMessageContaining("32");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void skipsNullAndBlankEntries() {
|
||||||
|
List<String> input = new ArrayList<>();
|
||||||
|
input.add(null);
|
||||||
|
input.add(" ");
|
||||||
|
input.add("example.com");
|
||||||
|
assertThat(AllowedDomainNormalizer.normalize(input)).containsExactly("example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resultIsImmutable() {
|
||||||
|
List<String> result = AllowedDomainNormalizer.normalize(List.of("example.com"));
|
||||||
|
assertThatThrownBy(() -> result.add("other.com"))
|
||||||
|
.isInstanceOf(UnsupportedOperationException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- normalizeLenient ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void lenientSkipsInvalidDomainsWithoutThrowing() {
|
||||||
|
List<String> result = AllowedDomainNormalizer.normalizeLenient(List.of("valid.com", "not a domain!!!"));
|
||||||
|
assertThat(result).containsExactly("valid.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void lenientStopsAtMaxDomains() {
|
||||||
|
List<String> many = new ArrayList<>();
|
||||||
|
for (int i = 0; i < 40; i++) {
|
||||||
|
many.add("host" + i + ".example.com");
|
||||||
|
}
|
||||||
|
List<String> result = AllowedDomainNormalizer.normalizeLenient(many);
|
||||||
|
assertThat(result).hasSize(32);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void lenientReturnsEmptyForNullInput() {
|
||||||
|
assertThat(AllowedDomainNormalizer.normalizeLenient(null)).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user