Filipe, 28.09.2026: „die rolle dogfather und vanvan sollen alles sehen und bereit machen auch vorher. nur die zwei sollen alles sehen, vorbereiten und einschakten koennen." DREI DINGE AENDERN SICH, UND ZWAR GENAU DIESE DREI 1. VORBEREITEN UND EINSCHALTEN duerfen jetzt beide -- einstellen, Vorbereitung, auf Sendung, beenden, Gaeste holen und entfernen, stummschalten, Anordnung, Video steuern. Beide dasselbe; es gibt hier keinen Ersten und keinen Zweiten. Das ist eine AUFGABE und kein Rang: Wer die Sendung fuehrt, bedient die Technik. 2. „ALLES SEHEN" heisst die Namensliste derer, die zusehen. Die bekamen bisher auch die linke Hand und die Modis, weil sie moderieren duerfen. Das war eine Vermischung zweier Dinge, die nichts miteinander zu tun haben: Wer einen Beitrag wegnehmen darf, muss deshalb nicht wissen, wer im Saal sitzt. Ab jetzt bekommt die Liste nur, wer auf der Buehne steht. Die Moderation bleibt unveraendert bei DogFather, beiden Haenden und den Modis -- einen Beitrag wegzunehmen ist weder „alles sehen" noch „vorbereiten" noch „einschalten", sondern dasselbe, was sie im Treff ohnehin tun. 3. WER AUF SENDUNG DRUECKT, IST IM BILD. Vorher stand als Host, wer zuletzt etwas eingestellt hatte. Mit zwei Leuten, die vorbereiten duerfen, waere das eine Falle: VanVan richtet am Nachmittag alles ein, Filipe drueckt abends auf Sendung -- und im Bild stuende VanVans Name, waehrend Filipe redet. Beim Einstellen wird `host_id` deshalb nur noch gefuellt, wenn dort noch niemand steht (damit die Ankuendigung „Als Naechstes" jemanden nennen kann). Wer die Sendung FUEHRT, entscheidet sich beim Einschalten. GEPRUEFT IN BEIDE RICHTUNGEN Nur zu messen, wer darf, hiesse: Die Tuer laesst sich spaeter weit aufmachen, ohne dass etwas rot wird. pruef-reaktion misst deshalb auch, wer ausdruecklich NICHT darf -- und dass es genau zwei sind: genau zwei fuehren die Sendung: admin, hand die rechte Hand darf einstellen / vorbereiten / Anordnung linke, modi, gast: duerfen nicht einstellen (403) ein Modi holt niemanden dazu (403) wer eingeschaltet hat, fuehrt die Sendung (Filipe) drueckt die rechte Hand, fuehrt sie (VanVan) die rechte Hand sieht, WER dabei ist -- sie fuehrt mit linke, modi: moderieren, bekommen die Namensliste aber nicht admin/hand: sehen Regiepult -- linke/modi/gast: nicht Das Regiepult haengt an `ich.host` und an nichts sonst. Ein zweiter Ort, an dem der Browser dieselbe Frage noch einmal beantwortet, waere der, der spaeter abweicht -- und dann stuenden Knoepfe da, die mit 403 antworten. pruef-reaktion 74 -> 88 Punkte, 0 Fehler. Dazu gruen: meldungen 8, rechtetafel 19, verborgen 25, modi-verborgen 85.
707 lines
29 KiB
JavaScript
707 lines
29 KiB
JavaScript
/* =====================================================================
|
|
DIE REACTION — die Wege (28.09.2026)
|
|
|
|
Der Ablauf aus Filipes Notiz, von links nach rechts:
|
|
|
|
Kachel sichtbar -> geschlossen -> Vorbereitung ->
|
|
Wartebereich/Chat -> Countdown -> LIVE ->
|
|
Reaction + Gaeste + Chat + PayPal -> Ende
|
|
|
|
Das sind drei Staende und nicht sieben. „Wartebereich" und
|
|
„Countdown" sind keine eigenen Zustaende, sondern das, was
|
|
„vorbereitung" auf dem Bildschirm TUT: Der Chat ist offen, der
|
|
Countdown laeuft, das Vorschaubild steht. Drei Staende, die sich
|
|
gegenseitig ausschliessen, sind pruefbar; sieben, von denen sich
|
|
vier ueberlappen, sind es nicht.
|
|
|
|
---------------------------------------------------------------------
|
|
WER WAS DARF
|
|
|
|
Host = DogFather. Er stellt ein, startet, beendet, holt und
|
|
entfernt Gaeste, schaltet stumm, bestimmt die Anordnung.
|
|
Moderation= DogFather, rechte und linke Hand, Modis. Sie koennen
|
|
Beitraege aus dem Live-Chat nehmen.
|
|
Gast = wen der Host holt. Jede Rolle des Hauses.
|
|
Zuschauer = jeder im Team-Dogi-Haus, auch die Community.
|
|
|
|
Die Reaction gibt es NUR im Team-Dogi-Haus. Auf der Agenturadresse
|
|
antwortet jeder Weg mit 404 -- nicht mit 403, denn ein „verboten"
|
|
verraet, dass es hier etwas gibt.
|
|
|
|
---------------------------------------------------------------------
|
|
DAS VIDEO LAEUFT NICHT UEBER DIESEN SERVER
|
|
|
|
Uebertragen wird nur der Spielstand: Kennung, laeuft/pausiert,
|
|
Sekunde. Jeder Browser laedt das Video selbst von YouTube. Die
|
|
Begruendung steht in reaktion-tabellen.js.
|
|
|
|
Aufrufen laesst sich alles ohne Sonderfall: Wer zusieht, meldet
|
|
sich alle 30 Sekunden mit `/dabei`. Wer sich zwei Minuten nicht
|
|
meldet, faellt heraus -- ein sauberes „Tschuess" gibt es im Netz
|
|
nicht, ein Handy geht einfach aus.
|
|
===================================================================== */
|
|
import express from "express";
|
|
import { db, sitzungLesen, protokolliere, echteIp } from "./workspace.js";
|
|
import { stromAn } from "./workspace-chat.js";
|
|
import { einstellung } from "./workspace.js";
|
|
import { mitZugangsdaten, wegeAusfaechern } from "./workspace-turn.js";
|
|
import {
|
|
SICHT_PLAETZE, GAESTE_MAX, TEXT_MAX, SCHREIB_BREMSE, STAENDE, LAYOUTS,
|
|
VERLAUF_TAGE,
|
|
} from "./reaktion-tabellen.js";
|
|
|
|
export const reaktionRouter = express.Router();
|
|
|
|
/** Wer die Sendung fuehrt: DogFather und die rechte Hand.
|
|
*
|
|
* Filipe am 28.09.2026: „die rolle dogfather und vanvan sollen alles
|
|
* sehen und bereit machen auch vorher. nur die zwei sollen alles
|
|
* sehen, vorbereiten und einschakten koennen."
|
|
*
|
|
* Das ist eine AUFGABE und kein Rang -- wer die Sendung fuehrt,
|
|
* bedient die Technik. Beide duerfen dasselbe; es gibt hier keinen
|
|
* Ersten und keinen Zweiten. */
|
|
const HOST_ROLLEN = new Set(["admin", "hand"]);
|
|
|
|
/** Wer Beitraege aus dem Chat nehmen darf.
|
|
*
|
|
* BLEIBT GROESSER ALS HOST_ROLLEN, und das ist Absicht: Einen
|
|
* Beitrag wegzunehmen ist weder „alles sehen" noch „vorbereiten"
|
|
* noch „einschalten" -- es ist dasselbe, was die Modis im Treff
|
|
* ohnehin tun. Wer moderiert, bekommt deshalb aber AUCH NICHT die
|
|
* Liste derer, die zusehen; die beiden Dinge hingen vorher
|
|
* zusammen, ohne dass es einen Grund dafuer gab. */
|
|
const MODERIERT = new Set(["admin", "hand", "linke", "modi"]);
|
|
|
|
export const REAKTION_HOST_ROLLEN = HOST_ROLLEN;
|
|
|
|
const jetzt = () => new Date().toISOString();
|
|
|
|
/* ---------- Schranken --------------------------------------------------- */
|
|
|
|
function angemeldet(req, res, next) {
|
|
const person = sitzungLesen(req);
|
|
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
|
|
req.person = person;
|
|
next();
|
|
}
|
|
|
|
/** Nicht das andere Haus.
|
|
*
|
|
* Gefragt wird „ist es die Agentur?" und nicht „ist es crew?" --
|
|
* auf einer Pruefadresse hat niemand ein Haus, und eine Schranke,
|
|
* die das mit dem fremden Haus verwechselt, macht die Seite dort
|
|
* still tot. Genau dieser Fehler ist am 27.09. beim Notizblock
|
|
* passiert und von pruef-handy gefunden worden. */
|
|
function nurTeamDogi(req, res, next) {
|
|
if (req.person?.haus === "agentur") {
|
|
return res.status(404).json({ fehler: "nicht_gefunden" });
|
|
}
|
|
next();
|
|
}
|
|
|
|
function nurHost(req, res, next) {
|
|
if (!HOST_ROLLEN.has(req.person?.rolle)) {
|
|
return res.status(403).json({ fehler: "nur_host" });
|
|
}
|
|
next();
|
|
}
|
|
|
|
/* Schutz gegen Anfragen von fremden Seiten -- wortgleich zu den
|
|
uebrigen Modulen. */
|
|
function gleicheHerkunft(req, res, next) {
|
|
const herkunft = req.get("origin");
|
|
if (!herkunft) return next();
|
|
let erlaubt;
|
|
try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; }
|
|
if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" });
|
|
next();
|
|
}
|
|
|
|
/* ---------- Die Schreibbremse -------------------------------------------
|
|
Ein Live-Chat ist der eine Ort im Haus, an dem jemand in zehn
|
|
Sekunden dreissig Zeilen schreiben kann -- aus Begeisterung oder
|
|
aus Aerger. Die Bremse zaehlt je Person und Minute. */
|
|
const bremse = new Map();
|
|
function zuSchnell(personId) {
|
|
const minute = Math.floor(Date.now() / 60_000);
|
|
const s = bremse.get(personId);
|
|
if (!s || s.minute !== minute) { bremse.set(personId, { minute, n: 1 }); return false; }
|
|
s.n += 1;
|
|
return s.n > SCHREIB_BREMSE;
|
|
}
|
|
|
|
/* ---------- Hilfen ------------------------------------------------------ */
|
|
|
|
const sendung = () => db().prepare("SELECT * FROM reaktion WHERE id = 1").get();
|
|
|
|
function gaesteListe() {
|
|
return db().prepare(`
|
|
SELECT g.person_id AS id, g.stumm, g.platz, p.name, p.rolle
|
|
FROM reaktion_gaeste g JOIN personen p ON p.id = g.person_id
|
|
ORDER BY g.platz`).all();
|
|
}
|
|
|
|
/** Wer gerade dabei ist -- und ob noch ein Kameraplatz frei war.
|
|
*
|
|
* ZWEI MINUTEN OHNE LEBENSZEICHEN HEISST WEG. Ein Browser, der
|
|
* zugeklappt wird, schickt kein „ich gehe". Ohne diese Grenze
|
|
* waechst die Zuschauerzahl den ganzen Abend und stimmt nie. */
|
|
function dabeiListe() {
|
|
const grenze = new Date(Date.now() - 120_000).toISOString();
|
|
db().prepare("DELETE FROM reaktion_dabei WHERE gesehen < ?").run(grenze);
|
|
return db().prepare(`
|
|
SELECT d.person_id AS id, d.sicht, d.seit, p.name, p.rolle
|
|
FROM reaktion_dabei d JOIN personen p ON p.id = d.person_id
|
|
ORDER BY d.seit`).all();
|
|
}
|
|
|
|
/** Alle, die von einer Aenderung erfahren sollen. */
|
|
function empfaenger() {
|
|
const ids = new Set(dabeiListe().map((d) => d.id));
|
|
for (const g of gaesteListe()) ids.add(g.id);
|
|
const s = sendung();
|
|
if (s?.host_id) ids.add(s.host_id);
|
|
return [...ids];
|
|
}
|
|
|
|
/** Was jeder sehen darf -- egal ob Zuschauer, Gast oder Host. */
|
|
function lage(person) {
|
|
const s = sendung();
|
|
const gaeste = gaesteListe();
|
|
const dabei = dabeiListe();
|
|
const istHost = HOST_ROLLEN.has(person.rolle);
|
|
const binGast = gaeste.some((g) => g.id === person.id);
|
|
return {
|
|
stand: s.stand,
|
|
titel: s.titel,
|
|
video: s.video,
|
|
vorschau: s.vorschau,
|
|
beginnt_am: s.beginnt_am,
|
|
layout: s.layout,
|
|
laeuft: !!s.laeuft,
|
|
sekunde: s.sekunde,
|
|
gestartet_am: s.gestartet_am,
|
|
host: s.host_id
|
|
? db().prepare("SELECT id, name, rolle FROM personen WHERE id = ?").get(s.host_id)
|
|
: null,
|
|
gaeste: gaeste.map((g) => ({ id: g.id, name: g.name, rolle: g.rolle, stumm: !!g.stumm, platz: g.platz })),
|
|
dabei: dabei.length,
|
|
/* ==== WER DIE LISTE BEKOMMT -- UND WARUM GENAU DIESE ==========
|
|
|
|
Namen der Zusehenden bekommt, wer auf der BUEHNE steht (Host,
|
|
Gaeste) oder moderiert. Ein Zuschauer bekommt sie nicht: Er
|
|
soll nicht ablesen koennen, wer sonst noch zusieht -- im Live
|
|
sieht man einander am Chat, und das reicht.
|
|
|
|
DIE GAESTE STEHEN HIER NICHT AUS HOEFLICHKEIT. Wer sendet,
|
|
muss wissen, wen er anrufen soll: Zuschauer rufen grundsaetzlich
|
|
nie an (sonst rufen zwei gleichzeitig an, und beide
|
|
scheitern -- „glare"). Ohne die Liste kaeme das Bild eines
|
|
Gastes deshalb bei keinem Zuschauer an. Zwei fuer sich richtige
|
|
Regeln, die zusammen ein Loch ergeben.
|
|
|
|
DIE MODERATION STAND HIER AUCH -- SEIT DEM 28.09. NICHT MEHR.
|
|
Filipe: „nur die zwei sollen alles sehen." Wer einen Beitrag
|
|
wegnehmen darf, muss deshalb nicht wissen, wer im Saal sitzt;
|
|
das war eine Vermischung zweier Dinge, die nichts miteinander
|
|
zu tun haben. Moderiert wird weiter -- im Chat sieht man
|
|
ohnehin, wer da ist. */
|
|
zuschauer: (istHost || binGast)
|
|
? dabei.map((d) => ({ id: d.id, name: d.name, rolle: d.rolle, sicht: !!d.sicht }))
|
|
: null,
|
|
ich: {
|
|
id: person.id,
|
|
name: person.name,
|
|
rolle: person.rolle,
|
|
host: istHost,
|
|
gast: binGast,
|
|
moderiert: MODERIERT.has(person.rolle),
|
|
sicht: !!(dabei.find((d) => d.id === person.id)?.sicht),
|
|
},
|
|
plaetze: SICHT_PLAETZE,
|
|
gaeste_max: GAESTE_MAX,
|
|
text_max: TEXT_MAX,
|
|
};
|
|
}
|
|
|
|
function melden(art, daten) {
|
|
stromAn(empfaenger(), art, daten);
|
|
}
|
|
|
|
/* ======================================================================
|
|
DIE WEGE
|
|
====================================================================== */
|
|
reaktionRouter.use("/workspace/api/reaktion", angemeldet, nurTeamDogi, gleicheHerkunft);
|
|
|
|
/** Wie steht es? — der eine Aufruf, den jede Seite beim Laden macht. */
|
|
reaktionRouter.get("/workspace/api/reaktion", (req, res) => {
|
|
try {
|
|
res.json(lage(req.person));
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Lage:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/** Der Host stellt ein: Titel, Video, Vorschaubild, Beginn. */
|
|
reaktionRouter.put("/workspace/api/reaktion", nurHost, express.json({ limit: "32kb" }),
|
|
(req, res) => {
|
|
try {
|
|
const titel = String(req.body?.titel ?? "").trim().slice(0, 120);
|
|
const video = videoKennung(String(req.body?.video ?? ""));
|
|
const vorschau = String(req.body?.vorschau ?? "").trim().slice(0, 400);
|
|
const beginn = req.body?.beginnt_am ? String(req.body.beginnt_am).slice(0, 40) : null;
|
|
|
|
if (req.body?.video && !video) {
|
|
return res.status(400).json({ fehler: "video_unklar" });
|
|
}
|
|
/* EIN BEGINN IN DER VERGANGENHEIT IST KEIN BEGINN. Der Countdown
|
|
zaehlte sonst rueckwaerts ins Minus, und niemand koennte
|
|
sagen, ob das Absicht war. */
|
|
if (beginn && Number.isFinite(Date.parse(beginn)) && Date.parse(beginn) < Date.now() - 60_000) {
|
|
return res.status(400).json({ fehler: "beginn_vorbei" });
|
|
}
|
|
|
|
/* HOST_ID WIRD BEIM EINSTELLEN NUR GESETZT, WENN NOCH NIEMAND
|
|
DASTEHT (28.09.2026).
|
|
|
|
Vorher stand hier, wer zuletzt etwas eingestellt hat. Mit
|
|
zwei Leuten, die vorbereiten duerfen, ist das eine Falle:
|
|
VanVan richtet am Nachmittag alles ein, Filipe drueckt
|
|
abends auf Sendung -- und im Bild stuende VanVans Name,
|
|
waehrend Filipe redet.
|
|
|
|
Wer die Sendung FUEHRT, entscheidet sich beim Einschalten
|
|
(siehe /stand). Hier wird nur der leere Platz gefuellt, damit
|
|
die Ankuendigung „Als Naechstes" jemanden nennen kann. */
|
|
const bisher = sendung();
|
|
db().prepare(`UPDATE reaktion SET titel = ?, video = ?, vorschau = ?,
|
|
beginnt_am = ?, host_id = ?, geaendert = ? WHERE id = 1`)
|
|
.run(titel, video, vorschau, beginn,
|
|
bisher.host_id || req.person.id, jetzt());
|
|
|
|
const l = lage(req.person);
|
|
melden("reaktion", { was: "eingestellt", lage: oeffentlich(l) });
|
|
res.json(l);
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Einstellen:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/** Der Stand wechselt: zu / vorbereitung / live. */
|
|
reaktionRouter.post("/workspace/api/reaktion/stand", nurHost, express.json({ limit: "4kb" }),
|
|
(req, res) => {
|
|
try {
|
|
const neu = String(req.body?.stand ?? "");
|
|
if (!STAENDE.includes(neu)) return res.status(400).json({ fehler: "stand_unbekannt" });
|
|
const s = sendung();
|
|
|
|
/* OHNE VIDEO KEIN LIVE. Der Knopf waere sonst da, das Ergebnis
|
|
ein schwarzes Rechteck -- und die Leute sitzen davor. */
|
|
if (neu === "live" && !s.video) {
|
|
return res.status(400).json({ fehler: "kein_video" });
|
|
}
|
|
|
|
const felder = { stand: neu, stand_seit: jetzt(), geaendert: jetzt() };
|
|
if (neu === "live" && s.stand !== "live") {
|
|
felder.gestartet_am = jetzt();
|
|
felder.laeuft = 1;
|
|
felder.sekunde = 0;
|
|
/* WER EINSCHALTET, IST IM BILD. Seine Kamera bekommt den
|
|
Platz des Hosts -- und sein Name steht daran. Der andere
|
|
kann jederzeit als Gast dazukommen. */
|
|
felder.host_id = req.person.id;
|
|
}
|
|
if (neu === "zu" && s.stand === "live") {
|
|
/* WAS VORBEI IST, WANDERT IN DEN VERLAUF -- und der Live-Chat
|
|
dieser Sendung ist damit Geschichte. Wuerde er stehen
|
|
bleiben, faenge die naechste Sendung mit den Rufen der
|
|
letzten an. */
|
|
const zahl = db().prepare("SELECT COUNT(*) AS n FROM reaktion_chat WHERE weg_am IS NULL").get();
|
|
db().prepare(`INSERT INTO reaktion_verlauf
|
|
(titel, video, host_id, begonnen, beendet, zuschauer, beitraege)
|
|
VALUES (?,?,?,?,?,?,?)`)
|
|
.run(s.titel, s.video, s.host_id, s.gestartet_am, jetzt(),
|
|
dabeiListe().length, zahl?.n || 0);
|
|
db().exec("DELETE FROM reaktion_chat");
|
|
db().exec("DELETE FROM reaktion_gaeste");
|
|
felder.laeuft = 0;
|
|
felder.sekunde = 0;
|
|
felder.gestartet_am = null;
|
|
}
|
|
if (neu === "zu") db().exec("DELETE FROM reaktion_dabei");
|
|
|
|
const spalten = Object.keys(felder).map((k) => `${k} = ?`).join(", ");
|
|
db().prepare(`UPDATE reaktion SET ${spalten} WHERE id = 1`)
|
|
.run(...Object.values(felder));
|
|
|
|
protokolliere(req.person.id, "reaktion_stand", `${s.stand} -> ${neu}`, echteIp(req));
|
|
const l = lage(req.person);
|
|
melden("reaktion", { was: "stand", lage: oeffentlich(l) });
|
|
res.json(l);
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Stand:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/** Ich bin da / ich bin noch da. Alle 30 Sekunden. */
|
|
reaktionRouter.post("/workspace/api/reaktion/dabei", express.json({ limit: "2kb" }),
|
|
(req, res) => {
|
|
try {
|
|
const s = sendung();
|
|
if (s.stand === "zu") return res.status(409).json({ fehler: "geschlossen" });
|
|
|
|
const schon = db().prepare("SELECT * FROM reaktion_dabei WHERE person_id = ?")
|
|
.get(req.person.id);
|
|
if (schon) {
|
|
db().prepare("UPDATE reaktion_dabei SET gesehen = ? WHERE person_id = ?")
|
|
.run(jetzt(), req.person.id);
|
|
} else {
|
|
/* DER KAMERAPLATZ WIRD BEIM EINTRETEN VERGEBEN, nicht beim
|
|
Zeichnen. Sonst haetten zwei gleichzeitig eintretende
|
|
Zuschauer denselben letzten Platz gelesen und beide
|
|
gerechnet, sie haetten ihn. */
|
|
const mitSicht = db().prepare(
|
|
"SELECT COUNT(*) AS n FROM reaktion_dabei WHERE sicht = 1").get()?.n || 0;
|
|
db().prepare("INSERT INTO reaktion_dabei (person_id, seit, gesehen, sicht) VALUES (?,?,?,?)")
|
|
.run(req.person.id, jetzt(), jetzt(), mitSicht < SICHT_PLAETZE ? 1 : 0);
|
|
melden("reaktion", { was: "dabei", lage: oeffentlich(lage(req.person)) });
|
|
}
|
|
res.json(lage(req.person));
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Dabei:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
function abmelden(req, res) {
|
|
try {
|
|
db().prepare("DELETE FROM reaktion_dabei WHERE person_id = ?").run(req.person.id);
|
|
melden("reaktion", { was: "weg", lage: oeffentlich(lage(req.person)) });
|
|
res.json({ ok: true });
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Weg:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
}
|
|
|
|
reaktionRouter.delete("/workspace/api/reaktion/dabei", abmelden);
|
|
|
|
/* DERSELBE WEG NOCH EINMAL ALS POST.
|
|
|
|
Beim Schliessen des Fensters wird ein gewoehnlicher Abruf
|
|
abgebrochen -- dafuer gibt es `navigator.sendBeacon`. Der schickt
|
|
aber IMMER POST; ein DELETE waere nie angekommen, und jeder haette
|
|
danach zwei Minuten lang als anwesend gegolten. Die Zuschauerzahl
|
|
waere den ganzen Abend gestiegen und nie gefallen.
|
|
|
|
Kein zweiter Code, nur ein zweiter Eingang: Beide zeigen auf
|
|
dieselbe Funktion. */
|
|
reaktionRouter.post("/workspace/api/reaktion/dabei-weg", abmelden);
|
|
|
|
/* ---------- Die Verbindungsadressen -------------------------------------
|
|
STUN sagt einem Browser, wie er von aussen aussieht; TURN leitet
|
|
weiter, wenn eine direkte Verbindung nicht zustande kommt.
|
|
|
|
WARUM DIE REACTION EINE EIGENE TUER HAT: Vor den Anruf-Wegen steht
|
|
`darfTelefonieren`, und die Community ist dort ausdruecklich nicht
|
|
dabei -- ein Zuschauer soll kein Teammitglied anklingeln koennen.
|
|
Das ist richtig und bleibt. Hier sitzt derselbe Mensch aber im
|
|
Saal, und ohne Adressen bliebe sein Bild schwarz.
|
|
|
|
Dieselbe Einstellung, dieselben Helfer, andere Tuer. Und die Tuer
|
|
ist nur offen, solange etwas laeuft: Bei „zu" gibt es nichts zu
|
|
holen -- sonst waere das ein dauerhaft offener Weg zu den
|
|
TURN-Zugangsdaten fuer jeden im Haus. */
|
|
reaktionRouter.get("/workspace/api/reaktion/adressen", (req, res) => {
|
|
try {
|
|
if (sendung().stand === "zu") return res.json({ adressen: [] });
|
|
let liste = [];
|
|
try { liste = JSON.parse(einstellung("anruf_adressen") || "[]"); } catch { liste = []; }
|
|
/* Als Kennung die Personennummer und nicht der Name -- sie landet
|
|
im Protokoll des TURN-Servers, und das ist kein Ort fuer
|
|
Klarnamen. Wortgleich zum Anrufteil. */
|
|
const { adressen } = mitZugangsdaten(wegeAusfaechern(liste), `r${req.person.id}`);
|
|
res.json({ adressen });
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Adressen:", fehler?.message);
|
|
/* KEIN 503. Ohne eigene Adressen nimmt der Browser seine Vorgabe
|
|
und die Sendung laeuft weiter -- eine leere Liste ist hier die
|
|
ehrlichere Antwort als ein Fehler, der nach Ausfall aussieht. */
|
|
res.json({ adressen: [] });
|
|
}
|
|
});
|
|
|
|
/* ---------- Gaeste ------------------------------------------------------ */
|
|
|
|
reaktionRouter.post("/workspace/api/reaktion/gast", nurHost, express.json({ limit: "4kb" }),
|
|
(req, res) => {
|
|
try {
|
|
const id = Number(req.body?.person_id);
|
|
if (!Number.isInteger(id) || id < 1) return res.status(400).json({ fehler: "person_unklar" });
|
|
const p = db().prepare("SELECT id, name, rolle FROM personen WHERE id = ? AND aktiv = 1").get(id);
|
|
if (!p) return res.status(404).json({ fehler: "nicht_gefunden" });
|
|
if (id === req.person.id) return res.status(400).json({ fehler: "host_ist_kein_gast" });
|
|
|
|
const da = db().prepare("SELECT COUNT(*) AS n FROM reaktion_gaeste").get()?.n || 0;
|
|
if (da >= GAESTE_MAX) return res.status(409).json({ fehler: "gaeste_voll" });
|
|
|
|
/* Der Platz ist die kleinste freie Zahl -- nicht „Anzahl + 1".
|
|
Wer Gast 1 entfernt und dann jemanden holt, bekaeme sonst
|
|
wieder Platz 2, und Platz 1 bliebe fuer immer leer. */
|
|
const belegt = new Set(gaesteListe().map((g) => g.platz));
|
|
let platz = 1;
|
|
while (belegt.has(platz)) platz++;
|
|
|
|
db().prepare("INSERT OR REPLACE INTO reaktion_gaeste (person_id, seit, stumm, platz) VALUES (?,?,0,?)")
|
|
.run(id, jetzt(), platz);
|
|
protokolliere(req.person.id, "reaktion_gast_dazu", `${p.name} (Platz ${platz})`, echteIp(req));
|
|
const l = lage(req.person);
|
|
melden("reaktion", { was: "gaeste", lage: oeffentlich(l) });
|
|
stromAn([id], "reaktion", { was: "du_bist_gast", lage: oeffentlich(l) });
|
|
res.status(201).json(l);
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Gast:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
reaktionRouter.delete("/workspace/api/reaktion/gast/:id", nurHost, (req, res) => {
|
|
try {
|
|
const id = Number(req.params.id);
|
|
const weg = db().prepare("DELETE FROM reaktion_gaeste WHERE person_id = ?").run(id);
|
|
if (!weg.changes) return res.status(404).json({ fehler: "nicht_gefunden" });
|
|
const l = lage(req.person);
|
|
melden("reaktion", { was: "gaeste", lage: oeffentlich(l) });
|
|
stromAn([id], "reaktion", { was: "du_bist_kein_gast", lage: oeffentlich(l) });
|
|
res.json(l);
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Gast weg:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
reaktionRouter.put("/workspace/api/reaktion/gast/:id/stumm", nurHost,
|
|
express.json({ limit: "2kb" }), (req, res) => {
|
|
try {
|
|
const id = Number(req.params.id);
|
|
const stumm = req.body?.stumm ? 1 : 0;
|
|
const a = db().prepare("UPDATE reaktion_gaeste SET stumm = ? WHERE person_id = ?")
|
|
.run(stumm, id);
|
|
if (!a.changes) return res.status(404).json({ fehler: "nicht_gefunden" });
|
|
const l = lage(req.person);
|
|
melden("reaktion", { was: "gaeste", lage: oeffentlich(l) });
|
|
res.json(l);
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Stumm:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/* ---------- Das Video --------------------------------------------------
|
|
Der Host sagt, wo das Video steht. Alle anderen folgen.
|
|
|
|
GESCHICKT WIRD DER SPIELSTAND, NICHT DAS BILD -- die Begruendung
|
|
steht in reaktion-tabellen.js. Hier steht nur die Folge davon:
|
|
Diese Route ist die einzige, die waehrend der Sendung oft laeuft,
|
|
und sie schreibt deshalb so wenig wie moeglich. */
|
|
reaktionRouter.post("/workspace/api/reaktion/video", nurHost, express.json({ limit: "2kb" }),
|
|
(req, res) => {
|
|
try {
|
|
const laeuft = req.body?.laeuft ? 1 : 0;
|
|
const sekunde = Math.max(0, Number(req.body?.sekunde) || 0);
|
|
const video = req.body?.video === undefined ? null : videoKennung(String(req.body.video));
|
|
if (req.body?.video !== undefined && !video) {
|
|
return res.status(400).json({ fehler: "video_unklar" });
|
|
}
|
|
if (video) {
|
|
db().prepare("UPDATE reaktion SET video = ?, laeuft = ?, sekunde = ?, geaendert = ? WHERE id = 1")
|
|
.run(video, laeuft, sekunde, jetzt());
|
|
} else {
|
|
db().prepare("UPDATE reaktion SET laeuft = ?, sekunde = ? WHERE id = 1")
|
|
.run(laeuft, sekunde);
|
|
}
|
|
const s = sendung();
|
|
melden("reaktion", {
|
|
was: "video",
|
|
video: s.video, laeuft: !!s.laeuft, sekunde: s.sekunde,
|
|
/* Die Uhrzeit des Servers geht mit: Der Empfaenger rechnet
|
|
die Laufzeit seit dem Absenden dazu. Ohne das waere jeder
|
|
genau so weit hinten, wie sein Netz langsam ist. */
|
|
gesendet: Date.now(),
|
|
});
|
|
res.json({ ok: true });
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Video:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
reaktionRouter.post("/workspace/api/reaktion/layout", nurHost, express.json({ limit: "2kb" }),
|
|
(req, res) => {
|
|
try {
|
|
const l = String(req.body?.layout ?? "");
|
|
if (!LAYOUTS.includes(l)) return res.status(400).json({ fehler: "layout_unbekannt" });
|
|
db().prepare("UPDATE reaktion SET layout = ? WHERE id = 1").run(l);
|
|
melden("reaktion", { was: "layout", layout: l });
|
|
res.json({ ok: true, layout: l });
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Layout:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/* ---------- Der Live-Chat ----------------------------------------------- */
|
|
|
|
reaktionRouter.get("/workspace/api/reaktion/chat", (req, res) => {
|
|
try {
|
|
const reihen = db().prepare(`
|
|
SELECT id, person_id, name, rolle, text, erstellt
|
|
FROM reaktion_chat WHERE weg_am IS NULL ORDER BY id DESC LIMIT 120`).all();
|
|
res.json({ beitraege: reihen.reverse() });
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Chat:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
reaktionRouter.post("/workspace/api/reaktion/chat", express.json({ limit: "8kb" }),
|
|
(req, res) => {
|
|
try {
|
|
const s = sendung();
|
|
if (s.stand === "zu") return res.status(409).json({ fehler: "geschlossen" });
|
|
const text = String(req.body?.text ?? "").trim().slice(0, TEXT_MAX);
|
|
if (!text) return res.status(400).json({ fehler: "leer" });
|
|
if (zuSchnell(req.person.id)) return res.status(429).json({ fehler: "zu_schnell" });
|
|
|
|
const { lastInsertRowid } = db().prepare(`INSERT INTO reaktion_chat
|
|
(person_id, name, rolle, text, erstellt) VALUES (?,?,?,?,?)`)
|
|
.run(req.person.id, req.person.name, req.person.rolle, text, jetzt());
|
|
const beitrag = db().prepare(
|
|
"SELECT id, person_id, name, rolle, text, erstellt FROM reaktion_chat WHERE id = ?")
|
|
.get(Number(lastInsertRowid));
|
|
melden("reaktion_chat", { was: "neu", beitrag });
|
|
res.status(201).json(beitrag);
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Schreiben:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/** Moderation: einen Beitrag wegnehmen.
|
|
*
|
|
* WEGGENOMMEN UND NICHT GELOESCHT. Wer moderiert, soll spaeter
|
|
* sagen koennen, was er weggenommen hat und warum -- eine geloeschte
|
|
* Zeile kann das nicht. */
|
|
reaktionRouter.delete("/workspace/api/reaktion/chat/:id", (req, res) => {
|
|
try {
|
|
const id = Number(req.params.id);
|
|
const b = db().prepare("SELECT * FROM reaktion_chat WHERE id = ?").get(id);
|
|
if (!b || b.weg_am) return res.status(404).json({ fehler: "nicht_gefunden" });
|
|
|
|
const eigen = b.person_id === req.person.id;
|
|
if (!eigen && !MODERIERT.has(req.person.rolle)) {
|
|
return res.status(403).json({ fehler: "nicht_erlaubt" });
|
|
}
|
|
db().prepare("UPDATE reaktion_chat SET weg_am = ?, weg_von = ? WHERE id = ?")
|
|
.run(jetzt(), req.person.id, id);
|
|
if (!eigen) {
|
|
protokolliere(req.person.id, "reaktion_chat_weg", `Beitrag ${id} von ${b.name}`, echteIp(req));
|
|
}
|
|
melden("reaktion_chat", { was: "weg", id });
|
|
res.json({ ok: true });
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Wegnehmen:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/* ---------- Die Kameras ------------------------------------------------
|
|
Nur der Hinweg. Was in `signal` steht, liest dieser Server nicht --
|
|
es geht unveraendert an den Empfaenger. Genau so macht es der
|
|
Anrufteil seit dem 18.09.
|
|
|
|
GESCHICKT WIRD NUR AN LEUTE, DIE GERADE DABEI SIND. Ohne das
|
|
koennte jemand aus dem Haus Verbindungsdaten an jeden Beliebigen
|
|
schicken, und die Reaction waere ein Weg, jemanden anzuklingeln. */
|
|
reaktionRouter.post("/workspace/api/reaktion/signal", express.json({ limit: "256kb" }),
|
|
(req, res) => {
|
|
try {
|
|
const an = Number(req.body?.an);
|
|
const daten = req.body?.daten;
|
|
if (!Number.isInteger(an) || daten === undefined) {
|
|
return res.status(400).json({ fehler: "unvollstaendig" });
|
|
}
|
|
const kreis = new Set(empfaenger());
|
|
if (!kreis.has(an) || !kreis.has(req.person.id)) {
|
|
return res.status(409).json({ fehler: "nicht_dabei" });
|
|
}
|
|
const wie_viele = stromAn([an], "reaktion_signal", {
|
|
von: req.person.id, name: req.person.name, daten,
|
|
});
|
|
res.json({ ok: true, zugestellt: wie_viele });
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Signal:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/* ======================================================================
|
|
HILFEN
|
|
====================================================================== */
|
|
|
|
/** Aus allem, was jemand einfuegen koennte, die YouTube-Kennung.
|
|
*
|
|
* Angenommen werden die vier Formen, die man wirklich in der
|
|
* Zwischenablage hat: die lange Adresse, die kurze, der
|
|
* Einbett-Pfad und die nackte Kennung. Alles andere wird
|
|
* abgelehnt -- eine falsche Kennung ist ein schwarzes Rechteck vor
|
|
* Publikum, und das faellt erst auf, wenn alle zusehen. */
|
|
export function videoKennung(roh) {
|
|
const t = String(roh || "").trim();
|
|
if (!t) return "";
|
|
if (/^[A-Za-z0-9_-]{11}$/.test(t)) return t;
|
|
const muster = [
|
|
/[?&]v=([A-Za-z0-9_-]{11})/,
|
|
/youtu\.be\/([A-Za-z0-9_-]{11})/,
|
|
/youtube\.com\/embed\/([A-Za-z0-9_-]{11})/,
|
|
/youtube\.com\/shorts\/([A-Za-z0-9_-]{11})/,
|
|
/youtube\.com\/live\/([A-Za-z0-9_-]{11})/,
|
|
];
|
|
for (const m of muster) {
|
|
const t2 = t.match(m);
|
|
if (t2) return t2[1];
|
|
}
|
|
return "";
|
|
}
|
|
|
|
/** Die Lage ohne die Namensliste -- fuer den Rundruf.
|
|
*
|
|
* `lage()` gibt einem Host die Namen aller Zusehenden. Genau diese
|
|
* Antwort geht per Strom an ALLE. Ohne dieses Sieb bekaeme jeder
|
|
* Zuschauer die Liste, die nur die Leitung sehen darf -- und zwar
|
|
* ohne dass es jemandem auffaellt, weil die Oberflaeche sie ja
|
|
* nicht anzeigt. */
|
|
function oeffentlich(l) {
|
|
const { zuschauer, ich, ...rest } = l;
|
|
return rest;
|
|
}
|
|
|
|
/** Aufraeumen: alter Verlauf und liegengebliebene Zusehende. */
|
|
export function reaktionAufraeumen() {
|
|
try {
|
|
const grenze = new Date(Date.now() - VERLAUF_TAGE * 86400_000).toISOString();
|
|
const a = db().prepare("DELETE FROM reaktion_verlauf WHERE beendet < ?").run(grenze);
|
|
const b = db().prepare("DELETE FROM reaktion_dabei WHERE gesehen < ?")
|
|
.run(new Date(Date.now() - 600_000).toISOString());
|
|
if (a.changes || b.changes) {
|
|
console.log(`[reaktion] Aufgeraeumt: ${a.changes} Verlauf, ${b.changes} Zusehende.`);
|
|
}
|
|
} catch (fehler) {
|
|
console.error("[reaktion] Aufraeumen:", fehler?.message);
|
|
}
|
|
}
|