mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Add domain allow-list for script assets
This commit is contained in:
@@ -74,11 +74,14 @@ marketplace-scripts/
|
||||
{
|
||||
"name": "Script display name",
|
||||
"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/`.
|
||||
|
||||
`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
|
||||
|
||||
To run the application:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
|
||||
2
pom.xml
2
pom.xml
@@ -14,7 +14,7 @@
|
||||
<java.version>17</java.version>
|
||||
<spring.boot.version>3.2.5</spring.boot.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>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||
|
||||
@@ -22,6 +22,7 @@ public record AssetView(
|
||||
String mediaType,
|
||||
String originalMediaType,
|
||||
AssetType assetType,
|
||||
List<String> allowedDomains,
|
||||
List<ScriptAssetAttachmentView> scriptAttachments,
|
||||
Boolean audioLoop,
|
||||
Integer audioDelayMillis,
|
||||
@@ -54,6 +55,7 @@ public record AssetView(
|
||||
visual.getMediaType(),
|
||||
visual.getOriginalMediaType(),
|
||||
asset.getAssetType(),
|
||||
List.of(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
@@ -87,6 +89,7 @@ public record AssetView(
|
||||
audio.getMediaType(),
|
||||
audio.getOriginalMediaType(),
|
||||
asset.getAssetType(),
|
||||
List.of(),
|
||||
null,
|
||||
audio.isAudioLoop(),
|
||||
audio.getAudioDelayMillis(),
|
||||
@@ -122,6 +125,7 @@ public record AssetView(
|
||||
script.getMediaType(),
|
||||
script.getOriginalMediaType(),
|
||||
asset.getAssetType(),
|
||||
script.getAllowedDomains(),
|
||||
script.getAttachments(),
|
||||
null,
|
||||
null,
|
||||
|
||||
@@ -14,6 +14,8 @@ public class CodeAssetRequest {
|
||||
|
||||
private Boolean isPublic;
|
||||
|
||||
private java.util.List<String> allowedDomains;
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
@@ -45,4 +47,12 @@ public class CodeAssetRequest {
|
||||
public void setIsPublic(Boolean isPublic) {
|
||||
this.isPublic = isPublic;
|
||||
}
|
||||
|
||||
public java.util.List<String> getAllowedDomains() {
|
||||
return allowedDomains;
|
||||
}
|
||||
|
||||
public void setAllowedDomains(java.util.List<String> allowedDomains) {
|
||||
this.allowedDomains = allowedDomains;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
package dev.kruhlmann.imgfloat.model;
|
||||
|
||||
import jakarta.persistence.CollectionTable;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.ElementCollection;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.PreUpdate;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Transient;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Entity
|
||||
@@ -33,6 +38,11 @@ public class ScriptAsset {
|
||||
@Column(name = "source_file_id")
|
||||
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
|
||||
private List<ScriptAssetAttachmentView> attachments = List.of();
|
||||
|
||||
@@ -49,6 +59,12 @@ public class ScriptAsset {
|
||||
if (this.name == null || this.name.isBlank()) {
|
||||
this.name = this.id;
|
||||
}
|
||||
if (this.allowedDomains == null) {
|
||||
this.allowedDomains = new ArrayList<>();
|
||||
}
|
||||
if (this.attachments == null) {
|
||||
this.attachments = List.of();
|
||||
}
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
@@ -115,6 +131,22 @@ public class ScriptAsset {
|
||||
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() {
|
||||
return attachments == null ? List.of() : attachments;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ public record ScriptMarketplaceEntry(
|
||||
String description,
|
||||
String logoUrl,
|
||||
String broadcaster,
|
||||
java.util.List<String> allowedDomains,
|
||||
long heartsCount,
|
||||
boolean hearted
|
||||
) {}
|
||||
|
||||
@@ -39,6 +39,7 @@ import dev.kruhlmann.imgfloat.service.media.MediaOptimizationService;
|
||||
import dev.kruhlmann.imgfloat.service.media.OptimizedAsset;
|
||||
import dev.kruhlmann.imgfloat.service.media.MediaTypeRegistry;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -62,6 +63,8 @@ public class ChannelDirectoryService {
|
||||
private static final Logger logger = LoggerFactory.getLogger(ChannelDirectoryService.class);
|
||||
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 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 AssetRepository assetRepository;
|
||||
@@ -374,6 +377,7 @@ public class ChannelDirectoryService {
|
||||
script.setMediaType(optimized.mediaType());
|
||||
script.setOriginalMediaType(mediaType);
|
||||
script.setSourceFileId(asset.getId());
|
||||
script.setAllowedDomains(List.of());
|
||||
script.setAttachments(List.of());
|
||||
scriptAssetRepository.save(script);
|
||||
ScriptAssetFile sourceFile = new ScriptAssetFile(asset.getBroadcaster(), AssetType.SCRIPT);
|
||||
@@ -413,6 +417,7 @@ public class ChannelDirectoryService {
|
||||
Channel channel = getOrCreateChannel(broadcaster);
|
||||
byte[] bytes = request.getSource().getBytes(StandardCharsets.UTF_8);
|
||||
enforceUploadLimit(bytes.length);
|
||||
List<String> allowedDomains = normalizeAllowedDomains(request.getAllowedDomains());
|
||||
|
||||
Asset asset = new Asset(channel.getBroadcaster(), AssetType.SCRIPT);
|
||||
asset.setDisplayOrder(nextDisplayOrder(channel.getBroadcaster(), AssetType.SCRIPT));
|
||||
@@ -440,6 +445,7 @@ public class ChannelDirectoryService {
|
||||
script.setSourceFileId(sourceFile.getId());
|
||||
script.setDescription(normalizeDescription(request.getDescription()));
|
||||
script.setPublic(Boolean.TRUE.equals(request.getIsPublic()));
|
||||
script.setAllowedDomains(allowedDomains);
|
||||
script.setAttachments(List.of());
|
||||
scriptAssetRepository.save(script);
|
||||
AssetView view = AssetView.fromScript(channel.getBroadcaster(), asset, script);
|
||||
@@ -458,6 +464,7 @@ public class ChannelDirectoryService {
|
||||
String normalized = normalize(broadcaster);
|
||||
byte[] bytes = request.getSource().getBytes(StandardCharsets.UTF_8);
|
||||
enforceUploadLimit(bytes.length);
|
||||
List<String> allowedDomains = normalizeAllowedDomains(request.getAllowedDomains());
|
||||
|
||||
return assetRepository
|
||||
.findById(assetId)
|
||||
@@ -491,6 +498,7 @@ public class ChannelDirectoryService {
|
||||
if (request.getIsPublic() != null) {
|
||||
script.setPublic(request.getIsPublic());
|
||||
}
|
||||
script.setAllowedDomains(allowedDomains);
|
||||
script.setOriginalMediaType(DEFAULT_CODE_MEDIA_TYPE);
|
||||
script.setMediaType(DEFAULT_CODE_MEDIA_TYPE);
|
||||
script.setAttachments(loadScriptAttachments(normalized, asset.getId(), null));
|
||||
@@ -635,6 +643,7 @@ public class ChannelDirectoryService {
|
||||
script.getDescription(),
|
||||
logoUrl,
|
||||
broadcaster,
|
||||
normalizeAllowedDomainsLenient(script.getAllowedDomains()),
|
||||
0,
|
||||
false
|
||||
);
|
||||
@@ -687,6 +696,7 @@ public class ChannelDirectoryService {
|
||||
script.getDescription(),
|
||||
logoUrl,
|
||||
broadcaster,
|
||||
normalizeAllowedDomainsLenient(script.getAllowedDomains()),
|
||||
0,
|
||||
false
|
||||
);
|
||||
@@ -715,6 +725,7 @@ public class ChannelDirectoryService {
|
||||
entry.description(),
|
||||
entry.logoUrl(),
|
||||
entry.broadcaster(),
|
||||
entry.allowedDomains(),
|
||||
heartCount,
|
||||
hearted
|
||||
);
|
||||
@@ -769,6 +780,7 @@ public class ChannelDirectoryService {
|
||||
entry.description(),
|
||||
entry.logoUrl(),
|
||||
entry.broadcaster(),
|
||||
entry.allowedDomains(),
|
||||
counts.getOrDefault(entry.id(), 0L),
|
||||
heartedIds.contains(entry.id())
|
||||
)
|
||||
@@ -845,6 +857,7 @@ public class ChannelDirectoryService {
|
||||
script.setOriginalMediaType(sourceContent.mediaType());
|
||||
script.setSourceFileId(sourceFile.getId());
|
||||
script.setLogoFileId(sourceScript.getLogoFileId());
|
||||
script.setAllowedDomains(normalizeAllowedDomains(sourceScript.getAllowedDomains()));
|
||||
script.setAttachments(List.of());
|
||||
scriptAssetRepository.save(script);
|
||||
|
||||
@@ -889,6 +902,7 @@ public class ChannelDirectoryService {
|
||||
if (sourceContent == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
List<String> allowedDomains = normalizeAllowedDomains(seedScript.allowedDomains());
|
||||
|
||||
Asset asset = new Asset(targetBroadcaster, AssetType.SCRIPT);
|
||||
asset.setDisplayOrder(nextDisplayOrder(targetBroadcaster, AssetType.SCRIPT));
|
||||
@@ -915,6 +929,7 @@ public class ChannelDirectoryService {
|
||||
script.setMediaType(sourceContent.mediaType());
|
||||
script.setOriginalMediaType(sourceContent.mediaType());
|
||||
script.setSourceFileId(sourceFile.getId());
|
||||
script.setAllowedDomains(allowedDomains);
|
||||
script.setAttachments(List.of());
|
||||
scriptAssetRepository.save(script);
|
||||
|
||||
@@ -1555,6 +1570,61 @@ public class ChannelDirectoryService {
|
||||
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) {
|
||||
if (fileId == null || fileId.isBlank()) {
|
||||
return;
|
||||
|
||||
@@ -3,6 +3,7 @@ package dev.kruhlmann.imgfloat.service;
|
||||
import dev.kruhlmann.imgfloat.model.ScriptMarketplaceEntry;
|
||||
import dev.kruhlmann.imgfloat.service.media.AssetContent;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.nio.file.DirectoryStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -81,6 +82,7 @@ public class MarketplaceScriptSeedLoader {
|
||||
String logoMediaType,
|
||||
Optional<Path> sourcePath,
|
||||
Optional<Path> logoPath,
|
||||
List<String> allowedDomains,
|
||||
List<SeedAttachment> attachments,
|
||||
AtomicReference<byte[]> sourceBytes,
|
||||
AtomicReference<byte[]> logoBytes
|
||||
@@ -92,6 +94,7 @@ public class MarketplaceScriptSeedLoader {
|
||||
description,
|
||||
logoPath.isPresent() ? "/api/marketplace/scripts/" + id + "/logo" : null,
|
||||
broadcaster,
|
||||
allowedDomains,
|
||||
0,
|
||||
false
|
||||
);
|
||||
@@ -166,6 +169,7 @@ public class MarketplaceScriptSeedLoader {
|
||||
if (attachments == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
List<String> allowedDomains = normalizeAllowedDomains(metadata.allowedDomains());
|
||||
|
||||
return Optional.of(
|
||||
new SeedScript(
|
||||
@@ -177,6 +181,7 @@ public class MarketplaceScriptSeedLoader {
|
||||
logoMediaType,
|
||||
Optional.ofNullable(sourcePath),
|
||||
Optional.ofNullable(logoPath),
|
||||
allowedDomains,
|
||||
attachments,
|
||||
new AtomicReference<>(),
|
||||
new AtomicReference<>()
|
||||
@@ -284,6 +289,46 @@ public class MarketplaceScriptSeedLoader {
|
||||
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) {
|
||||
if (!Files.isRegularFile(filePath)) {
|
||||
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) {
|
||||
if (!Files.isRegularFile(path)) {
|
||||
return null;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -720,6 +720,7 @@ export class BroadcastRenderer {
|
||||
width: scriptCanvas.width,
|
||||
height: scriptCanvas.height,
|
||||
attachments,
|
||||
allowedDomains: Array.isArray(asset.allowedDomains) ? asset.allowedDomains : [],
|
||||
},
|
||||
}, [offscreen]);
|
||||
}
|
||||
@@ -735,6 +736,7 @@ export class BroadcastRenderer {
|
||||
payload: {
|
||||
id: asset.id,
|
||||
attachments,
|
||||
allowedDomains: Array.isArray(asset.allowedDomains) ? asset.allowedDomains : [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ const errorKeys = new Set();
|
||||
const allowedImportUrls = new Set();
|
||||
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 nativeFetch = typeof self.fetch === "function" ? self.fetch.bind(self) : null;
|
||||
let activeScriptId = null;
|
||||
let chatMessages = [];
|
||||
let emoteCatalog = [];
|
||||
|
||||
@@ -49,6 +51,148 @@ function 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() {
|
||||
allowedFetchUrls.clear();
|
||||
scripts.forEach((script) => {
|
||||
@@ -125,6 +269,7 @@ function updateScriptContexts() {
|
||||
script.context.height = script.canvas?.height ?? 0;
|
||||
script.context.chatMessages = chatMessages;
|
||||
script.context.emoteCatalog = emoteCatalog;
|
||||
script.context.allowedDomains = script.allowedDomains;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -151,9 +296,12 @@ function ensureTickLoop() {
|
||||
script.context.deltaMs = deltaMs;
|
||||
script.context.elapsedMs = elapsedMs;
|
||||
try {
|
||||
activeScriptId = script.id;
|
||||
script.tick(script.context, script.state);
|
||||
} catch (error) {
|
||||
console.error(`Script ${script.id} tick failed`, error);
|
||||
} finally {
|
||||
activeScriptId = null;
|
||||
}
|
||||
});
|
||||
}, tickIntervalMs);
|
||||
@@ -168,7 +316,7 @@ function stopTickLoopIfIdle() {
|
||||
|
||||
function createScriptHandlers(source, context, state, sourceLabel = "") {
|
||||
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 factory = new Function(
|
||||
"context",
|
||||
@@ -212,6 +360,7 @@ self.addEventListener("message", (event) => {
|
||||
if (!payload?.id || !payload?.source || !payload?.canvas) {
|
||||
return;
|
||||
}
|
||||
const allowedDomains = sanitizeAllowedDomains(payload.allowedDomains);
|
||||
const canvas = payload.canvas;
|
||||
canvas.width = payload.width || canvas.width;
|
||||
canvas.height = payload.height || canvas.height;
|
||||
@@ -229,6 +378,7 @@ self.addEventListener("message", (event) => {
|
||||
assets: Array.isArray(payload.attachments) ? payload.attachments : [],
|
||||
chatMessages,
|
||||
emoteCatalog,
|
||||
allowedDomains,
|
||||
playAudio: (attachment) => {
|
||||
const attachmentId = typeof attachment === "string" ? attachment : attachment?.id;
|
||||
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 = {};
|
||||
try {
|
||||
activeScriptId = payload.id;
|
||||
handlers = createScriptHandlers(payload.source, context, state, `user-script-${payload.id}.js`);
|
||||
} catch (error) {
|
||||
console.error(`Script ${payload.id} failed to initialize`, error);
|
||||
reportScriptError(payload.id, "initialize", error);
|
||||
return;
|
||||
} finally {
|
||||
activeScriptId = null;
|
||||
}
|
||||
const script = {
|
||||
id: payload.id,
|
||||
canvas,
|
||||
ctx,
|
||||
context,
|
||||
state,
|
||||
init: handlers.init,
|
||||
tick: handlers.tick,
|
||||
};
|
||||
script.init = handlers.init;
|
||||
script.tick = handlers.tick;
|
||||
scripts.set(payload.id, script);
|
||||
refreshAllowedFetchUrls();
|
||||
if (script.init) {
|
||||
try {
|
||||
activeScriptId = script.id;
|
||||
script.init(script.context, script.state);
|
||||
} catch (error) {
|
||||
console.error(`Script ${payload.id} init failed`, error);
|
||||
reportScriptError(payload.id, "init", error);
|
||||
} finally {
|
||||
activeScriptId = null;
|
||||
}
|
||||
}
|
||||
ensureTickLoop();
|
||||
@@ -292,6 +452,8 @@ self.addEventListener("message", (event) => {
|
||||
return;
|
||||
}
|
||||
script.context.assets = Array.isArray(payload.attachments) ? payload.attachments : [];
|
||||
script.allowedDomains = sanitizeAllowedDomains(payload.allowedDomains);
|
||||
script.context.allowedDomains = script.allowedDomains;
|
||||
refreshAllowedFetchUrls();
|
||||
}
|
||||
|
||||
|
||||
@@ -30,10 +30,15 @@ export function createCustomAssetModal({
|
||||
const attachmentInput = document.getElementById("custom-asset-attachment-file");
|
||||
const attachmentList = document.getElementById("custom-asset-attachment-list");
|
||||
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 pendingLogoFile = null;
|
||||
let logoRemoved = false;
|
||||
let attachmentState = [];
|
||||
let allowedDomainState = [];
|
||||
let marketplaceEntries = [];
|
||||
|
||||
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 CodeMirror = globalThis.CodeMirror;
|
||||
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) => {};",
|
||||
);
|
||||
setAttachmentState(null, []);
|
||||
setAllowedDomainState([]);
|
||||
resetErrors();
|
||||
openModal();
|
||||
};
|
||||
@@ -314,6 +413,7 @@ export function createCustomAssetModal({
|
||||
setCodeReadOnly(true);
|
||||
setCodePlaceholder("Loading script...");
|
||||
setAttachmentState(asset.id, asset.scriptAttachments || []);
|
||||
setAllowedDomainState(asset.allowedDomains || []);
|
||||
openModal();
|
||||
|
||||
fetch(asset.url)
|
||||
@@ -373,7 +473,7 @@ export function createCustomAssetModal({
|
||||
submitButton.disabled = true;
|
||||
submitButton.textContent = "Saving...";
|
||||
}
|
||||
saveCodeAsset({ name, src, assetId, description, isPublic })
|
||||
saveCodeAsset({ name, src, assetId, description, isPublic, allowedDomains: allowedDomainState })
|
||||
.then((asset) => {
|
||||
if (asset) {
|
||||
return syncLogoChanges(asset).then((updated) => {
|
||||
@@ -495,14 +595,25 @@ export function createCustomAssetModal({
|
||||
renderAttachmentList();
|
||||
showToast?.("Attachment added.", "success");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
showToast?.(error?.message || "Unable to upload attachment. Please try again.", "error");
|
||||
})
|
||||
.finally(() => {
|
||||
attachmentInput.value = "";
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
showToast?.(error?.message || "Unable to upload attachment. Please try again.", "error");
|
||||
})
|
||||
.finally(() => {
|
||||
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 = {
|
||||
name,
|
||||
source: src,
|
||||
description: description || null,
|
||||
isPublic,
|
||||
allowedDomains: Array.isArray(allowedDomains) ? allowedDomains : [],
|
||||
};
|
||||
const method = assetId ? "PUT" : "POST";
|
||||
const url = assetId
|
||||
@@ -810,6 +922,16 @@ export function createCustomAssetModal({
|
||||
content.appendChild(title);
|
||||
content.appendChild(description);
|
||||
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");
|
||||
actions.className = "marketplace-actions";
|
||||
@@ -854,25 +976,34 @@ export function createCustomAssetModal({
|
||||
return;
|
||||
}
|
||||
const target = marketplaceChannelSelect?.value || broadcaster;
|
||||
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");
|
||||
const allowedDomains = Array.isArray(entry.allowedDomains) ? entry.allowedDomains.filter(Boolean) : [];
|
||||
confirmDomainImport(allowedDomains, target)
|
||||
.then((confirmed) => {
|
||||
if (!confirmed) {
|
||||
return null;
|
||||
}
|
||||
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) => {
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
closeMarketplaceModal();
|
||||
showToast?.("Script imported.", "success");
|
||||
onAssetSaved?.(asset);
|
||||
})
|
||||
.catch((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;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,6 +447,26 @@
|
||||
rows="3"
|
||||
></textarea>
|
||||
</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">
|
||||
<label>Logo (optional)</label>
|
||||
<div class="attachment-actions">
|
||||
|
||||
@@ -14,6 +14,8 @@ import dev.kruhlmann.imgfloat.model.AssetView;
|
||||
import dev.kruhlmann.imgfloat.model.AudioAsset;
|
||||
import dev.kruhlmann.imgfloat.model.Channel;
|
||||
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.TransformRequest;
|
||||
import dev.kruhlmann.imgfloat.model.VisibilityRequest;
|
||||
@@ -219,6 +221,31 @@ class ChannelDirectoryServiceTest {
|
||||
.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 {
|
||||
BufferedImage image = new BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB);
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
@@ -247,6 +274,7 @@ class ChannelDirectoryServiceTest {
|
||||
Map<String, dev.kruhlmann.imgfloat.model.VisualAsset> visualAssets = new ConcurrentHashMap<>();
|
||||
Map<String, AudioAsset> audioAssets = new ConcurrentHashMap<>();
|
||||
Map<String, ScriptAsset> scriptAssets = new ConcurrentHashMap<>();
|
||||
Map<String, dev.kruhlmann.imgfloat.model.ScriptAssetFile> scriptFiles = new ConcurrentHashMap<>();
|
||||
|
||||
when(channelRepository.findById(anyString())).thenAnswer((invocation) ->
|
||||
Optional.ofNullable(channels.get(invocation.getArgument(0)))
|
||||
@@ -353,12 +381,26 @@ class ChannelDirectoryServiceTest {
|
||||
return scriptAssets
|
||||
.values()
|
||||
.stream()
|
||||
.filter((script) -> ids.contains(script.getId()))
|
||||
.toList();
|
||||
.filter((script) -> ids.contains(script.getId()))
|
||||
.toList();
|
||||
});
|
||||
doAnswer((invocation) -> scriptAssets.remove(invocation.getArgument(0, String.class)))
|
||||
.when(scriptAssetRepository)
|
||||
.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) {
|
||||
|
||||
@@ -50,6 +50,7 @@ class SystemEnvironmentValidatorTest {
|
||||
ReflectionTestUtils.setField(validator, "assetsPath", "/tmp/assets");
|
||||
ReflectionTestUtils.setField(validator, "previewsPath", "/tmp/previews");
|
||||
ReflectionTestUtils.setField(validator, "dbPath", "/tmp/db");
|
||||
ReflectionTestUtils.setField(validator, "auditDbPath", "/tmp/audit.db");
|
||||
ReflectionTestUtils.setField(validator, "initialSysadmin", "admin");
|
||||
ReflectionTestUtils.setField(validator, "githubClientOwner", "owner");
|
||||
ReflectionTestUtils.setField(validator, "githubClientRepo", "repo");
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
org.mockito.internal.creation.bytebuddy.SubclassByteBuddyMockMaker
|
||||
Reference in New Issue
Block a user