Phase 7+11: token-based publishing — MediaMTX authMethod http -> dumpd /mtx-auth (bridge-only port 8081); dumpd mints 12 h HMAC tokens for Discord-authenticated streamers; dock rewritten (no passwords, naming rules with live preview, prefix/suffix, manual-setup accordion with hidden token); portal uses tokens; configure.py drops streamers; MediaMTX hot-reload, Caddy reload — no stream interruption
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
178
dock/index.html
178
dock/index.html
@@ -5,29 +5,41 @@
|
||||
<title>Vanguard Stream</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
:root { --bg:#1e1e1e; --fg:#e6e6e6; --mut:#9a9a9a; --acc:#3d8bfd; --ok:#2ecc71; --bad:#e74c3c; --box:#2a2a2a; }
|
||||
:root { --bg:#1e1e1e; --fg:#e6e6e6; --mut:#9a9a9a; --acc:#3d8bfd; --ok:#2ecc71; --bad:#e74c3c; --warn:#f1c40f; --box:#2a2a2a; }
|
||||
html,body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.4 system-ui,sans-serif}
|
||||
main{padding:12px;max-width:420px}
|
||||
main{padding:12px;max-width:440px}
|
||||
h1{font-size:15px;margin:0 0 10px;color:var(--mut);font-weight:600}
|
||||
h1 small{font-weight:400}
|
||||
label{display:block;margin:8px 0 2px;color:var(--mut);font-size:12px}
|
||||
input{width:100%;box-sizing:border-box;padding:8px;border:1px solid #444;border-radius:6px;background:var(--box);color:var(--fg);font-size:14px}
|
||||
input[readonly]{color:var(--mut)}
|
||||
button{width:100%;margin-top:10px;padding:11px;border:0;border-radius:6px;font-size:15px;font-weight:600;cursor:pointer;color:#fff;background:var(--acc)}
|
||||
button.live{background:var(--ok);color:#000}
|
||||
button.dump{background:var(--bad)}
|
||||
button.small{width:auto;padding:6px 10px;font-size:13px;margin:6px 6px 0 0;background:#3a3a3a}
|
||||
button:disabled{opacity:.5;cursor:default}
|
||||
#status{margin-top:10px;padding:8px;border-radius:6px;background:var(--box);color:var(--mut);min-height:1.4em;white-space:pre-wrap}
|
||||
details{margin-top:14px}
|
||||
details{margin-top:14px;border-top:1px solid #333;padding-top:8px}
|
||||
summary{color:var(--mut);cursor:pointer;font-size:12px}
|
||||
.row{display:flex;gap:8px}
|
||||
.row button{margin-top:10px}
|
||||
.preview{font-family:ui-monospace,monospace;font-size:12px;color:var(--mut);margin-top:4px;word-break:break-all}
|
||||
.preview b{color:var(--fg)}
|
||||
.rule{color:var(--bad);font-size:12px;margin-top:4px}
|
||||
.kv{font-family:ui-monospace,monospace;font-size:12px;background:var(--box);border-radius:6px;padding:8px;margin-top:6px;word-break:break-all}
|
||||
.kv .k{color:var(--mut)}
|
||||
.tok{letter-spacing:1px}
|
||||
.who{color:var(--mut);font-size:12px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Vanguard Stream</h1>
|
||||
<h1>Vanguard Stream <small id="who" class="who"></small></h1>
|
||||
|
||||
<label for="title">Stream title</label>
|
||||
<input id="title" placeholder="VanguardTraining10-10-2025" autocomplete="off">
|
||||
<input id="title" placeholder="VanguardTraining" autocomplete="off">
|
||||
<div class="preview">stream name: <b id="preview">—</b></div>
|
||||
<div class="rule" id="rule"></div>
|
||||
<button id="golive" class="live">Go Live</button>
|
||||
<div class="row">
|
||||
<button id="stop">Stop Stream</button>
|
||||
@@ -37,58 +49,115 @@
|
||||
|
||||
<details>
|
||||
<summary>Settings (saved in this dock)</summary>
|
||||
<label>Ingest host (RTMP server)</label><input id="host" placeholder="media.example.com">
|
||||
<label>Your streamer name</label><input id="user" placeholder="techy">
|
||||
<label>Your stream password</label><input id="pass" type="password">
|
||||
<label>Ingest host</label><input id="host" placeholder="media.example.com">
|
||||
<label>OBS WebSocket password</label><input id="obspw" type="password">
|
||||
<label>OBS WebSocket port</label><input id="obsport" value="4455">
|
||||
<label>Title prefix (optional, e.g. <code>HelmetCam-</code>)</label><input id="prefix" placeholder="">
|
||||
<label>Title suffix (optional; <code>{date}</code> becomes YYYY-MM-DD)</label><input id="suffix" placeholder="-{date}">
|
||||
<button id="save">Save settings</button>
|
||||
</details>
|
||||
|
||||
<details id="manual">
|
||||
<summary>Manual OBS setup (endpoints + token)</summary>
|
||||
<div class="kv"><span class="k">RTMP server </span><span id="m_rtmp">—</span></div>
|
||||
<div class="kv"><span class="k">Stream key </span><span id="m_key">—</span></div>
|
||||
<div class="kv"><span class="k">SRT URL </span><span id="m_srt">—</span></div>
|
||||
<div class="kv"><span class="k">Token </span><span id="m_tok" class="tok">•••••••••••• (hidden)</span><br><span class="k" id="m_exp"></span></div>
|
||||
<button class="small" id="m_show">Show token</button>
|
||||
<button class="small" id="m_copy_key">Copy stream key</button>
|
||||
<button class="small" id="m_copy_srt">Copy SRT URL</button>
|
||||
<button class="small" id="m_fresh">Fresh token (12 h)</button>
|
||||
<div class="who" style="margin-top:6px">Tokens are minted for your Discord login and expire; the Go Live button does all of this automatically.</div>
|
||||
</details>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// ---- settings --------------------------------------------------------------
|
||||
const KEYS = ["host","user","pass","obspw","obsport"];
|
||||
const KEYS = ["host","obspw","obsport","prefix","suffix"];
|
||||
const $ = id => document.getElementById(id);
|
||||
const status = (msg, cls) => { $("status").textContent = msg; $("status").style.color = cls==="bad"?"#e74c3c":cls==="ok"?"#2ecc71":"#9a9a9a"; };
|
||||
function load(){ try{ KEYS.forEach(k => { const v = localStorage.getItem("vg."+k); if (v!==null) $(k).value = v; }); }catch(e){} }
|
||||
function save(){ try{ KEYS.forEach(k => localStorage.setItem("vg."+k, $(k).value.trim())); status("settings saved"); }catch(e){ status("could not save settings (storage blocked)", "bad"); } }
|
||||
load();
|
||||
$("save").onclick = save;
|
||||
|
||||
function load(){ try{ KEYS.forEach(k => { const v = localStorage.getItem("vg."+k); if (v!==null) $(k).value = v; }); }catch(e){} if(!$("host").value) $("host").value = location.hostname; }
|
||||
function save(){ try{ KEYS.forEach(k => localStorage.setItem("vg."+k, $(k).value.trim())); status("settings saved"); }catch(e){ status("could not save settings (storage blocked)", "bad"); } renderManual(); }
|
||||
load(); $("save").onclick = save;
|
||||
const cfg = () => Object.fromEntries(KEYS.map(k => [k, $(k).value.trim()]));
|
||||
const cleanTitle = t => t.trim().replace(/\s+/g, "-").replace(/[^A-Za-z0-9_.-]/g, "");
|
||||
|
||||
// ---- naming rules (must match the server: the sweep publishes <Title>__<timestamp>.mp4) ----------------
|
||||
const today = () => { const d = new Date(); return new Date(d.getTime() - d.getTimezoneOffset()*60000).toISOString().slice(0,10); };
|
||||
function buildTitle(raw){
|
||||
const c = cfg();
|
||||
let t = (c.prefix||"") + raw.trim() + (c.suffix||"").replace("{date}", today());
|
||||
t = t.replace(/\s+/g, "-") // spaces -> dashes
|
||||
.replace(/[^A-Za-z0-9_.-]/g, "") // allowed characters only
|
||||
.replace(/_{2,}/g, "_") // "__" is reserved: it separates title from timestamp in filenames
|
||||
.replace(/^[.-]+/, "") // no leading dot (hidden) or dash
|
||||
.slice(0, 64);
|
||||
return t;
|
||||
}
|
||||
function ruleText(raw){
|
||||
const r = [];
|
||||
if (/__/.test(raw)) r.push("double underscore is reserved (becomes single)");
|
||||
if (/[^A-Za-z0-9_.\- ]/.test(raw)) r.push("only letters, digits, space, _ . - are kept");
|
||||
if (raw.trim().startsWith(".")) r.push("cannot start with a dot");
|
||||
if ((cfg().prefix + raw + cfg().suffix).length > 64) r.push("longer than 64 characters: trimmed");
|
||||
return r.join(" · ");
|
||||
}
|
||||
let me = null; // {user, token, expires}
|
||||
function renderPreview(){
|
||||
const t = buildTitle($("title").value);
|
||||
$("preview").textContent = t ? `${me ? me.user : "<you>"}/${t}` : "—";
|
||||
$("rule").textContent = ruleText($("title").value);
|
||||
renderManual(t);
|
||||
}
|
||||
$("title").addEventListener("input", renderPreview);
|
||||
["prefix","suffix"].forEach(k => $(k).addEventListener("input", renderPreview));
|
||||
|
||||
// ---- identity + tokens (the dock is behind Authentik: same-origin fetch carries the Discord session) -----
|
||||
async function whoami(){
|
||||
try {
|
||||
const r = await fetch("/whoami", {credentials:"same-origin"});
|
||||
const m = (await r.text()).match(/user:\s*(\S+)/); if (m) $("who").textContent = "— " + m[1];
|
||||
} catch(e){}
|
||||
}
|
||||
async function mint(){
|
||||
const r = await fetch("/me/stream-token", {credentials:"same-origin"});
|
||||
const b = await r.json().catch(()=>({}));
|
||||
if (!r.ok) throw new Error(b.error || `token ${r.status}`);
|
||||
me = b; renderPreview(); return b;
|
||||
}
|
||||
let showTok = false;
|
||||
function renderManual(t){
|
||||
const c = cfg(); const title = t ?? buildTitle($("title").value) || "TITLE";
|
||||
const user = me ? me.user : "<you>", tok = me ? me.token : "<token>";
|
||||
$("m_rtmp").textContent = `rtmp://${c.host||location.hostname}:1935/`;
|
||||
$("m_key").textContent = `${user}/${title}?user=${user}&pass=${showTok ? tok : "••••••••"}`;
|
||||
$("m_srt").textContent = `srt://${c.host||location.hostname}:8890?streamid=publish:${user}/${title}:${user}:${showTok ? tok : "••••••••"}`;
|
||||
$("m_tok").textContent = me ? (showTok ? me.token : "•••••••••••• (hidden)") : "— click “Fresh token”";
|
||||
$("m_exp").textContent = me ? `valid until ${new Date(me.expires*1000).toLocaleString()}` : "";
|
||||
}
|
||||
$("m_show").onclick = () => { showTok = !showTok; $("m_show").textContent = showTok ? "Hide token" : "Show token"; renderManual(); };
|
||||
$("m_fresh").onclick = async () => { try { await mint(); status("fresh token minted (12 h)", "ok"); } catch(e){ status(e.message, "bad"); } };
|
||||
async function copy(text, what){ try { await navigator.clipboard.writeText(text); status(`${what} copied`, "ok"); } catch(e){ status("clipboard blocked — select the text manually", "bad"); } }
|
||||
$("m_copy_key").onclick = async () => { if (!me) await mint().catch(e=>status(e.message,"bad")); if (me) copy(`${me.user}/${buildTitle($("title").value)||"TITLE"}?user=${me.user}&pass=${me.token}`, "stream key"); };
|
||||
$("m_copy_srt").onclick = async () => { if (!me) await mint().catch(e=>status(e.message,"bad")); if (me) copy(`srt://${cfg().host}:8890?streamid=publish:${me.user}/${buildTitle($("title").value)||"TITLE"}:${me.user}:${me.token}`, "SRT URL"); };
|
||||
$("manual").addEventListener("toggle", () => { if ($("manual").open) renderManual(); });
|
||||
|
||||
// ---- obs-websocket v5 ------------------------------------------------------
|
||||
async function sha256b64(s){
|
||||
const h = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s));
|
||||
return btoa(String.fromCharCode(...new Uint8Array(h)));
|
||||
}
|
||||
function obs(cfg){
|
||||
async function sha256b64(s){ const h = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s)); return btoa(String.fromCharCode(...new Uint8Array(h))); }
|
||||
function obs(c){
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${cfg.obsport||4455}`);
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${c.obsport||4455}`);
|
||||
const pending = new Map(); let n = 0;
|
||||
ws.onerror = () => reject(new Error("cannot reach OBS WebSocket (enable it in Tools > WebSocket Server Settings)"));
|
||||
ws.onmessage = async ev => {
|
||||
const m = JSON.parse(ev.data);
|
||||
if (m.op === 0) { // Hello
|
||||
if (m.op === 0) {
|
||||
let auth;
|
||||
if (m.d.authentication) {
|
||||
const secret = await sha256b64(cfg.obspw + m.d.authentication.salt);
|
||||
auth = await sha256b64(secret + m.d.authentication.challenge);
|
||||
}
|
||||
if (m.d.authentication) { const secret = await sha256b64(c.obspw + m.d.authentication.salt); auth = await sha256b64(secret + m.d.authentication.challenge); }
|
||||
ws.send(JSON.stringify({op:1, d:{rpcVersion:1, authentication:auth, eventSubscriptions:0}}));
|
||||
} else if (m.op === 2) { // Identified
|
||||
resolve({
|
||||
call(type, data){ return new Promise((res, rej) => {
|
||||
const id = String(++n); pending.set(id, {res, rej});
|
||||
ws.send(JSON.stringify({op:6, d:{requestType:type, requestId:id, requestData:data||{}}}));
|
||||
}); },
|
||||
close(){ ws.close(); }
|
||||
});
|
||||
} else if (m.op === 7) { // RequestResponse
|
||||
const p = pending.get(m.d.requestId); pending.delete(m.d.requestId);
|
||||
if (!p) return;
|
||||
} else if (m.op === 2) {
|
||||
resolve({ call(type, data){ return new Promise((res, rej) => { const id = String(++n); pending.set(id, {res, rej}); ws.send(JSON.stringify({op:6, d:{requestType:type, requestId:id, requestData:data||{}}})); }); }, close(){ ws.close(); } });
|
||||
} else if (m.op === 7) {
|
||||
const p = pending.get(m.d.requestId); pending.delete(m.d.requestId); if (!p) return;
|
||||
m.d.requestStatus.result ? p.res(m.d.responseData) : p.rej(new Error(m.d.requestStatus.comment || m.d.requestType + " failed"));
|
||||
}
|
||||
};
|
||||
@@ -98,44 +167,37 @@ function obs(cfg){
|
||||
|
||||
// ---- actions ---------------------------------------------------------------
|
||||
$("golive").onclick = async () => {
|
||||
const c = cfg(), title = cleanTitle($("title").value);
|
||||
const c = cfg(), title = buildTitle($("title").value);
|
||||
if (!title) return status("type a title first", "bad");
|
||||
if (!c.host || !c.user || !c.pass) return status("fill in settings first", "bad");
|
||||
if (!c.host) return status("set the ingest host in settings", "bad");
|
||||
$("golive").disabled = true;
|
||||
try {
|
||||
status("getting a stream token…");
|
||||
const t = await mint(); // fresh token every Go Live; nothing stored
|
||||
status("connecting to OBS…");
|
||||
const o = await obs(c);
|
||||
const key = `${c.user}/${title}?user=${encodeURIComponent(c.user)}&pass=${encodeURIComponent(c.pass)}`;
|
||||
await o.call("SetStreamServiceSettings", {
|
||||
streamServiceType: "rtmp_custom",
|
||||
streamServiceSettings: { server: `rtmp://${c.host}:1935/`, key, use_auth: false }
|
||||
});
|
||||
await o.call("SetStreamServiceSettings", { streamServiceType: "rtmp_custom",
|
||||
streamServiceSettings: { server: `rtmp://${c.host}:1935/`, key: `${t.user}/${title}?user=${encodeURIComponent(t.user)}&pass=${encodeURIComponent(t.token)}`, use_auth: false } });
|
||||
const st = await o.call("GetStreamStatus");
|
||||
if (st.outputActive) { await o.call("StopStream"); await new Promise(r => setTimeout(r, 1500)); }
|
||||
await o.call("StartStream");
|
||||
o.close();
|
||||
status(`LIVE → ${c.user}/${title}`, "ok");
|
||||
await o.call("StartStream"); o.close();
|
||||
status(`LIVE → ${t.user}/${title}`, "ok");
|
||||
} catch (e) { status(e.message, "bad"); }
|
||||
$("golive").disabled = false;
|
||||
};
|
||||
|
||||
$("stop").onclick = async () => {
|
||||
try { const o = await obs(cfg()); await o.call("StopStream"); o.close(); status("stream stopped"); }
|
||||
catch (e) { status(e.message, "bad"); }
|
||||
};
|
||||
$("stop").onclick = async () => { try { const o = await obs(cfg()); await o.call("StopStream"); o.close(); status("stream stopped"); } catch (e) { status(e.message, "bad"); } };
|
||||
|
||||
let armed = 0;
|
||||
$("dump").onclick = async () => {
|
||||
try {
|
||||
if (Date.now() - armed > 10000) { // first click: show what would go, arm for 10 s
|
||||
if (Date.now() - armed > 10000) {
|
||||
status("checking what tonight contains…");
|
||||
const dry = await (await fetch("/dump", {credentials:"same-origin"})).json(); // identity = your Discord login (Authentik)
|
||||
const dry = await (await fetch("/dump", {credentials:"same-origin"})).json();
|
||||
if (dry.error) throw new Error(dry.error);
|
||||
if (!dry.files.length) return status(`nothing recorded for ${dry.night}`);
|
||||
armed = Date.now();
|
||||
$("dump").textContent = "Click again to confirm";
|
||||
armed = Date.now(); $("dump").textContent = "Click again to confirm";
|
||||
setTimeout(() => { $("dump").textContent = "Dump tonight"; armed = 0; }, 10000);
|
||||
return status(`${dry.files.length} file(s), ${dry.gb} GB from the night of ${dry.night}.\nClick Dump again within 10 s to confirm. Undo window: 3 days (ask admin).`);
|
||||
return status(`${dry.files.length} file(s), ${dry.gb} GB from the night of ${dry.night}.\nClick Dump again within 10 s to confirm. Undo window: 3 days (ask an admin).`);
|
||||
}
|
||||
armed = 0; $("dump").textContent = "Dump tonight";
|
||||
const r = await (await fetch("/dump", {method:"POST", headers:{"Content-Type":"application/json"}, body:"{}", credentials:"same-origin"})).json();
|
||||
@@ -143,6 +205,8 @@ $("dump").onclick = async () => {
|
||||
status(`dumped ${r.moved} file(s), ${r.gb} GB (recoverable until ${r.undo_until})`, "ok");
|
||||
} catch (e) { status("dump failed: " + e.message, "bad"); }
|
||||
};
|
||||
|
||||
whoami(); renderPreview();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user