New sysadmin env var

This commit is contained in:
2026-01-13 21:21:28 +01:00
parent 559199d4e5
commit 467977ddac
5 changed files with 182 additions and 125 deletions

View File

@@ -29,6 +29,7 @@ Optional:
|----------|-------------|---------------| |----------|-------------|---------------|
| `IMGFLOAT_COMMIT_URL_PREFIX` | Git commit URL prefix used for the build link badge (unset to hide the badge) | https://github.com/imgfloat/server/commit/ | | `IMGFLOAT_COMMIT_URL_PREFIX` | Git commit URL prefix used for the build link badge (unset to hide the badge) | https://github.com/imgfloat/server/commit/ |
| `IMGFLOAT_MARKETPLACE_SCRIPTS_PATH` | Filesystem path to marketplace script seed directories (each containing `metadata.json`, optional `source.js`, optional `logo.png`, and optional `attachments/`) | /var/imgfloat/marketplace-scripts | | `IMGFLOAT_MARKETPLACE_SCRIPTS_PATH` | Filesystem path to marketplace script seed directories (each containing `metadata.json`, optional `source.js`, optional `logo.png`, and optional `attachments/`) | /var/imgfloat/marketplace-scripts |
| `IMGFLOAT_SYSADMIN_CHANNEL_ACCESS_ENABLED` | Allow sysadmins to manage any channel without being listed as a channel admin | true |
| `TWITCH_REDIRECT_URI` | Override default redirect URI | http://localhost:8080/login/oauth2/code/twitch | | `TWITCH_REDIRECT_URI` | Override default redirect URI | http://localhost:8080/login/oauth2/code/twitch |
| `IMGFLOAT_TOKEN_ENCRYPTION_PREVIOUS_KEYS` | Comma-delimited base64 keys to allow decryption after key rotation (oldest last) | oldKey1==,oldKey2== | | `IMGFLOAT_TOKEN_ENCRYPTION_PREVIOUS_KEYS` | Comma-delimited base64 keys to allow decryption after key rotation (oldest last) | oldKey1==,oldKey2== |

View File

