Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
e9f5e3e
feat(webui): enforce CSP with per-request script nonces
Blackwellcode Nov 12, 2026
283ed99
refactor(templates): bind events without inline handlers; harden chat…
Blackwellcode Nov 12, 2026
ba3b3e2
perf(css): load stylesheets as parallel <link>s instead of an @import…
Blackwellcode Nov 12, 2026
f54919c
fix(a11y): skip link, labelled search, <dialog> password modal, AA to…
Blackwellcode Nov 12, 2026
7221095
feat(ux): confirmation dialog for restart, maintenance and destructiv…
Blackwellcode Nov 12, 2026
d1dfd73
fix(a11y): user/group modals as <dialog> with focus management and la…
Blackwellcode Nov 12, 2026
bc6826e
fix(a11y): bottom navigation below 768px, one focus colour, dialog st…
Blackwellcode Nov 12, 2026
df80a5e
fix(a11y,ux): labels, headings, empty states and confirmations across…
Blackwellcode Nov 12, 2026
7116d5d
style(restyle): one token layer, dead CSS out, palette/type/radius vi…
Blackwellcode Nov 12, 2026
3dca32d
fix(overlays): send overlay tokens as headers, scripts out of inline …
Blackwellcode Nov 12, 2026
0cbadda
fix(a11y): default theme on Nebula tokens, remaining Lighthouse findings
Blackwellcode Nov 12, 2026
a3a1372
fix(i18n): sentence-case msgids, one ellipsis, German in Sie form, ca…
Blackwellcode Nov 12, 2026
4e56b54
fix(security): css_url filter for CSS url(), https images in CSP, str…
Blackwellcode Nov 12, 2026
69badf8
refactor(ux): one confirmation dialog, dialog strings via svT/JS_MESS…
Blackwellcode Nov 12, 2026
3b683ad
fix(i18n): translate the new UI strings in all 11 catalogs
Blackwellcode Nov 12, 2026
66f3390
refactor(overlays): shared overlay_audio.js, log failures instead of …
Blackwellcode Nov 12, 2026
97d79ed
style(tokens): dynamic VOD cards and brand glyph on tokens
Blackwellcode Nov 12, 2026
c1cf56c
perf(lcp): eager above-the-fold images on the dashboards
Blackwellcode Nov 12, 2026
1bbe002
docs: changelog and .env.example for CSP enforcement and overlay headers
Blackwellcode Nov 12, 2026
0d05a3a
Merge origin/master (PR #12 backend audit fixes) into audit/ui-fixes
Blackwellcode Nov 12, 2026
79cfafa
feat(overlays): token from the URL fragment, OBS URLs shown in Options
Blackwellcode Nov 12, 2026
b5624af
feat(auth-ui): "Log out everywhere" in the rail, bottom nav and mobil…
Blackwellcode Nov 12, 2026
90df570
docs(changelog): overlay #token= URLs and log out everywhere in the UI
Blackwellcode Nov 12, 2026
637114d
fix(a11y): dialog button variants win over legacy page button rules; …
Blackwellcode Nov 12, 2026
efb0423
fix(overlays): no polling without token, stop on 401, move ?token= in…
Blackwellcode Nov 12, 2026
4fbb2bb
refactor(ux): files and VOD delete use the confirmation dialog only
Blackwellcode Nov 12, 2026
196df90
style(a11y): exclude .sv-btn from legacy button rules, tokens instead…
Blackwellcode Nov 12, 2026
f1443bd
fix(a11y): unique field ids in the add/edit storage provider modals
Blackwellcode Nov 12, 2026
7ce4981
refactor(templates): logout-everywhere form as one include
Blackwellcode Nov 12, 2026
ac731b4
fix(i18n): login throttle plural in all catalogs
Blackwellcode Nov 12, 2026
f756fe8
test(webui): shared admin client, node logic tests for overlays, B an…
Blackwellcode Nov 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ VOD_PUBLIC_BASE=
# Required. Generate with: python -c "import secrets; print(secrets.token_hex(32))"
VOD_SECRET_KEY=
VOD_SECURE_COOKIES=false
# Content-Security-Policy: true (default) enforces it, false sends it as Report-Only.
VOD_CSP_ENFORCE=true

# --- Cloud / External Storage (optional) ---
# Dedicated AES-GCM key for provider credentials. Falls back to VOD_SECRET_KEY.
Expand Down
27 changes: 24 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ All notable changes to this project are documented here. The format follows
the new "log out everywhere" action (`POST /logout/all`) and every password
change sign the account out on all devices.
- **Overlay tokens in the query string are off by default.** The bundled
TTS/alert overlay pages pass `?token=`; set `OVERLAY_ALLOW_QUERY_TOKEN=true`
while you use them. The `/tts/api/ack` and `/alerts/api/ack` routes accept
POST only.
TTS/alert overlays read the token from `#token=` (or an old `?token=` URL)
and send it only as a header, so existing OBS sources keep working; move
the token behind `#` to keep it out of server logs. The `/tts/api/ack` and
`/alerts/api/ack` routes accept POST only.
- **Resource uploads use an allowlist** (documents, images, audio/video,
archives; `VOD_FILES_ALLOWED_EXTENSIONS` overrides it). Existing files with
other extensions are no longer served but can still be deleted.
Expand Down Expand Up @@ -115,6 +116,15 @@ All notable changes to this project are documented here. The format follows
with `STREAM_RECORDER_USE_PROXY=true`.
- **Backups.** `BACKUP_DIR` defaults to `<VOD_META_DIR>/backups`; "create
backup" runs the script named in `VOD_BACKUP_SCRIPT`.
- **Content-Security-Policy is enforced.** `VOD_CSP_ENFORCE` now defaults to
`true`: inline scripts need the per-request nonce and inline event handlers
(`onclick=` ...) are blocked. Custom templates or overlays with inline
scripts must use `nonce="{{ csp_nonce() }}"`. `VOD_CSP_ENFORCE=false` falls
back to `Content-Security-Policy-Report-Only`.
- **Overlay tokens as headers.** The TTS and alert overlays read the token
from `#token=` (or `?token=`) of their own URL and send it to the API only as
`X-TTS-Token` / `X-Alert-Token`; audio is fetched with the header and played
from a blob URL. Options > Settings shows the recommended OBS URLs.

### Security

Expand All @@ -123,6 +133,9 @@ All notable changes to this project are documented here. The format follows
rate limits and bans. `VOD_TRUSTED_PROXIES` accepts CIDR networks.
- Code and dot-directories below `VOD_BASE_DIR` can no longer be deleted as
streamer archives.
- WebUI: chat fallbacks escape Twitch chat text, toasts use `textContent`,
banner/hero URLs only reach CSS `url()` through the `css_url` filter, and
the CSP allows the emote hosts (BTTV, FFZ, 7TV, ivr.fi) and `https:` images.

### Fixed

Expand All @@ -145,6 +158,14 @@ All notable changes to this project are documented here. The format follows

### Added

- WebUI accessibility and design (audit 2026-11-12): native `<dialog>` modals
with focus handling, one confirmation dialog (`data-confirm`) for restart,
maintenance and deletions, bottom navigation with logout below 768px,
"Log out everywhere" in the rail, bottom navigation and mobile pages,
skip link, labelled fields, AA contrast tokens, unlocked zoom on mobile.
`static/css/tokens.css` is the single token layer; stylesheets load as
parallel `<link>`s.

- GitHub Actions CI (Python 3.10–3.12, ruff, unit tests, shell syntax),
Dependabot, contribution and security guidelines.
- Web UI in 12 languages, chosen from the browser language or the switcher.
Expand Down
4 changes: 4 additions & 0 deletions WEBUI/i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,10 @@ def N_(text):
N_("Progress"),
N_("Volume"),
N_("Start playback"),
# static/sv_confirm.js (confirmation dialog)
N_("Please confirm"),
N_("Confirm"),
N_("Cancel"),
)


