Add domain allow-list for script assets

This commit is contained in:
2026-01-25 14:01:53 +01:00
parent b115e16f11
commit b57420d727
17 changed files with 634 additions and 35 deletions

View File

@@ -74,11 +74,14 @@ marketplace-scripts/
{ {
"name": "Script display name", "name": "Script display name",
"description": "Short description", "description": "Short description",
"allowedDomains": ["api.example.com", "cdn.example.com:8443"]
} }
``` ```
Only `name` is required. The folder name is used to identify the marketplace listing; when a script is imported, the asset receives a new generated ID. Media types are inferred from the files on disk. Attachments are loaded from the `attachments/` folder and appear in the imported script's attachments list, referenced by filename (for example `rotate.png`). Attachment filenames must be unique within a script. The logo is optional and remains separate from attachments; if you want to use the same image inside the script, add a copy of it under `attachments/`. Only `name` is required. The folder name is used to identify the marketplace listing; when a script is imported, the asset receives a new generated ID. Media types are inferred from the files on disk. Attachments are loaded from the `attachments/` folder and appear in the imported script's attachments list, referenced by filename (for example `rotate.png`). Attachment filenames must be unique within a script. The logo is optional and remains separate from attachments; if you want to use the same image inside the script, add a copy of it under `attachments/`.
`allowedDomains` is optional; when provided it limits script `fetch` calls to the listed hostnames (up to 32 entries, ports allowed). Relative and same-origin requests remain permitted.
### Build and run ### Build and run
To run the application: To run the application:

View File

@@ -1,4 +1,5 @@
{ {
"name": "Lichess TV Chess", "name": "Lichess TV Chess",
"description": "Listen for !chess in chat, then render the current Lichess TV game live until it ends." "description": "Listen for !chess in chat, then render the current Lichess TV game live until it ends.",
"allowedDomains": ["lichess.org"]
} }

View File

@@ -14,7 +14,7 @@
<java.version>17</java.version> <java.version>17</java.version>
<spring.boot.version>3.2.5</spring.boot.version> <spring.boot.version>3.2.5</spring.boot.version>
<hibernate.version>6.4.4.Final</hibernate.version> <hibernate.version>6.4.4.Final</hibernate.version>
<bytebuddy.version>1.18.3</bytebuddy.version> <bytebuddy.version>1.14.13</bytebuddy.version>
<jacoco.version>0.8.14</jacoco.version> <jacoco.version>0.8.14</jacoco.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>

View File

