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",
|
"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:
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
2
pom.xml
2
pom.xml
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
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 : [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
org.mockito.internal.creation.bytebuddy.SubclassByteBuddyMockMaker
|
||||||
Reference in New Issue
Block a user