@@ -7,6 +7,7 @@ import static org.springframework.http.HttpStatus.UNAUTHORIZED;
import dev.kruhlmann.imgfloat.util.LogSanitizer; import dev.kruhlmann.imgfloat.util.LogSanitizer;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
@@ -17,13 +18,16 @@ public class AuthorizationService {
private final ChannelDirectoryService channelDirectoryService; private final ChannelDirectoryService channelDirectoryService;
private final SystemAdministratorService systemAdministratorService; private final SystemAdministratorService systemAdministratorService;
private final boolean sysadminChannelAccessEnabled;
public AuthorizationService( public AuthorizationService(
ChannelDirectoryService channelDirectoryService, ChannelDirectoryService channelDirectoryService,
SystemAdministratorService systemAdministratorService SystemAdministratorService systemAdministratorService,
@Value("${IMGFLOAT_SYSADMIN_CHANNEL_ACCESS_ENABLED:true}") boolean sysadminChannelAccessEnabled
) { ) {
this.channelDirectoryService = channelDirectoryService; this.channelDirectoryService = channelDirectoryService;
this.systemAdministratorService = systemAdministratorService; this.systemAdministratorService = systemAdministratorService;
this.sysadminChannelAccessEnabled = sysadminChannelAccessEnabled;
} }
public void userMatchesSessionUsernameOrThrowHttpError(String submittedUsername, String sessionUsername) { public void userMatchesSessionUsernameOrThrowHttpError(String submittedUsername, String sessionUsername) {
@@ -98,7 +102,9 @@ public class AuthorizationService {
public boolean userIsBroadcasterOrChannelAdminForBroadcaster(String broadcaster, String sessionUser) { public boolean userIsBroadcasterOrChannelAdminForBroadcaster(String broadcaster, String sessionUser) {
return ( return (
userIsBroadcaster(sessionUser, broadcaster) || userIsChannelAdminForBroadcaster(broadcaster, sessionUser) userIsBroadcaster(sessionUser, broadcaster)
|| userIsChannelAdminForBroadcaster(broadcaster, sessionUser)
|| (sysadminChannelAccessEnabled && userIsSystemAdministrator(sessionUser))
); );
} }

View File

@@ -978,6 +978,14 @@ button:disabled:hover {
flex-wrap: wrap; flex-wrap: wrap;
} }
.field-stack {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
min-width: 220px;
}
.inline-form input { .inline-form input {
flex: 1; flex: 1;
min-width: 220px; min-width: 220px;
@@ -988,6 +996,11 @@ button:disabled:hover {
color: #e2e8f0; color: #e2e8f0;
} }
.form-helper {
font-size: 12px;
color: #94a3b8;
}
.card-section { .card-section {
margin-top: 16px; margin-top: 16px;
display: flex; display: flex;
@@ -2037,6 +2050,14 @@ button:disabled:hover {
border: 1px solid #1f2937; border: 1px solid #1f2937;
} }
.stacked-list-item.empty {
justify-content: center;
color: #94a3b8;
font-size: 14px;
border-style: dashed;
background: rgba(15, 23, 42, 0.5);
}
.stacked-list-item .list-title { .stacked-list-item .list-title {
margin: 0; margin: 0;
font-weight: 700; font-weight: 700;

View File

@@ -1,3 +1,16 @@
const elements = {
adminList: document.getElementById("admin-list"),
suggestionList: document.getElementById("admin-suggestions"),
adminInput: document.getElementById("new-admin"),
addAdminButton: document.getElementById("add-admin-btn"),
canvasWidth: document.getElementById("canvas-width"),
canvasHeight: document.getElementById("canvas-height"),
canvasStatus: document.getElementById("canvas-status"),
canvasSaveButton: document.getElementById("save-canvas-btn"),
};
const apiBase = `/api/channels/${encodeURIComponent(broadcaster)}`;
function buildIdentity(admin) { function buildIdentity(admin) {
const identity = document.createElement("div"); const identity = document.createElement("div");
identity.className = "identity-row"; identity.className = "identity-row";
@@ -29,13 +42,13 @@ function buildIdentity(admin) {
} }
function renderAdmins(list) { function renderAdmins(list) {
const adminList = document.getElementById("admin-list"); if (!elements.adminList) return;
if (!adminList) return; elements.adminList.innerHTML = "";
adminList.innerHTML = "";
if (!list || list.length === 0) { if (!list || list.length === 0) {
const empty = document.createElement("li"); const empty = document.createElement("li");
empty.textContent = "No channel admins yet"; empty.className = "stacked-list-item empty";
adminList.appendChild(empty); empty.textContent = "No channel admins yet.";
elements.adminList.appendChild(empty);
return; return;
} }
@@ -56,20 +69,19 @@ function renderAdmins(list) {
actions.appendChild(removeBtn); actions.appendChild(removeBtn);
li.appendChild(actions); li.appendChild(actions);
adminList.appendChild(li); elements.adminList.appendChild(li);
}); });
} }
function renderSuggestedAdmins(list) { function renderSuggestedAdmins(list) {
const suggestionList = document.getElementById("admin-suggestions"); if (!elements.suggestionList) return;
if (!suggestionList) return;
suggestionList.innerHTML = ""; elements.suggestionList.innerHTML = "";
if (!list || list.length === 0) { if (!list || list.length === 0) {
const empty = document.createElement("li"); const empty = document.createElement("li");
empty.className = "stacked-list-item"; empty.className = "stacked-list-item empty";
empty.textContent = "No moderator suggestions right now"; empty.textContent = "No moderator suggestions right now.";
suggestionList.appendChild(empty); elements.suggestionList.appendChild(empty);
return; return;
} }
@@ -90,138 +102,152 @@ function renderSuggestedAdmins(list) {
actions.appendChild(addBtn); actions.appendChild(addBtn);
li.appendChild(actions); li.appendChild(actions);
suggestionList.appendChild(li); elements.suggestionList.appendChild(li);
}); });
} }
function fetchSuggestedAdmins() { function normalizeUsername(value) {
fetch(`/api/channels/${broadcaster}/admins/suggestions`) return (value || "").trim().replace(/^@+/, "");
.then((r) => { }
if (!r.ok) {
throw new Error("Failed to load admin suggestions"); function setButtonBusy(button, isBusy, busyLabel) {
if (!button) return;
if (!button.dataset.defaultLabel) {
button.dataset.defaultLabel = button.textContent;
} }
return r.json(); button.disabled = isBusy;
}) if (busyLabel) {
.then(renderSuggestedAdmins) button.textContent = isBusy ? busyLabel : button.dataset.defaultLabel;
.catch(() => { }
}
async function fetchJson(path, options = {}, errorMessage = "Request failed") {
const response = await fetch(`${apiBase}${path}`, options);
if (!response.ok) {
throw new Error(errorMessage);
}
return response.json();
}
async function fetchSuggestedAdmins() {
try {
const data = await fetchJson("/admins/suggestions", {}, "Failed to load admin suggestions");
renderSuggestedAdmins(data);
} catch (error) {
renderSuggestedAdmins([]); renderSuggestedAdmins([]);
}); }
} }
function fetchAdmins() { async function fetchAdmins() {
fetch(`/api/channels/${broadcaster}/admins`) try {
.then((r) => { const data = await fetchJson("/admins", {}, "Failed to load admins");
if (!r.ok) { renderAdmins(data);
throw new Error("Failed to load admins"); } catch (error) {
}
return r.json();
})
.then(renderAdmins)
.catch(() => {
renderAdmins([]); renderAdmins([]);
showToast("Unable to load admins right now. Please try again.", "error"); showToast("Unable to load admins right now. Please try again.", "error");
});
}
function removeAdmin(username) {
if (!username) return;
fetch(`/api/channels/${encodeURIComponent(broadcaster)}/admins/${encodeURIComponent(username)}`, {
method: "DELETE",
})
.then((response) => {
if (!response.ok) {
throw new Error();
} }
fetchAdmins();
fetchSuggestedAdmins();
})
.catch(() => {
showToast("Failed to remove admin. Please retry.", "error");
});
} }
function addAdmin(usernameFromAction) { async function removeAdmin(username) {
const input = document.getElementById("new-admin"); if (!username) return;
const username = (usernameFromAction || input?.value || "").trim(); try {
const response = await fetch(
`${apiBase}/admins/${encodeURIComponent(username)}`,
{ method: "DELETE" },
);
if (!response.ok) {
throw new Error("Remove admin failed");
}
await Promise.all([fetchAdmins(), fetchSuggestedAdmins()]);
} catch (error) {
showToast("Failed to remove admin. Please retry.", "error");
}
}
async function addAdmin(usernameFromAction) {
const username = normalizeUsername(usernameFromAction || elements.adminInput?.value);
if (!username) { if (!username) {
showToast("Enter a Twitch username to add as an admin.", "info"); showToast("Enter a Twitch username to add as an admin.", "info");
return; return;
} }
fetch(`/api/channels/${broadcaster}/admins`, { setButtonBusy(elements.addAdminButton, true, "Adding...");
try {
await fetchJson(
"/admins",
{
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username }), body: JSON.stringify({ username }),
}) },
.then((response) => { "Add admin failed",
if (!response.ok) { );
throw new Error("Add admin failed"); if (elements.adminInput) {
} elements.adminInput.value = "";
if (input) {
input.value = "";
} }
showToast(`Added @${username} as an admin.`, "success"); showToast(`Added @${username} as an admin.`, "success");
fetchAdmins(); await Promise.all([fetchAdmins(), fetchSuggestedAdmins()]);
fetchSuggestedAdmins(); } catch (error) {
}) showToast("Unable to add admin right now. Please try again.", "error");
.catch(() => showToast("Unable to add admin right now. Please try again.", "error")); } finally {
setButtonBusy(elements.addAdminButton, false, "Adding...");
}
} }
function renderCanvasSettings(settings) { function renderCanvasSettings(settings) {
const widthInput = document.getElementById("canvas-width"); if (elements.canvasWidth) elements.canvasWidth.value = Math.round(settings.width);
const heightInput = document.getElementById("canvas-height"); if (elements.canvasHeight) elements.canvasHeight.value = Math.round(settings.height);
if (widthInput) widthInput.value = Math.round(settings.width);
if (heightInput) heightInput.value = Math.round(settings.height);
} }
function fetchCanvasSettings() { async function fetchCanvasSettings() {
fetch(`/api/channels/${broadcaster}/canvas`) try {
.then((r) => { const data = await fetchJson("/canvas", {}, "Failed to load canvas settings");
if (!r.ok) { renderCanvasSettings(data);
throw new Error("Failed to load canvas settings"); } catch (error) {
}
return r.json();
})
.then(renderCanvasSettings)
.catch(() => {
renderCanvasSettings({ width: 1920, height: 1080 }); renderCanvasSettings({ width: 1920, height: 1080 });
showToast("Using default canvas size. Unable to load saved settings.", "warning"); showToast("Using default canvas size. Unable to load saved settings.", "warning");
}); }
} }
function saveCanvasSettings() { async function saveCanvasSettings() {
const widthInput = document.getElementById("canvas-width"); const width = parseFloat(elements.canvasWidth?.value) || 0;
const heightInput = document.getElementById("canvas-height"); const height = parseFloat(elements.canvasHeight?.value) || 0;
const status = document.getElementById("canvas-status");
const width = parseFloat(widthInput?.value) || 0;
const height = parseFloat(heightInput?.value) || 0;
if (width <= 0 || height <= 0) { if (width <= 0 || height <= 0) {
showToast("Please enter a valid width and height.", "info"); showToast("Please enter a valid width and height.", "info");
return; return;
} }
if (status) status.textContent = "Saving..."; if (elements.canvasStatus) elements.canvasStatus.textContent = "Saving...";
fetch(`/api/channels/${broadcaster}/canvas`, { setButtonBusy(elements.canvasSaveButton, true, "Saving...");
try {
const settings = await fetchJson(
"/canvas",
{
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ width, height }), body: JSON.stringify({ width, height }),
}) },
.then((r) => { "Failed to save canvas",
if (!r.ok) { );
throw new Error("Failed to save canvas");
}
return r.json();
})
.then((settings) => {
renderCanvasSettings(settings); renderCanvasSettings(settings);
if (status) status.textContent = "Saved."; if (elements.canvasStatus) elements.canvasStatus.textContent = "Saved.";
showToast("Canvas size saved successfully.", "success"); showToast("Canvas size saved successfully.", "success");
setTimeout(() => { setTimeout(() => {
if (status) status.textContent = ""; if (elements.canvasStatus) elements.canvasStatus.textContent = "";
}, 2000); }, 2000);
}) } catch (error) {
.catch(() => { if (elements.canvasStatus) elements.canvasStatus.textContent = "Unable to save right now.";
if (status) status.textContent = "Unable to save right now.";
showToast("Unable to save canvas size. Please retry.", "error"); showToast("Unable to save canvas size. Please retry.", "error");
} finally {
setButtonBusy(elements.canvasSaveButton, false, "Saving...");
}
}
if (elements.adminInput) {
elements.adminInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
addAdmin();
}
}); });
} }

View File

@@ -54,8 +54,8 @@
</label> </label>
</div> </div>
<div class="control-actions"> <div class="control-actions">
<button type="button" onclick="saveCanvasSettings()">Save canvas size</button> <button id="save-canvas-btn" type="button" onclick="saveCanvasSettings()">Save canvas size</button>
<span id="canvas-status" class="muted"></span> <span id="canvas-status" class="muted" role="status" aria-live="polite"></span>
</div> </div>
</section> </section>
@@ -69,8 +69,11 @@
</div> </div>
</div> </div>
<div class="inline-form"> <div class="inline-form">
<input id="new-admin" placeholder="Twitch username" /> <div class="field-stack">
<button type="button" onclick="addAdmin()">Add admin</button> <input id="new-admin" placeholder="Twitch username" autocomplete="off" />
<span class="form-helper">Enter the username without the @ symbol.</span>
</div>
<button id="add-admin-btn" type="button" onclick="addAdmin()">Add admin</button>
</div> </div>
<div class="card-section"> <div class="card-section">
<div class="section-header"> <div class="section-header">