Expand Down
68 changes: 2 additions & 66 deletions WEBUI/static/alerts_overlay.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,71 +19,7 @@
<body>
<div id="hud"></div>
<audio id="player" preload="auto"></audio>
<script>
const token = new URLSearchParams(location.search).get("token") || "";
const hud = document.getElementById("hud");
const player = document.getElementById("player");
let busy = false;
let currentId = null;

function url(path) {
const u = new URL(path, location.origin);
u.searchParams.set("token", token);
return u.pathname + u.search;
}

async function ack(id) {
if (!id) return;
try {
await fetch(url("/alerts/api/ack?id=" + encodeURIComponent(id)), { method: "POST" });
} catch (e) {}
}

async function tick() {
if (busy) return;
try {
const res = await fetch(url("/alerts/api/next"));
if (!res.ok) return;
const data = await res.json();
if (!data || !data.item) return;
const item = data.item;
busy = true;
currentId = item.id;
hud.textContent = "🔔 " + (item.label || item.event);
hud.classList.add("show");
player.src = item.audio_url;
await player.play();
} catch (e) {
hud.textContent = "click once to unlock audio";
hud.classList.add("show");
busy = false;
currentId = null;
}
}

player.addEventListener("ended", async () => {
const id = currentId;
currentId = null;
busy = false;
hud.classList.remove("show");
await ack(id);
tick();
});

