mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 11:49:25 +00:00
New sysadmin env var
This commit is contained in:
@@ -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== |
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,139 +102,153 @@ 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");
|
|
||||||
}
|
|
||||||
return r.json();
|
|
||||||
})
|
|
||||||
.then(renderSuggestedAdmins)
|
|
||||||
.catch(() => {
|
|
||||||
renderSuggestedAdmins([]);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchAdmins() {
|
function setButtonBusy(button, isBusy, busyLabel) {
|
||||||
fetch(`/api/channels/${broadcaster}/admins`)
|
if (!button) return;
|
||||||
.then((r) => {
|
if (!button.dataset.defaultLabel) {
|
||||||
if (!r.ok) {
|
button.dataset.defaultLabel = button.textContent;
|
||||||
throw new Error("Failed to load admins");
|
}
|
||||||
}
|
button.disabled = isBusy;
|
||||||
return r.json();
|
if (busyLabel) {
|
||||||
})
|
button.textContent = isBusy ? busyLabel : button.dataset.defaultLabel;
|
||||||
.then(renderAdmins)
|
}
|
||||||
.catch(() => {
|
|
||||||
renderAdmins([]);
|
|
||||||
showToast("Unable to load admins right now. Please try again.", "error");
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeAdmin(username) {
|
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([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAdmins() {
|
||||||
|
try {
|
||||||
|
const data = await fetchJson("/admins", {}, "Failed to load admins");
|
||||||
|
renderAdmins(data);
|
||||||
|
} catch (error) {
|
||||||
|
renderAdmins([]);
|
||||||
|
showToast("Unable to load admins right now. Please try again.", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeAdmin(username) {
|
||||||
if (!username) return;
|
if (!username) return;
|
||||||
fetch(`/api/channels/${encodeURIComponent(broadcaster)}/admins/${encodeURIComponent(username)}`, {
|
try {
|
||||||
method: "DELETE",
|
const response = await fetch(
|
||||||
})
|
`${apiBase}/admins/${encodeURIComponent(username)}`,
|
||||||
.then((response) => {
|
{ method: "DELETE" },
|
||||||
if (!response.ok) {
|
);
|
||||||
throw new Error();
|
if (!response.ok) {
|
||||||
}
|
throw new Error("Remove admin failed");
|
||||||
fetchAdmins();
|
}
|
||||||
fetchSuggestedAdmins();
|
await Promise.all([fetchAdmins(), fetchSuggestedAdmins()]);
|
||||||
})
|
} catch (error) {
|
||||||
.catch(() => {
|
showToast("Failed to remove admin. Please retry.", "error");
|
||||||
showToast("Failed to remove admin. Please retry.", "error");
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function addAdmin(usernameFromAction) {
|
async function addAdmin(usernameFromAction) {
|
||||||
const input = document.getElementById("new-admin");
|
const username = normalizeUsername(usernameFromAction || elements.adminInput?.value);
|
||||||
const username = (usernameFromAction || input?.value || "").trim();
|
|
||||||
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...");
|
||||||
method: "POST",
|
try {
|
||||||
headers: { "Content-Type": "application/json" },
|
await fetchJson(
|
||||||
body: JSON.stringify({ username }),
|
"/admins",
|
||||||
})
|
{
|
||||||
.then((response) => {
|
method: "POST",
|
||||||
if (!response.ok) {
|
headers: { "Content-Type": "application/json" },
|
||||||
throw new Error("Add admin failed");
|
body: JSON.stringify({ username }),
|
||||||
}
|
},
|
||||||
if (input) {
|
"Add admin failed",
|
||||||
input.value = "";
|
);
|
||||||
}
|
if (elements.adminInput) {
|
||||||
showToast(`Added @${username} as an admin.`, "success");
|
elements.adminInput.value = "";
|
||||||
fetchAdmins();
|
}
|
||||||
fetchSuggestedAdmins();
|
showToast(`Added @${username} as an admin.`, "success");
|
||||||
})
|
await Promise.all([fetchAdmins(), fetchSuggestedAdmins()]);
|
||||||
.catch(() => showToast("Unable to add admin right now. Please try again.", "error"));
|
} catch (error) {
|
||||||
|
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) {
|
||||||
}
|
renderCanvasSettings({ width: 1920, height: 1080 });
|
||||||
return r.json();
|
showToast("Using default canvas size. Unable to load saved settings.", "warning");
|
||||||
})
|
}
|
||||||
.then(renderCanvasSettings)
|
|
||||||
.catch(() => {
|
|
||||||
renderCanvasSettings({ width: 1920, height: 1080 });
|
|
||||||
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...");
|
||||||
method: "PUT",
|
try {
|
||||||
headers: { "Content-Type": "application/json" },
|
const settings = await fetchJson(
|
||||||
body: JSON.stringify({ width, height }),
|
"/canvas",
|
||||||
})
|
{
|
||||||
.then((r) => {
|
method: "PUT",
|
||||||
if (!r.ok) {
|
headers: { "Content-Type": "application/json" },
|
||||||
throw new Error("Failed to save canvas");
|
body: JSON.stringify({ width, height }),
|
||||||
}
|
},
|
||||||
return r.json();
|
"Failed to save canvas",
|
||||||
})
|
);
|
||||||
.then((settings) => {
|
renderCanvasSettings(settings);
|
||||||
renderCanvasSettings(settings);
|
if (elements.canvasStatus) elements.canvasStatus.textContent = "Saved.";
|
||||||
if (status) status.textContent = "Saved.";
|
showToast("Canvas size saved successfully.", "success");
|
||||||
showToast("Canvas size saved successfully.", "success");
|
setTimeout(() => {
|
||||||
setTimeout(() => {
|
if (elements.canvasStatus) elements.canvasStatus.textContent = "";
|
||||||
if (status) status.textContent = "";
|
}, 2000);
|
||||||
}, 2000);
|
} catch (error) {
|
||||||
})
|
if (elements.canvasStatus) elements.canvasStatus.textContent = "Unable to save right now.";
|
||||||
.catch(() => {
|
showToast("Unable to save canvas size. Please retry.", "error");
|
||||||
if (status) status.textContent = "Unable to save right now.";
|
} finally {
|
||||||
showToast("Unable to save canvas size. Please retry.", "error");
|
setButtonBusy(elements.canvasSaveButton, false, "Saving...");
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elements.adminInput) {
|
||||||
|
elements.adminInput.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
addAdmin();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchAdmins();
|
fetchAdmins();
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user