@@ -22,6 +22,7 @@ public record AssetView(
String mediaType, String mediaType,
String originalMediaType, String originalMediaType,
AssetType assetType, AssetType assetType,
List<String> allowedDomains,
List<ScriptAssetAttachmentView> scriptAttachments, List<ScriptAssetAttachmentView> scriptAttachments,
Boolean audioLoop, Boolean audioLoop,
Integer audioDelayMillis, Integer audioDelayMillis,
@@ -54,6 +55,7 @@ public record AssetView(
visual.getMediaType(), visual.getMediaType(),
visual.getOriginalMediaType(), visual.getOriginalMediaType(),
asset.getAssetType(), asset.getAssetType(),
List.of(),
null, null,
null, null,
null, null,
@@ -87,6 +89,7 @@ public record AssetView(
audio.getMediaType(), audio.getMediaType(),
audio.getOriginalMediaType(), audio.getOriginalMediaType(),
asset.getAssetType(), asset.getAssetType(),
List.of(),
null, null,
audio.isAudioLoop(), audio.isAudioLoop(),
audio.getAudioDelayMillis(), audio.getAudioDelayMillis(),
@@ -122,6 +125,7 @@ public record AssetView(
script.getMediaType(), script.getMediaType(),
script.getOriginalMediaType(), script.getOriginalMediaType(),
asset.getAssetType(), asset.getAssetType(),
script.getAllowedDomains(),
script.getAttachments(), script.getAttachments(),
null, null,
null, null,

View File

@@ -14,6 +14,8 @@ public class CodeAssetRequest {
private Boolean isPublic; private Boolean isPublic;
private java.util.List<String> allowedDomains;
public String getName() { public String getName() {
return name; return name;
} }
@@ -45,4 +47,12 @@ public class CodeAssetRequest {
public void setIsPublic(Boolean isPublic) { public void setIsPublic(Boolean isPublic) {
this.isPublic = isPublic; this.isPublic = isPublic;
} }
public java.util.List<String> getAllowedDomains() {
return allowedDomains;
}
public void setAllowedDomains(java.util.List<String> allowedDomains) {
this.allowedDomains = allowedDomains;
}
} }

View File

@@ -1,12 +1,17 @@
package dev.kruhlmann.imgfloat.model; package dev.kruhlmann.imgfloat.model;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id; import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.PrePersist; import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate; import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import jakarta.persistence.Transient; import jakarta.persistence.Transient;
import java.util.ArrayList;
import java.util.List; import java.util.List;
@Entity @Entity
@@ -33,6 +38,11 @@ public class ScriptAsset {
@Column(name = "source_file_id") @Column(name = "source_file_id")
private String sourceFileId; private String sourceFileId;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "script_asset_allowed_domains", joinColumns = @JoinColumn(name = "script_asset_id"))
@Column(name = "allowed_domain")
private List<String> allowedDomains = new ArrayList<>();
@Transient @Transient
private List<ScriptAssetAttachmentView> attachments = List.of(); private List<ScriptAssetAttachmentView> attachments = List.of();
@@ -49,6 +59,12 @@ public class ScriptAsset {
if (this.name == null || this.name.isBlank()) { if (this.name == null || this.name.isBlank()) {
this.name = this.id; this.name = this.id;
} }
if (this.allowedDomains == null) {
this.allowedDomains = new ArrayList<>();
}
if (this.attachments == null) {
this.attachments = List.of();
}
} }
public String getId() { public String getId() {
@@ -115,6 +131,22 @@ public class ScriptAsset {
this.sourceFileId = sourceFileId; this.sourceFileId = sourceFileId;
} }
public List<String> getAllowedDomains() {
return allowedDomains == null ? List.of() : List.copyOf(allowedDomains);
}
public void setAllowedDomains(List<String> allowedDomains) {
if (this.allowedDomains == null) {
this.allowedDomains = new ArrayList<>();
} else {
this.allowedDomains.clear();
}
if (allowedDomains == null) {
return;
}
this.allowedDomains.addAll(allowedDomains);
}
public List<ScriptAssetAttachmentView> getAttachments() { public List<ScriptAssetAttachmentView> getAttachments() {
return attachments == null ? List.of() : attachments; return attachments == null ? List.of() : attachments;
} }

View File

@@ -6,6 +6,7 @@ public record ScriptMarketplaceEntry(
String description, String description,
String logoUrl, String logoUrl,
String broadcaster, String broadcaster,
java.util.List<String> allowedDomains,
long heartsCount, long heartsCount,
boolean hearted boolean hearted
) {} ) {}

View File

@@ -39,6 +39,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 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;
@@ -62,6 +63,8 @@ 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 final ChannelRepository channelRepository; private final ChannelRepository channelRepository;
private final AssetRepository assetRepository; private final AssetRepository assetRepository;
@@ -374,6 +377,7 @@ public class ChannelDirectoryService {
script.setMediaType(optimized.mediaType()); script.setMediaType(optimized.mediaType());
script.setOriginalMediaType(mediaType); script.setOriginalMediaType(mediaType);
script.setSourceFileId(asset.getId()); script.setSourceFileId(asset.getId());
script.setAllowedDomains(List.of());
script.setAttachments(List.of()); script.setAttachments(List.of());
scriptAssetRepository.save(script); scriptAssetRepository.save(script);
ScriptAssetFile sourceFile = new ScriptAssetFile(asset.getBroadcaster(), AssetType.SCRIPT); ScriptAssetFile sourceFile = new ScriptAssetFile(asset.getBroadcaster(), AssetType.SCRIPT);
@@ -413,6 +417,7 @@ public class ChannelDirectoryService {
Channel channel = getOrCreateChannel(broadcaster); Channel channel = getOrCreateChannel(broadcaster);
byte[] bytes = request.getSource().getBytes(StandardCharsets.UTF_8); byte[] bytes = request.getSource().getBytes(StandardCharsets.UTF_8);
enforceUploadLimit(bytes.length); enforceUploadLimit(bytes.length);
List<String> allowedDomains = normalizeAllowedDomains(request.getAllowedDomains());
Asset asset = new Asset(channel.getBroadcaster(), AssetType.SCRIPT); Asset asset = new Asset(channel.getBroadcaster(), AssetType.SCRIPT);
asset.setDisplayOrder(nextDisplayOrder(channel.getBroadcaster(), AssetType.SCRIPT)); asset.setDisplayOrder(nextDisplayOrder(channel.getBroadcaster(), AssetType.SCRIPT));
@@ -440,6 +445,7 @@ public class ChannelDirectoryService {
script.setSourceFileId(sourceFile.getId()); script.setSourceFileId(sourceFile.getId());
script.setDescription(normalizeDescription(request.getDescription())); script.setDescription(normalizeDescription(request.getDescription()));
script.setPublic(Boolean.TRUE.equals(request.getIsPublic())); script.setPublic(Boolean.TRUE.equals(request.getIsPublic()));
script.setAllowedDomains(allowedDomains);
script.setAttachments(List.of()); script.setAttachments(List.of());
scriptAssetRepository.save(script); scriptAssetRepository.save(script);
AssetView view = AssetView.fromScript(channel.getBroadcaster(), asset, script); AssetView view = AssetView.fromScript(channel.getBroadcaster(), asset, script);
@@ -458,6 +464,7 @@ public class ChannelDirectoryService {
String normalized = normalize(broadcaster); String normalized = normalize(broadcaster);
byte[] bytes = request.getSource().getBytes(StandardCharsets.UTF_8); byte[] bytes = request.getSource().getBytes(StandardCharsets.UTF_8);
enforceUploadLimit(bytes.length); enforceUploadLimit(bytes.length);
List<String> allowedDomains = normalizeAllowedDomains(request.getAllowedDomains());
return assetRepository return assetRepository
.findById(assetId) .findById(assetId)
@@ -491,6 +498,7 @@ public class ChannelDirectoryService {
if (request.getIsPublic() != null) { if (request.getIsPublic() != null) {
script.setPublic(request.getIsPublic()); script.setPublic(request.getIsPublic());
} }
script.setAllowedDomains(allowedDomains);
script.setOriginalMediaType(DEFAULT_CODE_MEDIA_TYPE); script.setOriginalMediaType(DEFAULT_CODE_MEDIA_TYPE);
script.setMediaType(DEFAULT_CODE_MEDIA_TYPE); script.setMediaType(DEFAULT_CODE_MEDIA_TYPE);
script.setAttachments(loadScriptAttachments(normalized, asset.getId(), null)); script.setAttachments(loadScriptAttachments(normalized, asset.getId(), null));
@@ -635,6 +643,7 @@ public class ChannelDirectoryService {
script.getDescription(), script.getDescription(),
logoUrl, logoUrl,
broadcaster, broadcaster,
normalizeAllowedDomainsLenient(script.getAllowedDomains()),
0, 0,
false false
); );
@@ -687,6 +696,7 @@ public class ChannelDirectoryService {
script.getDescription(), script.getDescription(),
logoUrl, logoUrl,
broadcaster, broadcaster,
normalizeAllowedDomainsLenient(script.getAllowedDomains()),
0, 0,
false false
); );
@@ -715,6 +725,7 @@ public class ChannelDirectoryService {
entry.description(), entry.description(),
entry.logoUrl(), entry.logoUrl(),
entry.broadcaster(), entry.broadcaster(),
entry.allowedDomains(),
heartCount, heartCount,
hearted hearted
); );
@@ -769,6 +780,7 @@ public class ChannelDirectoryService {
entry.description(), entry.description(),
entry.logoUrl(), entry.logoUrl(),
entry.broadcaster(), entry.broadcaster(),
entry.allowedDomains(),
counts.getOrDefault(entry.id(), 0L), counts.getOrDefault(entry.id(), 0L),
heartedIds.contains(entry.id()) heartedIds.contains(entry.id())
) )
@@ -845,6 +857,7 @@ public class ChannelDirectoryService {
script.setOriginalMediaType(sourceContent.mediaType()); script.setOriginalMediaType(sourceContent.mediaType());
script.setSourceFileId(sourceFile.getId()); script.setSourceFileId(sourceFile.getId());
script.setLogoFileId(sourceScript.getLogoFileId()); script.setLogoFileId(sourceScript.getLogoFileId());
script.setAllowedDomains(normalizeAllowedDomains(sourceScript.getAllowedDomains()));
script.setAttachments(List.of()); script.setAttachments(List.of());
scriptAssetRepository.save(script); scriptAssetRepository.save(script);
@@ -889,6 +902,7 @@ public class ChannelDirectoryService {
if (sourceContent == null) { if (sourceContent == null) {
return Optional.empty(); return Optional.empty();
} }
List<String> allowedDomains = normalizeAllowedDomains(seedScript.allowedDomains());
Asset asset = new Asset(targetBroadcaster, AssetType.SCRIPT); Asset asset = new Asset(targetBroadcaster, AssetType.SCRIPT);
asset.setDisplayOrder(nextDisplayOrder(targetBroadcaster, AssetType.SCRIPT)); asset.setDisplayOrder(nextDisplayOrder(targetBroadcaster, AssetType.SCRIPT));
@@ -915,6 +929,7 @@ public class ChannelDirectoryService {
script.setMediaType(sourceContent.mediaType()); script.setMediaType(sourceContent.mediaType());
script.setOriginalMediaType(sourceContent.mediaType()); script.setOriginalMediaType(sourceContent.mediaType());
script.setSourceFileId(sourceFile.getId()); script.setSourceFileId(sourceFile.getId());
script.setAllowedDomains(allowedDomains);
script.setAttachments(List.of()); script.setAttachments(List.of());
scriptAssetRepository.save(script); scriptAssetRepository.save(script);
@@ -1555,6 +1570,61 @@ public class ChannelDirectoryService {
return trimmed.isBlank() ? null : trimmed; return trimmed.isBlank() ? null : trimmed;
} }
private List<String> normalizeAllowedDomains(List<String> requestedDomains) {
if (requestedDomains == null || requestedDomains.isEmpty()) {
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) {
try {
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) {
if (fileId == null || fileId.isBlank()) { if (fileId == null || fileId.isBlank()) {
return; return;

View File

@@ -3,6 +3,7 @@ package dev.kruhlmann.imgfloat.service;
import dev.kruhlmann.imgfloat.model.ScriptMarketplaceEntry; import dev.kruhlmann.imgfloat.model.ScriptMarketplaceEntry;
import dev.kruhlmann.imgfloat.service.media.AssetContent; import dev.kruhlmann.imgfloat.service.media.AssetContent;
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;
@@ -81,6 +82,7 @@ public class MarketplaceScriptSeedLoader {
String logoMediaType, String logoMediaType,
Optional<Path> sourcePath, Optional<Path> sourcePath,
Optional<Path> logoPath, Optional<Path> logoPath,
List<String> allowedDomains,
List<SeedAttachment> attachments, List<SeedAttachment> attachments,
AtomicReference<byte[]> sourceBytes, AtomicReference<byte[]> sourceBytes,
AtomicReference<byte[]> logoBytes AtomicReference<byte[]> logoBytes
@@ -92,6 +94,7 @@ public class MarketplaceScriptSeedLoader {
description, description,
logoPath.isPresent() ? "/api/marketplace/scripts/" + id + "/logo" : null, logoPath.isPresent() ? "/api/marketplace/scripts/" + id + "/logo" : null,
broadcaster, broadcaster,
allowedDomains,
0, 0,
false false
); );
@@ -166,6 +169,7 @@ public class MarketplaceScriptSeedLoader {
if (attachments == null) { if (attachments == null) {
return Optional.empty(); return Optional.empty();
} }
List<String> allowedDomains = normalizeAllowedDomains(metadata.allowedDomains());
return Optional.of( return Optional.of(
new SeedScript( new SeedScript(
@@ -177,6 +181,7 @@ public class MarketplaceScriptSeedLoader {
logoMediaType, logoMediaType,
Optional.ofNullable(sourcePath), Optional.ofNullable(sourcePath),
Optional.ofNullable(logoPath), Optional.ofNullable(logoPath),
allowedDomains,
attachments, attachments,
new AtomicReference<>(), new AtomicReference<>(),
new AtomicReference<>() new AtomicReference<>()
@@ -284,6 +289,46 @@ public class MarketplaceScriptSeedLoader {
return Optional.of(new AssetContent(bytes, mediaType)); return Optional.of(new AssetContent(bytes, mediaType));
} }
private List<String> normalizeAllowedDomains(List<String> requestedDomains) {
if (requestedDomains == null || requestedDomains.isEmpty()) {
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) {
if (!Files.isRegularFile(filePath)) { if (!Files.isRegularFile(filePath)) {
return Optional.empty(); return Optional.empty();
@@ -296,7 +341,7 @@ public class MarketplaceScriptSeedLoader {
} }
} }
record ScriptSeedMetadata(String name, String description, String broadcaster) { record ScriptSeedMetadata(String name, String description, String broadcaster, List<String> allowedDomains) {
static ScriptSeedMetadata read(Path path) { static ScriptSeedMetadata read(Path path) {
if (!Files.isRegularFile(path)) { if (!Files.isRegularFile(path)) {
return null; return null;

View File

@@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS script_asset_allowed_domains (
script_asset_id TEXT NOT NULL,
allowed_domain TEXT NOT NULL,
PRIMARY KEY (script_asset_id, allowed_domain),
FOREIGN KEY (script_asset_id) REFERENCES script_assets(id) ON DELETE CASCADE
);

View File

@@ -720,6 +720,7 @@ export class BroadcastRenderer {
width: scriptCanvas.width, width: scriptCanvas.width,
height: scriptCanvas.height, height: scriptCanvas.height,
attachments, attachments,
allowedDomains: Array.isArray(asset.allowedDomains) ? asset.allowedDomains : [],
}, },
}, [offscreen]); }, [offscreen]);
} }
@@ -735,6 +736,7 @@ export class BroadcastRenderer {
payload: { payload: {
id: asset.id, id: asset.id,
attachments, attachments,
allowedDomains: Array.isArray(asset.allowedDomains) ? asset.allowedDomains : [],
}, },
}); });
} }

View File

@@ -9,6 +9,8 @@ const errorKeys = new Set();
const allowedImportUrls = new Set(); const allowedImportUrls = new Set();
const nativeImportScripts = typeof self.importScripts === "function" ? self.importScripts.bind(self) : null; const nativeImportScripts = typeof self.importScripts === "function" ? self.importScripts.bind(self) : null;
const sharedDependencyUrls = ["/js/vendor/three.min.js", "/js/vendor/GLTFLoader.js", "/js/vendor/OBJLoader.js"]; const sharedDependencyUrls = ["/js/vendor/three.min.js", "/js/vendor/GLTFLoader.js", "/js/vendor/OBJLoader.js"];
const nativeFetch = typeof self.fetch === "function" ? self.fetch.bind(self) : null;
let activeScriptId = null;
let chatMessages = []; let chatMessages = [];
let emoteCatalog = []; let emoteCatalog = [];
@@ -49,6 +51,148 @@ function loadSharedDependencies() {
loadSharedDependencies(); loadSharedDependencies();
function sanitizeAllowedDomains(domains) {
if (!Array.isArray(domains)) {
return [];
}
const normalized = [];
domains.forEach((raw) => {
const candidate = typeof raw === "string" ? raw.trim() : "";
if (!candidate) {
return;
}
const withScheme = candidate.includes("://") ? candidate : `https://${candidate}`;
try {
const url = new URL(withScheme, self.location?.href || "http://localhost");
if (!url.hostname) {
return;
}
const host = url.hostname.toLowerCase();
const port = url.port ? `:${url.port}` : "";
const value = `${host}${port}`;
if (!normalized.includes(value) && normalized.length < 32) {
normalized.push(value);
}
} catch (_error) {
// ignore invalid domains from metadata/user input
}
});
return normalized;
}
function extractUrlFromInput(input) {
if (typeof input === "string") {
return input;
}
if (input && typeof input.url === "string") {
return input.url;
}
return "";
}
function extractDomain(url) {
try {
return new URL(url, self.location?.href || "http://localhost").host.toLowerCase();
} catch (_error) {
return "";
}
}
function isSameOrigin(url) {
if (!self.location?.origin) {
return false;
}
try {
return new URL(url, self.location.origin).origin === self.location.origin;
} catch (_error) {
return false;
}
}
function domainMatches(domain, allowed) {
if (!domain || !allowed) {
return false;
}
if (allowed.includes(":")) {
return domain === allowed;
}
return domain === allowed || domain.endsWith(`.${allowed}`);
}
function isFetchAllowedForScript(script, targetUrl) {
const normalized = normalizeUrl(targetUrl);
if (!normalized) {
return { allowed: false, reason: "Invalid or empty URL" };
}
if (isSameOrigin(normalized) || allowedFetchUrls.has(normalized)) {
return { allowed: true, normalized };
}
const domain = extractDomain(normalized);
if (!domain) {
return { allowed: false, reason: "Invalid URL" };
}
const allowedDomains = Array.isArray(script.allowedDomains) ? script.allowedDomains : [];
const allowed = allowedDomains.some((value) => domainMatches(domain, value));
return allowed
? { allowed: true, normalized }
: { allowed: false, reason: `Domain ${domain} is not in the allowed list.`, normalized };
}
function createScriptFetch(script) {
return async function scriptFetch(input, init) {
const targetUrl = extractUrlFromInput(input);
const decision = isFetchAllowedForScript(script, targetUrl);
console.info(
`Script ${script.id} fetch attempt`,
JSON.stringify({
url: targetUrl || "",
normalized: decision.normalized || "",
allowedDomains: script.allowedDomains,
allowed: decision.allowed,
reason: decision.reason,
})
);
if (!decision.allowed) {
const message = `Fetch blocked for script ${script.id}: ${decision.reason}`;
const error = new Error(message);
console.error(message);
reportScriptError(script.id, "fetch", error);
return Promise.reject(error);
}
if (!nativeFetch) {
const error = new Error("Fetch is unavailable in this environment.");
reportScriptError(script.id, "fetch", error);
return Promise.reject(error);
}
try {
return await nativeFetch(input, init);
} catch (error) {
console.error(`Fetch failed for script ${script.id} (${targetUrl || "<unknown>"})`, error);
reportScriptError(script.id, "fetch", error);
throw error;
}
};
}
function fetchForActiveScript(input, init) {
const script = activeScriptId ? scripts.get(activeScriptId) : null;
if (!script) {
const error = new Error("Fetch is only available inside a running script context.");
console.error(error.message);
return Promise.reject(error);
}
if (!script.context.fetch) {
script.context.fetch = createScriptFetch(script);
}
return script.context.fetch(input, init);
}
if (nativeFetch) {
self.fetch = function sandboxedFetch(input, init) {
return fetchForActiveScript(input, init);
};
}
function refreshAllowedFetchUrls() { function refreshAllowedFetchUrls() {
allowedFetchUrls.clear(); allowedFetchUrls.clear();
scripts.forEach((script) => { scripts.forEach((script) => {
@@ -125,6 +269,7 @@ function updateScriptContexts() {
script.context.height = script.canvas?.height ?? 0; script.context.height = script.canvas?.height ?? 0;
script.context.chatMessages = chatMessages; script.context.chatMessages = chatMessages;
script.context.emoteCatalog = emoteCatalog; script.context.emoteCatalog = emoteCatalog;
script.context.allowedDomains = script.allowedDomains;
}); });
} }
@@ -151,9 +296,12 @@ function ensureTickLoop() {
script.context.deltaMs = deltaMs; script.context.deltaMs = deltaMs;
script.context.elapsedMs = elapsedMs; script.context.elapsedMs = elapsedMs;
try { try {
activeScriptId = script.id;
script.tick(script.context, script.state); script.tick(script.context, script.state);
} catch (error) { } catch (error) {
console.error(`Script ${script.id} tick failed`, error); console.error(`Script ${script.id} tick failed`, error);
} finally {
activeScriptId = null;
} }
}); });
}, tickIntervalMs); }, tickIntervalMs);
@@ -168,7 +316,7 @@ function stopTickLoopIfIdle() {
function createScriptHandlers(source, context, state, sourceLabel = "") { function createScriptHandlers(source, context, state, sourceLabel = "") {
const contextPrelude = const contextPrelude =
"const { canvas, ctx, channelName, width, height, now, deltaMs, elapsedMs, assets, chatMessages, emoteCatalog, playAudio } = context;"; "const { canvas, ctx, channelName, width, height, now, deltaMs, elapsedMs, assets, chatMessages, emoteCatalog, playAudio, fetch, allowedDomains } = context;";
const sourceUrl = sourceLabel ? `\n//# sourceURL=${sourceLabel}` : ""; const sourceUrl = sourceLabel ? `\n//# sourceURL=${sourceLabel}` : "";
const factory = new Function( const factory = new Function(
"context", "context",
@@ -212,6 +360,7 @@ self.addEventListener("message", (event) => {
if (!payload?.id || !payload?.source || !payload?.canvas) { if (!payload?.id || !payload?.source || !payload?.canvas) {
return; return;
} }
const allowedDomains = sanitizeAllowedDomains(payload.allowedDomains);
const canvas = payload.canvas; const canvas = payload.canvas;
canvas.width = payload.width || canvas.width; canvas.width = payload.width || canvas.width;
canvas.height = payload.height || canvas.height; canvas.height = payload.height || canvas.height;
@@ -229,6 +378,7 @@ self.addEventListener("message", (event) => {
assets: Array.isArray(payload.attachments) ? payload.attachments : [], assets: Array.isArray(payload.attachments) ? payload.attachments : [],
chatMessages, chatMessages,
emoteCatalog, emoteCatalog,
allowedDomains,
playAudio: (attachment) => { playAudio: (attachment) => {
const attachmentId = typeof attachment === "string" ? attachment : attachment?.id; const attachmentId = typeof attachment === "string" ? attachment : attachment?.id;
if (!attachmentId) { if (!attachmentId) {
@@ -243,31 +393,41 @@ self.addEventListener("message", (event) => {
}); });
}, },
}; };
const script = {
id: payload.id,
allowedDomains,
canvas,
ctx,
context,
state,
init: null,
tick: null,
};
context.fetch = createScriptFetch(script);
let handlers = {}; let handlers = {};
try { try {
activeScriptId = payload.id;
handlers = createScriptHandlers(payload.source, context, state, `user-script-${payload.id}.js`); handlers = createScriptHandlers(payload.source, context, state, `user-script-${payload.id}.js`);
} catch (error) { } catch (error) {
console.error(`Script ${payload.id} failed to initialize`, error); console.error(`Script ${payload.id} failed to initialize`, error);
reportScriptError(payload.id, "initialize", error); reportScriptError(payload.id, "initialize", error);
return; return;
} finally {
activeScriptId = null;
} }
const script = { script.init = handlers.init;
id: payload.id, script.tick = handlers.tick;
canvas,
ctx,
context,
state,
init: handlers.init,
tick: handlers.tick,
};
scripts.set(payload.id, script); scripts.set(payload.id, script);
refreshAllowedFetchUrls(); refreshAllowedFetchUrls();
if (script.init) { if (script.init) {
try { try {
activeScriptId = script.id;
script.init(script.context, script.state); script.init(script.context, script.state);
} catch (error) { } catch (error) {
console.error(`Script ${payload.id} init failed`, error); console.error(`Script ${payload.id} init failed`, error);
reportScriptError(payload.id, "init", error); reportScriptError(payload.id, "init", error);
} finally {
activeScriptId = null;
} }
} }
ensureTickLoop(); ensureTickLoop();
@@ -292,6 +452,8 @@ self.addEventListener("message", (event) => {
return; return;
} }
script.context.assets = Array.isArray(payload.attachments) ? payload.attachments : []; script.context.assets = Array.isArray(payload.attachments) ? payload.attachments : [];
script.allowedDomains = sanitizeAllowedDomains(payload.allowedDomains);
script.context.allowedDomains = script.allowedDomains;
refreshAllowedFetchUrls(); refreshAllowedFetchUrls();
} }

View File

@@ -30,10 +30,15 @@ export function createCustomAssetModal({
const attachmentInput = document.getElementById("custom-asset-attachment-file"); const attachmentInput = document.getElementById("custom-asset-attachment-file");
const attachmentList = document.getElementById("custom-asset-attachment-list"); const attachmentList = document.getElementById("custom-asset-attachment-list");
const attachmentHint = document.getElementById("custom-asset-attachment-hint"); const attachmentHint = document.getElementById("custom-asset-attachment-hint");
const allowedDomainInput = document.getElementById("custom-asset-allowed-domain");
const allowedDomainList = document.getElementById("custom-asset-allowed-domain-list");
const allowedDomainAddButton = document.getElementById("custom-asset-allowed-domain-add");
const allowedDomainHint = document.getElementById("custom-asset-allowed-domain-hint");
let currentAssetId = null; let currentAssetId = null;
let pendingLogoFile = null; let pendingLogoFile = null;
let logoRemoved = false; let logoRemoved = false;
let attachmentState = []; let attachmentState = [];
let allowedDomainState = [];
let marketplaceEntries = []; let marketplaceEntries = [];
const resetErrors = () => { const resetErrors = () => {
@@ -48,6 +53,99 @@ export function createCustomAssetModal({
} }
}; };
const normalizeAllowedDomain = (value) => {
if (!value) return null;
const trimmed = value.trim();
if (!trimmed) return null;
const candidate = trimmed.includes("://") ? trimmed : `https://${trimmed}`;
try {
const url = new URL(candidate);
if (!url.hostname) {
return null;
}
const host = url.hostname.toLowerCase();
const port = url.port ? `:${url.port}` : "";
return `${host}${port}`;
} catch (_error) {
return null;
}
};
const setAllowedDomainState = (domains) => {
allowedDomainState = Array.isArray(domains)
? domains
.map((domain) => normalizeAllowedDomain(domain))
.filter((domain, index, list) => domain && list.indexOf(domain) === index)
: [];
renderAllowedDomains();
if (allowedDomainInput) {
allowedDomainInput.value = "";
}
};
const removeAllowedDomain = (domain) => {
allowedDomainState = allowedDomainState.filter((item) => item !== domain);
renderAllowedDomains();
};
const addAllowedDomain = (value) => {
const normalized = normalizeAllowedDomain(value);
if (!normalized) {
showToast?.("Enter a valid domain like api.example.com.", "error");
return;
}
if (allowedDomainState.includes(normalized)) {
showToast?.("Domain already added.", "info");
if (allowedDomainInput) allowedDomainInput.value = "";
return;
}
if (allowedDomainState.length >= 32) {
showToast?.("You can allow up to 32 domains per script.", "error");
return;
}
allowedDomainState = [...allowedDomainState, normalized];
renderAllowedDomains();
if (allowedDomainInput) {
allowedDomainInput.value = "";
}
};
function renderAllowedDomains() {
if (!allowedDomainList) {
return;
}
allowedDomainList.innerHTML = "";
if (!allowedDomainState.length) {
const empty = document.createElement("li");
empty.className = "attachment-empty";
empty.textContent = "No external domains allowed (only same-origin requests).";
allowedDomainList.appendChild(empty);
return;
}
allowedDomainState.forEach((domain) => {
const item = document.createElement("li");
item.className = "attachment-item";
const meta = document.createElement("div");
meta.className = "attachment-meta";
const name = document.createElement("strong");
name.textContent = domain;
meta.appendChild(name);
const actions = document.createElement("div");
actions.className = "attachment-actions-row";
const remove = document.createElement("button");
remove.type = "button";
remove.className = "secondary danger";
remove.textContent = "Remove";
remove.addEventListener("click", () => removeAllowedDomain(domain));
actions.appendChild(remove);
item.appendChild(meta);
item.appendChild(actions);
allowedDomainList.appendChild(item);
});
}
const registerCodeEditorLint = () => { const registerCodeEditorLint = () => {
const CodeMirror = globalThis.CodeMirror; const CodeMirror = globalThis.CodeMirror;
if (!CodeMirror?.registerHelper || CodeMirror.__customAssetLintRegistered) { if (!CodeMirror?.registerHelper || CodeMirror.__customAssetLintRegistered) {
@@ -282,6 +380,7 @@ export function createCustomAssetModal({
"function init(context, state) {\n const { assets } = context;\n\n}\n\nfunction tick(context, state) {\n\n}\n\n// or\n// module.exports.init = (context, state) => {};\n// module.exports.tick = (context, state) => {};", "function init(context, state) {\n const { assets } = context;\n\n}\n\nfunction tick(context, state) {\n\n}\n\n// or\n// module.exports.init = (context, state) => {};\n// module.exports.tick = (context, state) => {};",
); );
setAttachmentState(null, []); setAttachmentState(null, []);
setAllowedDomainState([]);
resetErrors(); resetErrors();
openModal(); openModal();
}; };
@@ -314,6 +413,7 @@ export function createCustomAssetModal({
setCodeReadOnly(true); setCodeReadOnly(true);
setCodePlaceholder("Loading script..."); setCodePlaceholder("Loading script...");
setAttachmentState(asset.id, asset.scriptAttachments || []); setAttachmentState(asset.id, asset.scriptAttachments || []);
setAllowedDomainState(asset.allowedDomains || []);
openModal(); openModal();
fetch(asset.url) fetch(asset.url)
@@ -373,7 +473,7 @@ export function createCustomAssetModal({
submitButton.disabled = true; submitButton.disabled = true;
submitButton.textContent = "Saving..."; submitButton.textContent = "Saving...";
} }
saveCodeAsset({ name, src, assetId, description, isPublic }) saveCodeAsset({ name, src, assetId, description, isPublic, allowedDomains: allowedDomainState })
.then((asset) => { .then((asset) => {
if (asset) { if (asset) {
return syncLogoChanges(asset).then((updated) => { return syncLogoChanges(asset).then((updated) => {
@@ -495,14 +595,25 @@ export function createCustomAssetModal({
renderAttachmentList(); renderAttachmentList();
showToast?.("Attachment added.", "success"); showToast?.("Attachment added.", "success");
} }
}) })
.catch((error) => { .catch((error) => {
console.error(error); console.error(error);
showToast?.(error?.message || "Unable to upload attachment. Please try again.", "error"); showToast?.(error?.message || "Unable to upload attachment. Please try again.", "error");
}) })
.finally(() => { .finally(() => {
attachmentInput.value = ""; attachmentInput.value = "";
}); });
});
}
if (allowedDomainAddButton) {
allowedDomainAddButton.addEventListener("click", () => addAllowedDomain(allowedDomainInput?.value));
}
if (allowedDomainInput) {
allowedDomainInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
addAllowedDomain(event.target?.value);
}
}); });
} }
@@ -642,12 +753,13 @@ export function createCustomAssetModal({
}); });
} }
function saveCodeAsset({ name, src, assetId, description, isPublic }) { function saveCodeAsset({ name, src, assetId, description, isPublic, allowedDomains }) {
const payload = { const payload = {
name, name,
source: src, source: src,
description: description || null, description: description || null,
isPublic, isPublic,
allowedDomains: Array.isArray(allowedDomains) ? allowedDomains : [],
}; };
const method = assetId ? "PUT" : "POST"; const method = assetId ? "PUT" : "POST";
const url = assetId const url = assetId
@@ -810,6 +922,16 @@ export function createCustomAssetModal({
content.appendChild(title); content.appendChild(title);
content.appendChild(description); content.appendChild(description);
content.appendChild(meta); content.appendChild(meta);
if (Array.isArray(entry.allowedDomains) && entry.allowedDomains.length) {
const domains = document.createElement("small");
domains.className = "marketplace-domains";
const summary =
entry.allowedDomains.length > 3
? `${entry.allowedDomains.slice(0, 3).join(", ")}, …`
: entry.allowedDomains.join(", ");
domains.textContent = `Allowed domains: ${summary}`;
content.appendChild(domains);
}
const actions = document.createElement("div"); const actions = document.createElement("div");
actions.className = "marketplace-actions"; actions.className = "marketplace-actions";
@@ -854,25 +976,34 @@ export function createCustomAssetModal({
return; return;
} }
const target = marketplaceChannelSelect?.value || broadcaster; const target = marketplaceChannelSelect?.value || broadcaster;
fetch(`/api/marketplace/scripts/${entry.id}/import`, { const allowedDomains = Array.isArray(entry.allowedDomains) ? entry.allowedDomains.filter(Boolean) : [];
method: "POST", confirmDomainImport(allowedDomains, target)
headers: { "Content-Type": "application/json" }, .then((confirmed) => {
body: JSON.stringify({ targetBroadcaster: target }), if (!confirmed) {
}) return null;
.then((response) => {
if (!response.ok) {
throw new Error("Failed to import script");
} }
return response.json(); return fetch(`/api/marketplace/scripts/${entry.id}/import`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetBroadcaster: target }),
}).then((response) => {
if (!response.ok) {
throw new Error("Failed to import script");
}
return response.json();
});
}) })
.then((asset) => { .then((asset) => {
if (!asset) {
return;
}
closeMarketplaceModal(); closeMarketplaceModal();
showToast?.("Script imported.", "success"); showToast?.("Script imported.", "success");
onAssetSaved?.(asset); onAssetSaved?.(asset);
}) })
.catch((error) => { .catch((error) => {
console.error(error); console.error(error);
showToast?.("Unable to import script. Please try again.", "error"); showToast?.(error?.message || "Unable to import script. Please try again.", "error");
}); });
} }
@@ -1043,4 +1174,72 @@ export function createCustomAssetModal({
return undefined; return undefined;
} }
function confirmDomainImport(domains, target) {
if (!Array.isArray(domains) || domains.length === 0) {
return Promise.resolve(true);
}
return new Promise((resolve) => {
const overlay = document.createElement("div");
overlay.className = "modal";
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
const dialog = document.createElement("div");
dialog.className = "modal-card";
const title = document.createElement("h3");
title.textContent = "Allow external domains?";
dialog.appendChild(title);
const copy = document.createElement("p");
copy.textContent = `This script requests network access to the following domains on ${target}:`;
dialog.appendChild(copy);
const list = document.createElement("ul");
list.className = "domain-list";
domains.forEach((domain) => {
const item = document.createElement("li");
item.textContent = domain;
list.appendChild(item);
});
dialog.appendChild(list);
const buttons = document.createElement("div");
buttons.className = "modal-actions";
const cancel = document.createElement("button");
cancel.type = "button";
cancel.className = "secondary";
cancel.textContent = "Cancel";
cancel.addEventListener("click", () => {
cleanup();
resolve(false);
});
const confirm = document.createElement("button");
confirm.type = "button";
confirm.className = "primary";
confirm.textContent = "Allow & import";
confirm.addEventListener("click", () => {
cleanup();
resolve(true);
});
buttons.appendChild(cancel);
buttons.appendChild(confirm);
dialog.appendChild(buttons);
overlay.addEventListener("click", (event) => {
if (event.target === overlay) {
cleanup();
resolve(false);
}
});
overlay.appendChild(dialog);
document.body.appendChild(overlay);
function cleanup() {
overlay.remove();
}
});
}
} }

View File

@@ -447,6 +447,26 @@
rows="3" rows="3"
></textarea> ></textarea>
</div> </div>
<div class="form-group">
<label for="custom-asset-allowed-domain">Allowed domains</label>
<div class="attachment-actions">
<input
id="custom-asset-allowed-domain"
class="text-input"
type="text"
placeholder="api.example.com"
autocomplete="off"
/>
<button type="button" class="secondary" id="custom-asset-allowed-domain-add">
Add domain
</button>
</div>
<p class="field-note" id="custom-asset-allowed-domain-hint">
Scripts may fetch data only from these domains (max 32). Relative and Imgfloat URLs are always
allowed.
</p>
<ul id="custom-asset-allowed-domain-list" class="attachment-list compact"></ul>
</div>
<div class="form-group"> <div class="form-group">
<label>Logo (optional)</label> <label>Logo (optional)</label>
<div class="attachment-actions"> <div class="attachment-actions">

View File

@@ -14,6 +14,8 @@ import dev.kruhlmann.imgfloat.model.AssetView;
import dev.kruhlmann.imgfloat.model.AudioAsset; import dev.kruhlmann.imgfloat.model.AudioAsset;
import dev.kruhlmann.imgfloat.model.Channel; import dev.kruhlmann.imgfloat.model.Channel;
import dev.kruhlmann.imgfloat.model.ScriptAsset; import dev.kruhlmann.imgfloat.model.ScriptAsset;
import dev.kruhlmann.imgfloat.model.CodeAssetRequest;
import dev.kruhlmann.imgfloat.model.AssetType;
import dev.kruhlmann.imgfloat.model.Settings; import dev.kruhlmann.imgfloat.model.Settings;
import dev.kruhlmann.imgfloat.model.TransformRequest; import dev.kruhlmann.imgfloat.model.TransformRequest;
import dev.kruhlmann.imgfloat.model.VisibilityRequest; import dev.kruhlmann.imgfloat.model.VisibilityRequest;
@@ -219,6 +221,31 @@ class ChannelDirectoryServiceTest {
.anyMatch((entry) -> "rotating-logo".equals(entry.id())); .anyMatch((entry) -> "rotating-logo".equals(entry.id()));
} }
@Test
void clearsAllowedDomainsWhenUpdatedToEmpty() {
CodeAssetRequest request = new CodeAssetRequest();
request.setName("Test script");
request.setSource("exports.init = function() {}; exports.tick = function() {};");
request.setAllowedDomains(List.of("example.com"));
AssetView created = service.createCodeAsset("caster", request, "caster").orElseThrow();
scriptAssetRepository.findById(created.id()).ifPresent((script) -> script.setSourceFileId(created.id()));
scriptAssetFileRepository.save(new dev.kruhlmann.imgfloat.model.ScriptAssetFile("caster", AssetType.SCRIPT) {
{
setId(created.id());
}
});
CodeAssetRequest updated = new CodeAssetRequest();
updated.setName("Test script");
updated.setSource(request.getSource());
updated.setAllowedDomains(List.of()); // clear allowlist
AssetView saved = service.updateCodeAsset("caster", created.id(), updated, "caster").orElseThrow();
ScriptAsset script = scriptAssetRepository.findById(created.id()).orElseThrow();
assertThat(script.getAllowedDomains()).isEmpty();
assertThat(saved.allowedDomains()).isEmpty();
}
private byte[] samplePng() throws IOException { private byte[] samplePng() throws IOException {
BufferedImage image = new BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB); BufferedImage image = new BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB);
ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream out = new ByteArrayOutputStream();
@@ -247,6 +274,7 @@ class ChannelDirectoryServiceTest {
Map<String, dev.kruhlmann.imgfloat.model.VisualAsset> visualAssets = new ConcurrentHashMap<>(); Map<String, dev.kruhlmann.imgfloat.model.VisualAsset> visualAssets = new ConcurrentHashMap<>();
Map<String, AudioAsset> audioAssets = new ConcurrentHashMap<>(); Map<String, AudioAsset> audioAssets = new ConcurrentHashMap<>();
Map<String, ScriptAsset> scriptAssets = new ConcurrentHashMap<>(); Map<String, ScriptAsset> scriptAssets = new ConcurrentHashMap<>();
Map<String, dev.kruhlmann.imgfloat.model.ScriptAssetFile> scriptFiles = new ConcurrentHashMap<>();
when(channelRepository.findById(anyString())).thenAnswer((invocation) -> when(channelRepository.findById(anyString())).thenAnswer((invocation) ->
Optional.ofNullable(channels.get(invocation.getArgument(0))) Optional.ofNullable(channels.get(invocation.getArgument(0)))
@@ -353,12 +381,26 @@ class ChannelDirectoryServiceTest {
return scriptAssets return scriptAssets
.values() .values()
.stream() .stream()
.filter((script) -> ids.contains(script.getId())) .filter((script) -> ids.contains(script.getId()))
.toList(); .toList();
}); });
doAnswer((invocation) -> scriptAssets.remove(invocation.getArgument(0, String.class))) doAnswer((invocation) -> scriptAssets.remove(invocation.getArgument(0, String.class)))
.when(scriptAssetRepository) .when(scriptAssetRepository)
.deleteById(anyString()); .deleteById(anyString());
when(scriptAssetFileRepository.save(any(dev.kruhlmann.imgfloat.model.ScriptAssetFile.class))).thenAnswer(
(invocation) -> {
dev.kruhlmann.imgfloat.model.ScriptAssetFile file = invocation.getArgument(0);
if (file.getId() == null) {
file.setId(java.util.UUID.randomUUID().toString());
}
scriptFiles.put(file.getId(), file);
return file;
}
);
when(scriptAssetFileRepository.findById(anyString())).thenAnswer((invocation) ->
Optional.ofNullable(scriptFiles.get(invocation.getArgument(0)))
);
} }
private List<Asset> filterAssetsByBroadcaster(Collection<Asset> assets, String broadcaster) { private List<Asset> filterAssetsByBroadcaster(Collection<Asset> assets, String broadcaster) {

View File

@@ -50,6 +50,7 @@ class SystemEnvironmentValidatorTest {
ReflectionTestUtils.setField(validator, "assetsPath", "/tmp/assets"); ReflectionTestUtils.setField(validator, "assetsPath", "/tmp/assets");
ReflectionTestUtils.setField(validator, "previewsPath", "/tmp/previews"); ReflectionTestUtils.setField(validator, "previewsPath", "/tmp/previews");
ReflectionTestUtils.setField(validator, "dbPath", "/tmp/db"); ReflectionTestUtils.setField(validator, "dbPath", "/tmp/db");
ReflectionTestUtils.setField(validator, "auditDbPath", "/tmp/audit.db");
ReflectionTestUtils.setField(validator, "initialSysadmin", "admin"); ReflectionTestUtils.setField(validator, "initialSysadmin", "admin");
ReflectionTestUtils.setField(validator, "githubClientOwner", "owner"); ReflectionTestUtils.setField(validator, "githubClientOwner", "owner");
ReflectionTestUtils.setField(validator, "githubClientRepo", "repo"); ReflectionTestUtils.setField(validator, "githubClientRepo", "repo");

View File

@@ -0,0 +1 @@
org.mockito.internal.creation.bytebuddy.SubclassByteBuddyMockMaker