player.addEventListener("error", async () => {
const id = currentId;
currentId = null;
busy = false;
hud.classList.remove("show");
await ack(id);
});

window.addEventListener("pointerdown", () => {
player.play().catch(() => {});
}, { once: true });

setInterval(tick, 900);
tick();
</script>
<script src="/static/overlay_common.js"></script>
<script src="/static/alerts_overlay.js"></script>
</body>
</html>
88 changes: 88 additions & 0 deletions WEBUI/static/alerts_overlay.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/* Alert sound overlay (OBS browser source). External file: the enforced CSP blocks inline scripts.
Auth: token from #token= of the overlay URL (a legacy ?token= is moved into the fragment),
sent to the API only as X-Alert-Token header. No token or HTTP 401: polling stops. */
(function () {
const overlay = window.SVOverlay;
const token = overlay.readToken().token;
const hud = document.getElementById("hud");
const player = document.getElementById("player");
const AUTH_HEADERS = { "X-Alert-Token": token };

function showHud(text) {
hud.textContent = text;
hud.classList.add("show");
}

if (!token) {
showHud("Token fehlt – Overlay-URL mit #token=… verwenden");
return;
}

const audio = overlay.createAudio(player, AUTH_HEADERS);
let busy = false;
let currentId = null;

async function ack(id) {
if (!id) return;
try {
await fetch("/alerts/api/ack?id=" + encodeURIComponent(id), { method: "POST", headers: AUTH_HEADERS });
} catch (e) {
console.error("[alerts-overlay] ack failed for item", id, e);
}
}

async function tick() {
if (busy) return "ok";
const res = await fetch("/alerts/api/next", { headers: AUTH_HEADERS });
if (res.status === 401) return "unauthorized";
if (!res.ok) return "error";
const data = await res.json();
if (!data || !data.item) return "ok";
const item = data.item;
busy = true;
currentId = item.id;
showHud("🔔 " + (item.label || item.event));
try {
await audio.load(item.audio_url);
await player.play();
} catch (e) {
console.error("[alerts-overlay] loading or playing the next alert failed", e);
showHud("click once to unlock audio");
busy = false;
currentId = null;
}
return "ok";
}

const poller = overlay.createPoller(tick, {
interval: 900,
maxInterval: 30000,
label: "alerts next",
onUnauthorized: function () { showHud("Token ungültig – Abfrage angehalten"); },
});

player.addEventListener("ended", async () => {
const id = currentId;
currentId = null;
busy = false;
hud.classList.remove("show");
audio.release();
await ack(id);
poller.now();
});

player.addEventListener("error", async () => {
const id = currentId;
currentId = null;
busy = false;
hud.classList.remove("show");
audio.release();
await ack(id);
});

window.addEventListener("pointerdown", () => {
player.play().catch((e) => console.error("[alerts-overlay] unlock play failed", e));
}, { once: true });

poller.start();
})();
Loading
Loading