From c3019a1c48f2bd8b483cfdc8ea6d33eb66815af1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Wed, 14 Jan 2026 00:44:58 +0100 Subject: [PATCH] Gate twitch integration --- .../controller/ChannelApiController.java | 20 ++++++ .../dev/kruhlmann/imgfloat/model/Channel.java | 22 +++++++ .../model/ChannelScriptSettingsRequest.java | 30 +++++++++ .../service/ChannelDirectoryService.java | 23 +++++++ .../migration/V2__channel_script_settings.sql | 10 +++ src/main/resources/static/css/styles.css | 6 ++ src/main/resources/static/js/broadcast.js | 56 ++++++++++++----- .../resources/static/js/broadcast/renderer.js | 63 ++++++++++++------- src/main/resources/static/js/dashboard.js | 53 ++++++++++++++++ src/main/resources/templates/dashboard.html | 22 +++++++ 10 files changed, 267 insertions(+), 38 deletions(-) create mode 100644 src/main/java/dev/kruhlmann/imgfloat/model/ChannelScriptSettingsRequest.java create mode 100644 src/main/resources/db/migration/V2__channel_script_settings.sql diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java index 6472c56..ec7be05 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java @@ -7,6 +7,7 @@ import static org.springframework.http.HttpStatus.NOT_FOUND; import dev.kruhlmann.imgfloat.model.AdminRequest; import dev.kruhlmann.imgfloat.model.AssetView; import dev.kruhlmann.imgfloat.model.CanvasSettingsRequest; +import dev.kruhlmann.imgfloat.model.ChannelScriptSettingsRequest; import dev.kruhlmann.imgfloat.model.CodeAssetRequest; import dev.kruhlmann.imgfloat.model.OauthSessionUser; import dev.kruhlmann.imgfloat.model.PlaybackRequest; @@ -209,6 +210,25 @@ public class ChannelApiController { return channelDirectoryService.updateCanvasSettings(broadcaster, request); } + @GetMapping("/settings") + public ChannelScriptSettingsRequest getScriptSettings(@PathVariable("broadcaster") String broadcaster) { + return channelDirectoryService.getChannelScriptSettings(broadcaster); + } + + @PutMapping("/settings") + public ChannelScriptSettingsRequest updateScriptSettings( + @PathVariable("broadcaster") String broadcaster, + @Valid @RequestBody ChannelScriptSettingsRequest request, + OAuth2AuthenticationToken oauthToken + ) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + String logBroadcaster = LogSanitizer.sanitize(broadcaster); + String logSessionUsername = LogSanitizer.sanitize(sessionUsername); + authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername); + LOG.info("Updating script settings for {} by {}", logBroadcaster, logSessionUsername); + return channelDirectoryService.updateChannelScriptSettings(broadcaster, request); + } + @PostMapping(value = "/assets", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity createAsset( @PathVariable("broadcaster") String broadcaster, diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/Channel.java b/src/main/java/dev/kruhlmann/imgfloat/model/Channel.java index 19e8749..618a3a6 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/Channel.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/Channel.java @@ -33,6 +33,12 @@ public class Channel { private double canvasHeight = 1080; + @Column(name = "allow_channel_emotes_for_assets", nullable = false) + private boolean allowChannelEmotesForAssets = true; + + @Column(name = "allow_script_chat_access", nullable = false) + private boolean allowScriptChatAccess = true; + @Column(name = "created_at", nullable = false, updatable = false) private Instant createdAt; @@ -79,6 +85,22 @@ public class Channel { this.canvasHeight = canvasHeight; } + public boolean isAllowChannelEmotesForAssets() { + return allowChannelEmotesForAssets; + } + + public void setAllowChannelEmotesForAssets(boolean allowChannelEmotesForAssets) { + this.allowChannelEmotesForAssets = allowChannelEmotesForAssets; + } + + public boolean isAllowScriptChatAccess() { + return allowScriptChatAccess; + } + + public void setAllowScriptChatAccess(boolean allowScriptChatAccess) { + this.allowScriptChatAccess = allowScriptChatAccess; + } + @PrePersist @PreUpdate public void normalizeFields() { diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/ChannelScriptSettingsRequest.java b/src/main/java/dev/kruhlmann/imgfloat/model/ChannelScriptSettingsRequest.java new file mode 100644 index 0000000..2fc91d5 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/model/ChannelScriptSettingsRequest.java @@ -0,0 +1,30 @@ +package dev.kruhlmann.imgfloat.model; + +public class ChannelScriptSettingsRequest { + + private boolean allowChannelEmotesForAssets = true; + private boolean allowScriptChatAccess = true; + + public ChannelScriptSettingsRequest() {} + + public ChannelScriptSettingsRequest(boolean allowChannelEmotesForAssets, boolean allowScriptChatAccess) { + this.allowChannelEmotesForAssets = allowChannelEmotesForAssets; + this.allowScriptChatAccess = allowScriptChatAccess; + } + + public boolean isAllowChannelEmotesForAssets() { + return allowChannelEmotesForAssets; + } + + public void setAllowChannelEmotesForAssets(boolean allowChannelEmotesForAssets) { + this.allowChannelEmotesForAssets = allowChannelEmotesForAssets; + } + + public boolean isAllowScriptChatAccess() { + return allowScriptChatAccess; + } + + public void setAllowScriptChatAccess(boolean allowScriptChatAccess) { + this.allowScriptChatAccess = allowScriptChatAccess; + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java index 8ccdce6..0b0e739 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java @@ -12,6 +12,7 @@ import dev.kruhlmann.imgfloat.model.AudioAsset; import dev.kruhlmann.imgfloat.model.CanvasEvent; import dev.kruhlmann.imgfloat.model.CanvasSettingsRequest; import dev.kruhlmann.imgfloat.model.Channel; +import dev.kruhlmann.imgfloat.model.ChannelScriptSettingsRequest; import dev.kruhlmann.imgfloat.model.CodeAssetRequest; import dev.kruhlmann.imgfloat.model.MarketplaceScriptHeart; import dev.kruhlmann.imgfloat.model.PlaybackRequest; @@ -193,6 +194,28 @@ public class ChannelDirectoryService { return response; } + public ChannelScriptSettingsRequest getChannelScriptSettings(String broadcaster) { + Channel channel = getOrCreateChannel(broadcaster); + return new ChannelScriptSettingsRequest( + channel.isAllowChannelEmotesForAssets(), + channel.isAllowScriptChatAccess() + ); + } + + public ChannelScriptSettingsRequest updateChannelScriptSettings( + String broadcaster, + ChannelScriptSettingsRequest request + ) { + Channel channel = getOrCreateChannel(broadcaster); + channel.setAllowChannelEmotesForAssets(request.isAllowChannelEmotesForAssets()); + channel.setAllowScriptChatAccess(request.isAllowScriptChatAccess()); + channelRepository.save(channel); + return new ChannelScriptSettingsRequest( + channel.isAllowChannelEmotesForAssets(), + channel.isAllowScriptChatAccess() + ); + } + public Optional createAsset(String broadcaster, MultipartFile file) throws IOException { long fileSize = file.getSize(); if (fileSize > uploadLimitBytes) { diff --git a/src/main/resources/db/migration/V2__channel_script_settings.sql b/src/main/resources/db/migration/V2__channel_script_settings.sql new file mode 100644 index 0000000..7e0a472 --- /dev/null +++ b/src/main/resources/db/migration/V2__channel_script_settings.sql @@ -0,0 +1,10 @@ +ALTER TABLE channels ADD COLUMN allow_channel_emotes_for_assets BOOLEAN NOT NULL DEFAULT TRUE; +ALTER TABLE channels ADD COLUMN allow_script_chat_access BOOLEAN NOT NULL DEFAULT TRUE; + +UPDATE channels +SET allow_channel_emotes_for_assets = TRUE +WHERE allow_channel_emotes_for_assets IS NULL; + +UPDATE channels +SET allow_script_chat_access = TRUE +WHERE allow_script_chat_access IS NULL; diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css index d9a3e0d..e4df6bf 100644 --- a/src/main/resources/static/css/styles.css +++ b/src/main/resources/static/css/styles.css @@ -1929,6 +1929,12 @@ button:disabled:hover { color: #cbd5e1; } +.control-grid .checkbox-row { + flex-direction: row; + align-items: center; + gap: 10px; +} + .control-grid .inline-toggle { align-items: center; justify-content: space-between; diff --git a/src/main/resources/static/js/broadcast.js b/src/main/resources/static/js/broadcast.js index 76fe6bb..87d968a 100644 --- a/src/main/resources/static/js/broadcast.js +++ b/src/main/resources/static/js/broadcast.js @@ -7,6 +7,28 @@ const scriptLayer = document.getElementById("broadcast-script-layer"); setUpElectronWindowFrame(); const renderer = new BroadcastRenderer({ canvas, scriptLayer, broadcaster, showToast }); +const defaultScriptSettings = { + allowChannelEmotesForAssets: true, + allowScriptChatAccess: true, +}; +let currentScriptSettings = { ...defaultScriptSettings }; + +const settingsPromise = fetch(`/api/channels/${encodeURIComponent(broadcaster)}/settings`) + .then((response) => { + if (!response.ok) { + throw new Error("Failed to load channel settings"); + } + return response.json(); + }) + .then((settings) => { + currentScriptSettings = { ...defaultScriptSettings, ...settings }; + renderer.setScriptSettings(currentScriptSettings); + }) + .catch((error) => { + console.warn("Unable to load channel settings", error); + renderer.setScriptSettings(defaultScriptSettings); + }); + fetch(`/api/twitch/emotes?channel=${encodeURIComponent(broadcaster)}`) .then((response) => { if (!response.ok) { @@ -16,20 +38,26 @@ fetch(`/api/twitch/emotes?channel=${encodeURIComponent(broadcaster)}`) }) .then((catalog) => renderer.setEmoteCatalog(catalog)) .catch((error) => console.warn("Unable to load Twitch emotes", error)); -const disconnectChat = connectTwitchChat( - broadcaster, - ({ channel, displayName, message, tags, prefix, raw }) => { - console.log(`[twitch:${broadcaster}] ${displayName}: ${message}`); - renderer.receiveChatMessage({ - channel, - displayName, - message, - tags, - prefix, - raw, - }); - }, -); +let disconnectChat = () => {}; +settingsPromise.finally(() => { + if (!currentScriptSettings.allowScriptChatAccess) { + return; + } + disconnectChat = connectTwitchChat( + broadcaster, + ({ channel, displayName, message, tags, prefix, raw }) => { + console.log(`[twitch:${broadcaster}] ${displayName}: ${message}`); + renderer.receiveChatMessage({ + channel, + displayName, + message, + tags, + prefix, + raw, + }); + }, + ); +}); setUpElectronWindowResizeListener(canvas); renderer.start(); diff --git a/src/main/resources/static/js/broadcast/renderer.js b/src/main/resources/static/js/broadcast/renderer.js index 5436f21..7752bc4 100644 --- a/src/main/resources/static/js/broadcast/renderer.js +++ b/src/main/resources/static/js/broadcast/renderer.js @@ -28,7 +28,11 @@ export class BroadcastRenderer { this.chatMessages = []; this.emoteCatalog = []; this.emoteCatalogById = new Map(); + this.globalEmotes = []; + this.channelEmotes = []; this.lastChatPruneAt = 0; + this.allowChannelEmotesForAssets = true; + this.allowScriptChatAccess = true; this.obsBrowser = !!globalThis.obsstudio; this.supportsAnimatedDecode = @@ -423,6 +427,17 @@ export class BroadcastRenderer { this.updateScriptWorkerEmoteCatalog(); } + setScriptSettings(settings) { + this.allowChannelEmotesForAssets = settings?.allowChannelEmotesForAssets !== false; + this.allowScriptChatAccess = settings?.allowScriptChatAccess !== false; + if (!this.allowScriptChatAccess) { + this.chatMessages = []; + } + this.refreshEmoteCatalog(); + this.updateScriptWorkerChatMessages(); + this.updateScriptWorkerEmoteCatalog(); + } + updateScriptWorkerCanvas() { if (!this.scriptWorker || !this.scriptWorkerReady) { return; @@ -443,7 +458,7 @@ export class BroadcastRenderer { this.scriptWorker.postMessage({ type: "chatMessages", payload: { - messages: this.chatMessages, + messages: this.allowScriptChatAccess ? this.chatMessages : [], }, }); } @@ -461,27 +476,20 @@ export class BroadcastRenderer { } setEmoteCatalog(catalog) { - const globalEmotes = Array.isArray(catalog?.global) ? catalog.global : []; - const channelEmotes = Array.isArray(catalog?.channel) ? catalog.channel : []; - this.emoteCatalog = [...globalEmotes, ...channelEmotes]; + this.globalEmotes = Array.isArray(catalog?.global) ? catalog.global : []; + this.channelEmotes = Array.isArray(catalog?.channel) ? catalog.channel : []; + this.refreshEmoteCatalog(); + } + + refreshEmoteCatalog() { + const allowedChannelEmotes = this.allowChannelEmotesForAssets ? this.channelEmotes : []; + this.emoteCatalog = [...this.globalEmotes, ...allowedChannelEmotes]; this.emoteCatalogById = new Map( this.emoteCatalog.map((entry) => [String(entry?.id || ""), entry]).filter(([key]) => key), ); if (this.chatMessages.length) { this.chatMessages = this.chatMessages.map((message) => { - if (!Array.isArray(message.fragments)) { - return message; - } - const fragments = message.fragments.map((fragment) => { - if (fragment.type !== "emote" || fragment.url) { - return fragment; - } - const emoteInfo = this.emoteCatalogById.get(String(fragment.id)); - if (!emoteInfo) { - return fragment; - } - return { ...fragment, url: emoteInfo.url, name: emoteInfo.name || fragment.name }; - }); + const fragments = this.buildMessageFragments(message.message || "", message.tags); return { ...message, fragments }; }); this.updateScriptWorkerChatMessages(); @@ -537,13 +545,17 @@ export class BroadcastRenderer { } const emoteText = message.slice(emote.start, emote.end + 1); const emoteInfo = this.emoteCatalogById.get(String(emote.id)); - fragments.push({ - type: "emote", - id: emote.id, - text: emoteText, - name: emoteInfo?.name || emoteText, - url: emoteInfo?.url || null, - }); + if (emoteInfo) { + fragments.push({ + type: "emote", + id: emote.id, + text: emoteText, + name: emoteInfo?.name || emoteText, + url: emoteInfo?.url || null, + }); + } else { + fragments.push({ type: "text", text: emoteText }); + } cursor = emote.end + 1; }); if (cursor < message.length) { @@ -553,6 +565,9 @@ export class BroadcastRenderer { } receiveChatMessage(message) { + if (!this.allowScriptChatAccess) { + return; + } if (!message) { return; } diff --git a/src/main/resources/static/js/dashboard.js b/src/main/resources/static/js/dashboard.js index fad6b86..50072ed 100644 --- a/src/main/resources/static/js/dashboard.js +++ b/src/main/resources/static/js/dashboard.js @@ -7,6 +7,10 @@ const elements = { canvasHeight: document.getElementById("canvas-height"), canvasStatus: document.getElementById("canvas-status"), canvasSaveButton: document.getElementById("save-canvas-btn"), + allowChannelEmotes: document.getElementById("allow-channel-emotes"), + allowScriptChat: document.getElementById("allow-script-chat"), + scriptSettingsStatus: document.getElementById("script-settings-status"), + scriptSettingsSaveButton: document.getElementById("save-script-settings-btn"), }; const apiBase = `/api/channels/${encodeURIComponent(broadcaster)}`; @@ -242,6 +246,54 @@ async function saveCanvasSettings() { } } +function renderScriptSettings(settings) { + if (elements.allowChannelEmotes) { + elements.allowChannelEmotes.checked = settings.allowChannelEmotesForAssets !== false; + } + if (elements.allowScriptChat) { + elements.allowScriptChat.checked = settings.allowScriptChatAccess !== false; + } +} + +async function fetchScriptSettings() { + try { + const data = await fetchJson("/settings", {}, "Failed to load script settings"); + renderScriptSettings(data); + } catch (error) { + renderScriptSettings({ allowChannelEmotesForAssets: true, allowScriptChatAccess: true }); + showToast("Using default script settings. Unable to load saved preferences.", "warning"); + } +} + +async function saveScriptSettings() { + const allowChannelEmotesForAssets = elements.allowChannelEmotes?.checked ?? true; + const allowScriptChatAccess = elements.allowScriptChat?.checked ?? true; + if (elements.scriptSettingsStatus) elements.scriptSettingsStatus.textContent = "Saving..."; + setButtonBusy(elements.scriptSettingsSaveButton, true, "Saving..."); + try { + const settings = await fetchJson( + "/settings", + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ allowChannelEmotesForAssets, allowScriptChatAccess }), + }, + "Failed to save script settings", + ); + renderScriptSettings(settings); + if (elements.scriptSettingsStatus) elements.scriptSettingsStatus.textContent = "Saved."; + showToast("Script settings saved successfully.", "success"); + setTimeout(() => { + if (elements.scriptSettingsStatus) elements.scriptSettingsStatus.textContent = ""; + }, 2000); + } catch (error) { + if (elements.scriptSettingsStatus) elements.scriptSettingsStatus.textContent = "Unable to save right now."; + showToast("Unable to save script settings. Please retry.", "error"); + } finally { + setButtonBusy(elements.scriptSettingsSaveButton, false, "Saving..."); + } +} + if (elements.adminInput) { elements.adminInput.addEventListener("keydown", (event) => { if (event.key === "Enter") { @@ -254,3 +306,4 @@ if (elements.adminInput) { fetchAdmins(); fetchSuggestedAdmins(); fetchCanvasSettings(); +fetchScriptSettings(); diff --git a/src/main/resources/templates/dashboard.html b/src/main/resources/templates/dashboard.html index 8d5241f..fd7d607 100644 --- a/src/main/resources/templates/dashboard.html +++ b/src/main/resources/templates/dashboard.html @@ -85,6 +85,28 @@ +
+

Script privacy

+

Script asset access

+

Control what data scripts can access in your channel.

+
+ + +
+
+ + +
+
+