Pult: Stream-PC per Tipp freigeben, Sound-Quelle legt er selbst in OBS an
Paket bauen und veroeffentlichen / paket (push) Successful in 42s
Paket bauen und veroeffentlichen / paket (push) Successful in 42s
- ingest wuerfelt beim ersten Start einen Schluessel und meldet sich beim Hub; der antwortet 202, bis im Pult jemand auf Freigeben tippt - nach dem Verbinden schickt der Hub die Adresse von /sounds, ingest legt Szene "abdulive Ton" mit der Browser-Source an und haengt sie in jede Szene - fern:verbinden bleibt als Weg von Hand Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
32416d1916
commit
54dbe1265b
15 files changed
+675
-50
No files matched your search
@@ -39,6 +39,8 @@ export type Zugang = {
|
||||
darfFern: (req: http.IncomingMessage) => boolean;
|
||||
fernKey: () => string;
|
||||
neuerFernKey: () => string;
|
||||
/** Den Schluessel eines im Pult freigegebenen PCs eintragen. */
|
||||
fernKeySetzen: (key: string) => void;
|
||||
/** Ist ueberhaupt ein Passwort gesetzt? Ohne das ist alles offen. */
|
||||
hatPasswort: () => boolean;
|
||||
};
|
||||
@@ -184,6 +186,9 @@ export function createZugang(): Zugang {
|
||||
return Boolean(gegeben && gleich(gegeben, fernKey()));
|
||||
},
|
||||
fernKey,
|
||||
fernKeySetzen(k) {
|
||||
updateSecrets((s) => { s.web = { ...s.web, fernKey: k }; });
|
||||
},
|
||||
neuerFernKey() {
|
||||
const neu = crypto.randomBytes(24).toString('base64url');
|
||||
updateSecrets((s) => { s.web = { ...s.web, fernKey: neu }; });
|
||||
|
||||
@@ -132,6 +132,12 @@ export type OverlayEvent =
|
||||
| { kind: 'sound-stopp' }
|
||||
/** Die Liste der Sounds hat sich geaendert - offene Pulte laden neu. */
|
||||
| { kind: 'sounds-geaendert' }
|
||||
/**
|
||||
* Ein Stream-PC wartet auf Freigabe, oder die Liste hat sich geaendert.
|
||||
* Ohne Inhalt: wer und von wo, holt sich das Pult mit Anmeldung - der
|
||||
* Ereignisstrom geht auch an die Browser-Sources.
|
||||
*/
|
||||
| { kind: 'fern-anfrage' }
|
||||
| ObsStandEvent
|
||||
| { kind: 'clear'; userId?: string; messageId?: string }
|
||||
| { kind: 'beat'; t: number }
|
||||
|
||||
@@ -114,3 +114,39 @@ test('nach der Anmeldung geht es nur auf bekannte Seiten', () => {
|
||||
assert.equal(sichererPfad('//boese.example'), '/');
|
||||
assert.equal(sichererPfad(null), '/');
|
||||
});
|
||||
|
||||
test('ein unbekannter PC wartet auf Freigabe und bekommt dann seinen Schluessel eingetragen', () => {
|
||||
const bus = createBus();
|
||||
const ereignisse: string[] = [];
|
||||
bus.subscribe((e) => ereignisse.push(e.kind));
|
||||
const fern = createFernsteuerung(bus);
|
||||
const key = 'k'.repeat(32);
|
||||
|
||||
fern.anfragen(key, 'ABDU-PC', '203.0.113.7');
|
||||
fern.anfragen(key, 'ABDU-PC', '203.0.113.7');
|
||||
const [a, ...rest] = fern.offeneAnfragen();
|
||||
assert.equal(rest.length, 0, 'derselbe PC fragt alle paar Sekunden, das ist eine Anfrage');
|
||||
assert.equal(a?.name, 'ABDU-PC');
|
||||
assert.equal('key' in (a as object), false, 'der Schluessel geht nie ans Pult');
|
||||
assert.ok(ereignisse.includes('fern-anfrage'));
|
||||
|
||||
assert.equal(fern.freigeben(a!.id), key);
|
||||
assert.deepEqual(fern.offeneAnfragen(), []);
|
||||
assert.equal(fern.freigeben(a!.id), null, 'zweimal freigeben geht nicht');
|
||||
});
|
||||
|
||||
test('mehr als drei fremde Anfragen verdraengen die aeltesten', () => {
|
||||
const fern = createFernsteuerung(createBus());
|
||||
for (let i = 0; i < 5; i++) fern.anfragen(`schluessel-${i}`.padEnd(30, 'x'), `PC${i}`, '');
|
||||
assert.deepEqual(fern.offeneAnfragen().map((a) => a.name).sort(), ['PC2', 'PC3', 'PC4']);
|
||||
});
|
||||
|
||||
test('beim Verbinden erfaehrt der PC die Adresse der Sound-Quelle', () => {
|
||||
const fern = createFernsteuerung(createBus(), {
|
||||
willkommen: () => ({ soundsUrl: 'https://hub.example/sounds?key=abc' }),
|
||||
});
|
||||
const l = leitung();
|
||||
fern.annehmen(l.req, l.res);
|
||||
const [hallo] = l.befehle();
|
||||
assert.deepEqual(hallo, { art: 'einrichten', soundsUrl: 'https://hub.example/sounds?key=abc' });
|
||||
});
|
||||
@@ -32,9 +32,28 @@ export type FernBefehl =
|
||||
|
||||
export type FernAntwort = { ok: boolean; text: string };
|
||||
|
||||
/**
|
||||
* Ein Stream-PC, der sich mit einem noch unbekannten Schluessel meldet. Er
|
||||
* wartet, bis jemand im Pult auf "Freigeben" tippt - damit Abdu keinen Befehl
|
||||
* abtippen muss. Der Schluessel selbst geht nie ans Pult zurueck.
|
||||
*/
|
||||
export type FernAnfrage = { id: string; name: string; adresse: string; seit: string };
|
||||
|
||||
/** So viele Anfragen gleichzeitig, so lange gueltig. Mehr ist Laerm oder ein Angriff. */
|
||||
const MAX_ANFRAGEN = 3;
|
||||
const ANFRAGE_MS = 10 * 60_000;
|
||||
|
||||
export type Fernsteuerung = {
|
||||
/** Die SSE-Verbindung des Stream-PCs. Eine neue ersetzt die alte. */
|
||||
annehmen: (req: http.IncomingMessage, res: http.ServerResponse) => void;
|
||||
/** Ein unbekannter Schluessel klopft an. */
|
||||
anfragen: (key: string, name: string, adresse: string) => void;
|
||||
offeneAnfragen: () => FernAnfrage[];
|
||||
/** Gibt den Schluessel der Anfrage zurueck, damit er eingetragen werden kann. */
|
||||
freigeben: (id: string) => string | null;
|
||||
ablehnen: (id: string) => boolean;
|
||||
/** Leitung schliessen, z. B. nachdem der Schluessel neu gewuerfelt wurde. */
|
||||
trennen: () => void;
|
||||
/** Stand von OBS, wie ihn der PC meldet. */
|
||||
melden: (roh: unknown) => void;
|
||||
antwort: (roh: unknown) => void;
|
||||
@@ -66,15 +85,77 @@ export function standAus(roh: unknown): Omit<ObsStandEvent, 'kind' | 'pc'> {
|
||||
};
|
||||
}
|
||||
|
||||
export function createFernsteuerung(bus: Bus): Fernsteuerung {
|
||||
export type FernOptionen = {
|
||||
/**
|
||||
* Was der PC gleich nach dem Verbinden erfaehrt, z. B. die Adresse der
|
||||
* Sound-Quelle mit Schluessel. Damit richtet er OBS selbst ein.
|
||||
*/
|
||||
willkommen?: () => Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export function createFernsteuerung(bus: Bus, fo: FernOptionen = {}): Fernsteuerung {
|
||||
let leitung: http.ServerResponse | null = null;
|
||||
const offen = new Map<string, { fertig: (a: FernAntwort) => void; timer: NodeJS.Timeout }>();
|
||||
const anfragen = new Map<string, FernAnfrage & { key: string; zuletzt: number }>();
|
||||
|
||||
const veroeffentlichen = (stand: Omit<ObsStandEvent, 'kind'>): void => {
|
||||
bus.publish({ kind: 'obs', ...stand });
|
||||
};
|
||||
|
||||
const aufraeumen = (): void => {
|
||||
const jetzt = Date.now();
|
||||
for (const [id, a] of anfragen) if (jetzt - a.zuletzt > ANFRAGE_MS) anfragen.delete(id);
|
||||
};
|
||||
|
||||
return {
|
||||
anfragen(key, name, adresse) {
|
||||
aufraeumen();
|
||||
// Derselbe PC klopft alle paar Sekunden erneut an. Das ist dieselbe
|
||||
// Anfrage, keine neue - sonst stuende er nach einer Minute zehnmal da.
|
||||
for (const a of anfragen.values()) {
|
||||
if (a.key === key) { a.zuletzt = Date.now(); return; }
|
||||
}
|
||||
if (anfragen.size >= MAX_ANFRAGEN) {
|
||||
const aelteste = [...anfragen.values()].sort((x, y) => x.zuletzt - y.zuletzt)[0];
|
||||
if (aelteste) anfragen.delete(aelteste.id);
|
||||
}
|
||||
const id = crypto.randomBytes(6).toString('hex');
|
||||
const sauber = (s: string, n: number): string =>
|
||||
s.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, n) || 'unbekannt';
|
||||
anfragen.set(id, {
|
||||
id, key, name: sauber(name, 40), adresse: sauber(adresse, 60),
|
||||
seit: new Date().toISOString(), zuletzt: Date.now(),
|
||||
});
|
||||
log.info(`Stream-PC "${sauber(name, 40)}" (${sauber(adresse, 60)}) wartet auf Freigabe im Pult`);
|
||||
bus.publish({ kind: 'fern-anfrage' });
|
||||
},
|
||||
|
||||
offeneAnfragen() {
|
||||
aufraeumen();
|
||||
return [...anfragen.values()].map(({ id, name, adresse, seit }) => ({ id, name, adresse, seit }));
|
||||
},
|
||||
|
||||
freigeben(id) {
|
||||
aufraeumen();
|
||||
const a = anfragen.get(id);
|
||||
if (!a) return null;
|
||||
// Es gibt nur einen Stream-PC. Wer freigibt, verwirft die anderen.
|
||||
anfragen.clear();
|
||||
log.info(`Stream-PC "${a.name}" freigegeben`);
|
||||
bus.publish({ kind: 'fern-anfrage' });
|
||||
return a.key;
|
||||
},
|
||||
|
||||
ablehnen(id) {
|
||||
const weg = anfragen.delete(id);
|
||||
if (weg) bus.publish({ kind: 'fern-anfrage' });
|
||||
return weg;
|
||||
},
|
||||
|
||||
trennen() {
|
||||
leitung?.end();
|
||||
},
|
||||
|
||||
annehmen(req, res) {
|
||||
if (leitung) {
|
||||
// Zwei PCs zugleich hiesse: zwei OBS schalten auf denselben Knopf.
|
||||
@@ -92,6 +173,8 @@ export function createFernsteuerung(bus: Bus): Fernsteuerung {
|
||||
leitung = res;
|
||||
log.info('Stream-PC verbunden');
|
||||
veroeffentlichen({ ...LEER, pc: true });
|
||||
const hallo = fo.willkommen?.();
|
||||
if (hallo) res.write(`data: ${JSON.stringify({ art: 'einrichten', ...hallo })}\n\n`);
|
||||
|
||||
const beat = setInterval(
|
||||
() => res.write(`data: ${JSON.stringify({ art: 'beat', t: Date.now() })}\n\n`),
|
||||
|
||||
@@ -23,7 +23,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import { paths } from '#core/config.ts';
|
||||
import { logger } from '#core/logger.ts';
|
||||
import { setupRoutes, type ServerDeps } from '../../ingest/src/server.ts';
|
||||
import { createZugang, type Zugang } from './auth.ts';
|
||||
import { bearer, createZugang, type Zugang } from './auth.ts';
|
||||
import { createBus, type Bus, type OverlayEvent } from './bus.ts';
|
||||
import { startDemo } from './demo.ts';
|
||||
import { createFernsteuerung, type FernBefehl, type Fernsteuerung } from './fern.ts';
|
||||
@@ -130,7 +130,14 @@ export function startOverlayServer(opts: OverlayOptions): OverlayServer {
|
||||
const lz: Laufzeit = {
|
||||
vorfuehrung: { abbruch: null },
|
||||
sounds: createSoundboard(opts.soundsDir ?? path.join(paths.db, 'sounds')),
|
||||
fern: createFernsteuerung(bus),
|
||||
// Der PC bekommt beim Verbinden die Adresse der Sound-Quelle und legt sie
|
||||
// damit selbst in OBS an. Der Overlay-Schluessel darf nur lesen, und der
|
||||
// PC haette ihn ohnehin - er steht in jeder Browser-Source in OBS.
|
||||
fern: createFernsteuerung(bus, {
|
||||
willkommen: () => ({
|
||||
soundsUrl: `${opts.publicUrl || `http://127.0.0.1:${opts.port}`}/sounds?key=${zugang.overlayKey()}`,
|
||||
}),
|
||||
}),
|
||||
quellen: new Map(),
|
||||
};
|
||||
// Hinter einem Proxy muss der Dienst von aussen erreichbar sein. Ohne
|
||||
@@ -210,7 +217,18 @@ async function handle(
|
||||
// ---- Die Leitung zum Stream-PC. Eigener Schluessel im Kopf, nie in der
|
||||
// Adresse: wer ihn hat, schaltet Szenen um.
|
||||
if (p.startsWith('/fern/')) {
|
||||
if (!zugang.darfFern(req)) return send(res, 403, 'text/plain; charset=utf-8', 'Falscher Schluessel.');
|
||||
if (!zugang.darfFern(req)) {
|
||||
// Ein unbekannter Schluessel an der Leitung ist ein PC, der sich
|
||||
// anmelden will. Er wartet auf "Freigeben" im Pult und fragt so lange
|
||||
// nach. Alles andere unter /fern/ bleibt ohne gueltigen Schluessel zu.
|
||||
const key = bearer(req.headers.authorization);
|
||||
if (p === '/fern/befehle' && req.method === 'GET' && key && key.length >= 24 && key.length <= 128) {
|
||||
fern.anfragen(key, String(req.headers['x-abdulive-rechner'] ?? ''), absender(req));
|
||||
return send(res, 202, 'application/json; charset=utf-8',
|
||||
JSON.stringify({ text: 'Wartet auf Freigabe im Pult.' }));
|
||||
}
|
||||
return send(res, 403, 'text/plain; charset=utf-8', 'Falscher Schluessel.');
|
||||
}
|
||||
if (p === '/fern/befehle' && req.method === 'GET') return fern.annehmen(req, res);
|
||||
if ((p === '/fern/stand' || p === '/fern/antwort') && req.method === 'POST') {
|
||||
let roh: unknown;
|
||||
@@ -285,6 +303,32 @@ async function handle(
|
||||
tts: Boolean(opts.tts), liegestuetze: opts.liegestuetze?.stand() ?? null,
|
||||
}));
|
||||
}
|
||||
if (p === '/pult/fern') {
|
||||
const liste = (code: number, text: string): void =>
|
||||
send(res, code, 'application/json; charset=utf-8', JSON.stringify({
|
||||
text, anfragen: fern.offeneAnfragen(), verbunden: fern.verbunden(),
|
||||
}));
|
||||
if (req.method === 'GET') return liste(200, '');
|
||||
const form = await readForm(req);
|
||||
const was = form.get('was');
|
||||
if (was === 'freigeben') {
|
||||
const key = fern.freigeben(form.get('id') ?? '');
|
||||
if (!key) return liste(404, 'Die Anfrage ist abgelaufen. Der PC fragt gleich neu an.');
|
||||
zugang.fernKeySetzen(key);
|
||||
// Die alte Leitung (falls es eine gab) gehoert zu einem anderen Schluessel.
|
||||
fern.trennen();
|
||||
return liste(200, 'Freigegeben. Der Stream-PC verbindet sich in ein paar Sekunden.');
|
||||
}
|
||||
if (was === 'ablehnen') {
|
||||
return liste(200, fern.ablehnen(form.get('id') ?? '') ? 'Abgelehnt.' : 'Schon weg.');
|
||||
}
|
||||
if (was === 'trennen') {
|
||||
zugang.neuerFernKey();
|
||||
fern.trennen();
|
||||
return liste(200, 'Getrennt. Der PC muss neu freigegeben werden.');
|
||||
}
|
||||
return liste(400, 'Unbekannt.');
|
||||
}
|
||||
if (p === '/pult/obs' && req.method === 'POST') {
|
||||
const form = await readForm(req);
|
||||
const befehl = befehlAus(form);
|
||||
@@ -410,10 +454,15 @@ async function handle(
|
||||
|
||||
if (p === '/schluessel/neu' && req.method === 'POST') {
|
||||
zugang.neuerOverlayKey();
|
||||
// Der PC verbindet sich neu, bekommt die neue Adresse der Sound-Quelle
|
||||
// und traegt sie in OBS ein. Die uebrigen Browser-Sources nicht - die hat
|
||||
// jemand von Hand angelegt, und nur der weiss, wo sie liegen.
|
||||
fern.trennen();
|
||||
return redirect(res, '/');
|
||||
}
|
||||
if (p === '/fernschluessel/neu' && req.method === 'POST') {
|
||||
zugang.neuerFernKey();
|
||||
fern.trennen();
|
||||
return redirect(res, '/#stream-pc');
|
||||
}
|
||||
|
||||
@@ -588,6 +637,16 @@ export function sichererPfad(roh: string | null | undefined): string {
|
||||
return roh === '/pult' ? '/pult' : '/';
|
||||
}
|
||||
|
||||
/**
|
||||
* Woher eine Anfrage kommt. Hinter dem Proxy steht die echte Adresse im Kopf,
|
||||
* die eigene Verbindung kommt immer vom Proxy. Nur zur Anzeige bei der
|
||||
* Freigabe - nichts haengt daran, ob sie stimmt.
|
||||
*/
|
||||
function absender(req: http.IncomingMessage): string {
|
||||
const kopf = req.headers['x-real-ip'] ?? String(req.headers['x-forwarded-for'] ?? '').split(',')[0];
|
||||
return String(kopf || req.socket.remoteAddress || '').trim();
|
||||
}
|
||||
|
||||
function readBody(req: http.IncomingMessage, max: number): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
@@ -856,12 +915,16 @@ ${lzFeld}
|
||||
|
||||
<h2 id="stream-pc">Stream-PC verbinden</h2>
|
||||
<p class="lead">Damit das Pult Szenen umschalten, Clips speichern und Ton stummschalten
|
||||
kann, wählt sich der Stream-PC hier ein. Einmal auf dem Stream-PC in PowerShell
|
||||
ausführen, danach <code>abdulive ingest</code> neu starten:</p>
|
||||
<p><code id="fernbefehl">${escape(fernBefehl)}</code>
|
||||
<button data-copy="fernbefehl">kopieren</button></p>
|
||||
kann, wählt sich der Stream-PC hier ein. Das macht er von selbst: beim ersten
|
||||
Start meldet er sich, und im Pult unter <a href="/pult#szenen">Szenen</a> erscheint
|
||||
„Freigeben“. Danach legt er in OBS auch die Sound-Quelle selbst an.</p>
|
||||
<p class="status"><span class="dot${fernInfo.verbunden ? ' on' : ''}"></span>${
|
||||
fernInfo.verbunden ? 'Stream-PC ist verbunden.' : 'Stream-PC ist gerade nicht verbunden.'}</p>
|
||||
<details><summary class="hint">Von Hand verbinden, falls die Freigabe nicht klappt</summary>
|
||||
<p><code id="fernbefehl">${escape(fernBefehl)}</code>
|
||||
<button data-copy="fernbefehl">kopieren</button></p>
|
||||
<p class="hint">Auf dem Stream-PC in PowerShell, danach <code>abdulive ingest</code> neu starten.</p>
|
||||
</details>
|
||||
<form method="post" action="/fernschluessel/neu">
|
||||
<button type="submit" style="margin-left:0">Schlüssel neu würfeln</button>
|
||||
<span class="hint">Dieser Schlüssel schaltet OBS. Nie in eine Browser-Source eintragen,
|
||||
|
||||
Reference in new issue
Block a user