Der Treff sagt jetzt, ob gerade gestreamt wird
Filipe zu dieser Seite: "mach was anderes daraus oder mach es viel krass und geiler ... erstell was was mich von den socken schmeisst. es soll wirklich sogar ein wow effekt fuer die community ergeben." Dazu die eine Auflage: die Kachel bleibt. WAS SIE WAR: zwei Karten mit zwei Adressen. Sauber gebaut, richtig beschriftet -- und niemand kam ein zweites Mal wieder. Wer im Treff ist, kennt die Website. Den Rabattcode merkt man sich beim ersten Mal. DIE FRAGE, DIE WIRKLICH JEMAND HAT, IST EINE ANDERE: Laeuft er gerade? Und wenn nicht -- wie lange noch? Die Antwort kennt das Haus seit dem 20.08.: postfach.dogfather-universe.com/live-status, derselbe Dienst, von dem der oeffentliche Streamplan lebt. Im Treff gab es sie bisher nicht. Jetzt steht oben ein Puls: ein Ring, der sich ueber den Tag bis zur naechsten Sendezeit fuellt, darin der Countdown auf die Sekunde. Sendet er, wird die Karte rot, atmet im Ruhepuls und traegt den Titel des Streams plus einen Weg hin. Darunter die drei Accounts und -- wenn die Verwaltung eines eintraegt -- das besondere Event mit eigener Uhr. DREI AUSGAENGE, NICHT ZWEI. Das ist der eigentliche Unterschied zur oeffentlichen Seite: streamplan.js faengt dort jeden Fehler ab und setzt `istLive = false`. Aus "wir konnten nicht fragen" wird "er sendet nicht" -- und wer das liest, macht die Seite zu und verpasst den Stream. Der Dienst liefert `autoHealthy` sogar mit; gelesen wird es draussen nicht. Hier schon: Bei einer Stoerung steht "Konnten wir gerade nicht nachsehen", dazu der Hinweis, dass das NICHT heisst, dass kein Stream laeuft. Der Countdown laeuft dabei weiter -- er braucht kein Netz. NICHTS IST ABGESCHRIEBEN. Die drei Accounts kommen ueber /workspace/api/draussen aus derselben KANAELE-Liste, an der die Aufgabenverteilung haengt; die Adressen baut der Steckbrief. Die Sendezeit steht im Server, und pruef-draussen haelt sie gegen FIX_STUNDE in streamplan.js -- verschiebt jemand den Termin und aendert nur einen Ort, wird die Pruefung rot. Gemessen, bevor gebaut wurde: der Dienst antwortet und meldet sich gesund, CORS steht auf *, und `connect-src` der Treff-Seiten fuehrt postfach bereits. Ohne das Letzte waere der Abruf still blockiert worden und haette ausgesehen wie ein toter Dienst. Die Kachel heisst jetzt "Draussen" statt "Unsere Seiten" -- drei Dinge statt zwei Adressen. Ziel und Datei bleiben gleich. pruef-wege-nach-draussen schreibt den Namen nicht mehr ab, sondern verlangt, dass Kachel und Seitenueberschrift uebereinstimmen. Zwei Namen fuer denselben Ort waren schon einmal ein echter Fehler. Nebenbefund, dort gleich behoben: Dieselbe Pruefung verlangte, dass die Community-Kacheln restlos in Dreierreihen aufgehen -- obwohl ihr eigener Kommentar sagt, eine freie Zelle am Ende sei erlaubt. Sie war rot bei einwandfreiem Raster. Gemessen wird jetzt, was wirklich schiefgeht: eine doppelt breite Kachel, die in der letzten Spalte beginnt und die Zelle davor leer laesst. Mit Gegenprobe. pruef-draussen: 39 Pruefungen, alle vier Lagen im echten Browser -- die Stoerung wird dafuer absichtlich hergestellt. pruef-wege-nach-draussen: 67 statt 66, keine Fehler mehr. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -46,6 +46,7 @@ import { kiRouter } from "./workspace-ki.js";
|
||||
import { contentRouter } from "./workspace-content.js";
|
||||
import { sicherungRouter, sicherungStarten } from "./workspace-sicherung.js";
|
||||
import { steckbriefRouter } from "./workspace-steckbrief.js";
|
||||
import { draussenRouter } from "./workspace-draussen.js";
|
||||
import { vorlagenRouter } from "./workspace-vorlagen.js";
|
||||
/* Die Arbeitslage von Managern und Scouts -- nur fuer Spicy Media und
|
||||
DogFather. Sie ruehrt Termine, Chats und Dateien nicht an; die
|
||||
@@ -217,6 +218,7 @@ app.use(sicherungRouter);
|
||||
JSON in einem <img> ergibt nur ein kaputtes Bild ohne jeden Hinweis.
|
||||
Der Steckbrief bringt seine eigene Schranke mit. */
|
||||
app.use(steckbriefRouter);
|
||||
app.use(draussenRouter);
|
||||
/* Ebenfalls VOR aufgabenRouter: Der Service Worker holt den
|
||||
oeffentlichen Schluessel, bevor irgendetwas anderes laeuft, und die
|
||||
Push-Wege bringen ihre eigene Schranke mit. */
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
/* =====================================================================
|
||||
DRAUSSEN -- der Puls auf der Community-Seite (21.09.2026)
|
||||
|
||||
Filipe: "mach was anderes aus dieser seite oder mach es viel krass
|
||||
und geiler ... erstell was was mich von den socken schmeisst. es
|
||||
soll wirklich sogar ein wow effekt fuer die community ergeben."
|
||||
|
||||
---------------------------------------------------------------------
|
||||
WAS HIER GEPRUEFT WIRD, UND WARUM AUSGERECHNET DAS
|
||||
|
||||
Die Seite beantwortet jetzt die Frage "laeuft er gerade?". Eine
|
||||
Anzeige, die diese Frage beantwortet, hat genau eine Art, wirklich
|
||||
Schaden anzurichten: Sie sagt "nicht live", ohne nachgesehen zu
|
||||
haben. Wer das liest, macht die Seite zu -- und verpasst den Stream.
|
||||
|
||||
DAS IST KEIN GEDANKENSPIEL. Genau so macht es die oeffentliche
|
||||
Seite: `streamplan.js` faengt jeden Fehler ab und setzt
|
||||
`istLive = false`. Aus "wir konnten nicht fragen" wird "er sendet
|
||||
nicht". Der Dienst liefert sogar `autoHealthy` mit, damit man das
|
||||
unterscheiden kann -- gelesen wird es dort nicht.
|
||||
|
||||
DESHALB WIRD DIE STOERUNG HIER ABSICHTLICH HERGESTELLT. Vier Lagen,
|
||||
vier Messungen, und die wichtigste ist die, in der nichts antwortet:
|
||||
|
||||
1. er sendet -> "JETZT live", Weg zum Stream
|
||||
2. er sendet nicht -> Countdown
|
||||
3. der Pruefer ist gestoert -> "konnten wir nicht nachsehen"
|
||||
4. gar keine Antwort -> dasselbe, nicht "nicht live"
|
||||
|
||||
Die Lagen 3 und 4 tragen zusaetzlich eine GEGENPROBE: Es reicht
|
||||
nicht, dass irgendein Text dasteht -- es darf ausdruecklich NICHT
|
||||
"Gerade nicht live" dort stehen. Ohne diese Zeile waere die Pruefung
|
||||
gruen, sobald ueberhaupt etwas angezeigt wird.
|
||||
|
||||
---------------------------------------------------------------------
|
||||
UND DIE ZAHL, DIE ES ZWEIMAL GIBT
|
||||
|
||||
Die Sendezeit (20 Uhr) steht im Server und in `streamplan.js` der
|
||||
oeffentlichen Seite. Zwei Zahlen, die dasselbe bedeuten, laufen
|
||||
irgendwann auseinander -- beide sehen fuer sich plausibel aus, und
|
||||
es faellt erst auf, wenn jemand vergeblich wartet. Abschnitt 4 haelt
|
||||
sie gegeneinander, ohne Netz.
|
||||
|
||||
Aufrufen mit: node server/pruef-draussen.mjs
|
||||
===================================================================== */
|
||||
|
||||
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { request as httpAnfrage } from "node:http";
|
||||
import { eigenerPort } from "./helfer-port.mjs";
|
||||
|
||||
const PORT = await eigenerPort(import.meta, "pruef-draussen");
|
||||
const ordner = mkdtempSync(join(tmpdir(), "ws-draussen-"));
|
||||
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
|
||||
process.env.PORT = String(PORT);
|
||||
process.env.SITE_ACCESS_SECRET = "lokaler-test";
|
||||
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
|
||||
|
||||
const express = (await import("express")).default;
|
||||
const ec = express.response.cookie;
|
||||
express.response.cookie = function (n, w, o) {
|
||||
return ec.call(this, n, w, { ...(o || {}), secure: false });
|
||||
};
|
||||
import { notbremse } from "./helfer-notbremse.mjs";
|
||||
await import("./index.js");
|
||||
notbremse(180_000, "pruef-draussen");
|
||||
await new Promise((r) => setTimeout(r, 700));
|
||||
|
||||
process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
|
||||
|
||||
let fehler = 0, geprueft = 0;
|
||||
const melde = (t) => console.log(t);
|
||||
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
||||
|
||||
const CREW = "crew.dogfather-universe.com";
|
||||
const WURZEL = join(import.meta.dirname, "..");
|
||||
|
||||
function roh(pfad, host, kopf = {}, koerper = null) {
|
||||
return new Promise((fertig, schief) => {
|
||||
const a = httpAnfrage({
|
||||
host: "127.0.0.1", port: PORT, path: pfad,
|
||||
method: koerper ? "POST" : "GET",
|
||||
headers: { Host: host, ...(koerper ? { "Content-Type": "application/json" } : {}), ...kopf },
|
||||
}, (antwort) => {
|
||||
let text = "";
|
||||
antwort.on("data", (s) => { text += s; });
|
||||
antwort.on("end", () => fertig({ code: antwort.statusCode, text, kopf: antwort.headers }));
|
||||
});
|
||||
a.on("error", schief);
|
||||
if (koerper) a.write(JSON.stringify(koerper));
|
||||
a.end();
|
||||
});
|
||||
}
|
||||
|
||||
const { DatabaseSync } = await import("node:sqlite");
|
||||
const { scryptSync, randomBytes } = await import("node:crypto");
|
||||
{
|
||||
const d = new DatabaseSync(process.env.WORKSPACE_DB);
|
||||
for (const [name, rolle, code] of [["Mara", "gast", "CODE-GAST-0001"]]) {
|
||||
const salz = randomBytes(16).toString("hex");
|
||||
const hash = scryptSync(code, salz, 64,
|
||||
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
|
||||
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt)"
|
||||
+ " VALUES (?,?,?,?,?,1,?)")
|
||||
.run(name, rolle, hash, salz, 32768, new Date().toISOString());
|
||||
}
|
||||
d.close();
|
||||
}
|
||||
|
||||
async function anmelden(rolle, code, host) {
|
||||
let a = await roh("/workspace/api/anmelden", host, {}, { rolle, code });
|
||||
if (a.code === 400 && a.text.includes("alter_offen")) {
|
||||
a = await roh("/workspace/api/anmelden", host, {}, { rolle, code, alter_ok: true });
|
||||
}
|
||||
if (a.code !== 200) throw new Error(`Anmeldung ${rolle}: ${a.code} ${a.text}`);
|
||||
return [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ");
|
||||
}
|
||||
const kGast = await anmelden("gast", "CODE-GAST-0001", CREW);
|
||||
|
||||
const { KANAELE } = await import("./workspace.js");
|
||||
const { SENDEZEIT_STUNDE } = await import("./workspace-draussen.js");
|
||||
|
||||
/* =======================================================================
|
||||
1. DER WEG ZU DEN KANAELEN
|
||||
======================================================================= */
|
||||
melde("");
|
||||
melde("=== 1. Die Kanaele kommen vom Server ===");
|
||||
{
|
||||
const zu = await roh("/workspace/api/draussen", CREW);
|
||||
ok(zu.code === 401, `ohne Anmeldung verschlossen (${zu.code})`);
|
||||
|
||||
const a = await roh("/workspace/api/draussen", CREW, { Cookie: kGast });
|
||||
ok(a.code === 200, `ein Mitglied bekommt sie (${a.code})`);
|
||||
const d = JSON.parse(a.text || "{}");
|
||||
const liste = d.kanaele || [];
|
||||
|
||||
ok(liste.length === KANAELE.length && liste.length > 0,
|
||||
`es sind ${liste.length} Kanaele, so viele wie im Haus (${KANAELE.length})`);
|
||||
|
||||
/* NICHT ABGESCHRIEBEN, SONDERN VERGLICHEN. Wuerde hier ein Handle
|
||||
als Text stehen, waere diese Pruefung genau so lange gruen wie der
|
||||
Tippfehler, den sie finden soll -- am 21.09. war das bei zwei
|
||||
anderen Pruefungen der Fall. */
|
||||
const falsch = liste.filter((k, i) => k.handle !== KANAELE[i].handle);
|
||||
ok(falsch.length === 0,
|
||||
falsch.length ? `ABWEICHUNG: ${falsch.map((k) => k.handle).join(", ")}`
|
||||
: `die Handles stimmen mit KANAELE ueberein (${liste.map((k) => k.handle).join(", ")})`);
|
||||
|
||||
const schlechteAdresse = liste.filter(
|
||||
(k) => !k.adresse || !k.adresse.startsWith("https://www.tiktok.com/@")
|
||||
|| !k.adresse.endsWith(k.handle.replace(/^@/, "")));
|
||||
ok(schlechteAdresse.length === 0,
|
||||
schlechteAdresse.length
|
||||
? `ADRESSE PASST NICHT ZUM HANDLE: ${schlechteAdresse.map((k) => k.adresse).join(", ")}`
|
||||
: "jede Adresse endet auf ihrem eigenen Handle");
|
||||
|
||||
const ohneSatz = liste.filter((k) => !k.satz || k.satz.length < 10);
|
||||
ok(ohneSatz.length === 0,
|
||||
ohneSatz.length ? `ohne Satz: ${ohneSatz.map((k) => k.name).join(", ")}`
|
||||
: "jeder Kanal sagt in einem Satz, was es dort gibt");
|
||||
|
||||
/* Die Saetze fuer draussen sind ANDERE als die fuer das Team. Stuende
|
||||
dort derselbe Text, waere das Feld ueberfluessig -- und jemand
|
||||
wuerde es beim naechsten Kanal weglassen. */
|
||||
const gleich = liste.filter((k, i) => k.satz === KANAELE[i].text);
|
||||
ok(gleich.length === 0,
|
||||
gleich.length ? `${gleich.length} Kanaele zeigen den internen Text`
|
||||
: "und es ist nicht der interne Text aus der Aufgabenverteilung");
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
2. DIE INHALTSRICHTLINIE MUSS DEN ABRUF ERLAUBEN
|
||||
======================================================================= */
|
||||
melde("");
|
||||
melde("=== 2. Darf die Seite den Live-Dienst ueberhaupt fragen? ===");
|
||||
{
|
||||
/* WARUM DAS EINE EIGENE PRUEFUNG IST: Verbietet `connect-src` den
|
||||
Abruf, bricht `fetch` im Browser ab -- und zwar mit demselben
|
||||
Fehler wie ein toter Server. Die Seite landet in "unklar" und
|
||||
sieht dabei voellig in Ordnung aus. Der Unterschied steht nur in
|
||||
der Browserkonsole, die niemand aufmacht. */
|
||||
const a = await roh("/workspace/unsere-seiten.html", CREW, { Cookie: kGast });
|
||||
const regel = String(a.kopf["content-security-policy"] || "");
|
||||
const zeile = regel.split(";").map((t) => t.trim())
|
||||
.find((t) => t.startsWith("connect-src")) || "";
|
||||
ok(zeile.length > 0, `es gibt eine connect-src-Regel (${zeile.slice(0, 60)}…)`);
|
||||
ok(zeile.includes("postfach.dogfather-universe.com"),
|
||||
"und sie fuehrt postfach.dogfather-universe.com");
|
||||
ok(!zeile.includes("*") || zeile.includes("postfach"),
|
||||
"Gegenprobe: die Erlaubnis steht namentlich da, nicht als Sternchen");
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
3. DIE SEITE SELBST
|
||||
======================================================================= */
|
||||
melde("");
|
||||
melde("=== 3. Was im Quelltext der Seite steht ===");
|
||||
{
|
||||
const html = readFileSync(join(WURZEL, "workspace/unsere-seiten.html"), "utf8");
|
||||
|
||||
ok(/id="puls"/.test(html), "der Puls steht auf der Seite");
|
||||
ok(/data-lage="laedt"/.test(html),
|
||||
"und beginnt in der Lage „laedt“ -- nicht in „nicht live“");
|
||||
|
||||
/* KEIN HANDLE IM QUELLTEXT DER KANALREIHE. Der Stream-Knopf traegt
|
||||
bewusst einen: Er ist der eine feste Weg zum Hauptkanal und muss
|
||||
auch dann funktionieren, wenn der Server-Abruf scheitert. Alles
|
||||
andere kommt vom Server. */
|
||||
const kanalReihe = (html.match(/id="kanaele-reihe"[^>]*>([\s\S]*?)<\/div>/) || [])[1] || "";
|
||||
ok(kanalReihe.trim() === "",
|
||||
"die Kanalreihe ist im Quelltext leer -- sie kommt vom Server");
|
||||
|
||||
const streamKnopf = (html.match(/id="puls-knopf"[\s\S]{0,200}?href="([^"]+)"/) || [])[1] || "";
|
||||
const haupt = KANAELE[0].handle.replace(/^@/, "");
|
||||
ok(streamKnopf.includes(haupt),
|
||||
`der Weg zum Stream zeigt auf den Hauptkanal (${streamKnopf})`);
|
||||
|
||||
const css = readFileSync(join(WURZEL, "workspace/assets/css/unsere-seiten.css"), "utf8");
|
||||
ok(/prefers-reduced-motion/.test(css),
|
||||
"wer keine Bewegung moechte, bekommt keine");
|
||||
ok(/\[data-lage="live"\][\s\S]{0,400}animation/.test(css),
|
||||
"und die Bewegung haengt am Live-Zustand, laeuft also nicht dauernd");
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
4. EINE ZAHL, ZWEI ORTE
|
||||
======================================================================= */
|
||||
melde("");
|
||||
melde("=== 4. Die Sendezeit steht nicht zweimal verschieden da ===");
|
||||
{
|
||||
const streamplan = readFileSync(join(WURZEL, "assets/js/streamplan.js"), "utf8");
|
||||
const treffer = streamplan.match(/FIX_STUNDE\s*=\s*(\d+)/);
|
||||
/* DRITTER AUSGANG: Findet sich die Zahl nicht, ist das kein "in
|
||||
Ordnung" -- dann hat jemand `streamplan.js` umgebaut, und dieser
|
||||
Vergleich misst ab sofort nichts mehr. Das muss auffallen. */
|
||||
ok(!!treffer,
|
||||
treffer ? `die oeffentliche Seite sendet um ${treffer[1]} Uhr`
|
||||
: "FIX_STUNDE steht nicht mehr in assets/js/streamplan.js -- "
|
||||
+ "dieser Vergleich kann nichts mehr messen");
|
||||
ok(!!treffer && Number(treffer[1]) === SENDEZEIT_STUNDE,
|
||||
`und der Treff rechnet mit derselben Stunde (${SENDEZEIT_STUNDE})`);
|
||||
ok(SENDEZEIT_STUNDE >= 0 && SENDEZEIT_STUNDE <= 23,
|
||||
`Gegenprobe: die Stunde ist ueberhaupt eine Uhrzeit (${SENDEZEIT_STUNDE})`);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
5. DIE VIER LAGEN -- IM ECHTEN BROWSER
|
||||
======================================================================= */
|
||||
melde("");
|
||||
melde("=== 5. Was ein Mensch wirklich sieht ===");
|
||||
{
|
||||
let pw = null;
|
||||
try {
|
||||
pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
|
||||
} catch { /* dritter Ausgang unten */ }
|
||||
|
||||
if (!pw) {
|
||||
melde(" -- Playwright ist nicht da -- im Browser wurde NICHT nachgesehen.");
|
||||
melde(" Das ist kein Befund. Die Abschnitte 1-4 gelten weiter.");
|
||||
} else {
|
||||
const browser = await pw.chromium.launch();
|
||||
try {
|
||||
const ctx = await browser.newContext({ viewport: { width: 412, height: 950 },
|
||||
isMobile: true, hasTouch: true });
|
||||
await ctx.addCookies(kGast.split("; ").filter(Boolean).map((z) => {
|
||||
const [name, ...rest] = z.split("=");
|
||||
return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" };
|
||||
}));
|
||||
const seite = await ctx.newPage();
|
||||
const kaputt = [];
|
||||
seite.on("pageerror", (e) => kaputt.push(String(e).slice(0, 140)));
|
||||
|
||||
/** Stellt die Antwort des Live-Dienstes her und laedt neu.
|
||||
* `antwort === null` heisst: gar keine Antwort -- so verhaelt
|
||||
* sich ein Ausfall, und genau dieser Fall wird sonst nie
|
||||
* geprueft, weil man ihn nicht herbeifuehren kann, ohne ihn
|
||||
* herbeizufuehren. */
|
||||
async function lage(antwort, event = null) {
|
||||
await seite.unrouteAll?.().catch(() => {});
|
||||
await seite.route("**/postfach.dogfather-universe.com/**", async (weg) => {
|
||||
const u = weg.request().url();
|
||||
if (u.includes("/special-event")) {
|
||||
await weg.fulfill({ status: 200, contentType: "application/json",
|
||||
body: JSON.stringify({ ok: true, event }) });
|
||||
return;
|
||||
}
|
||||
if (antwort === null) { await weg.abort("failed"); return; }
|
||||
await weg.fulfill({ status: 200, contentType: "application/json",
|
||||
body: JSON.stringify(antwort) });
|
||||
});
|
||||
await seite.goto(`http://127.0.0.1:${PORT}/workspace/unsere-seiten.html`,
|
||||
{ waitUntil: "domcontentloaded" });
|
||||
await seite.waitForTimeout(1200);
|
||||
return seite.evaluate(() => ({
|
||||
lage: document.getElementById("puls")?.dataset.lage,
|
||||
satz: document.getElementById("puls-lage")?.textContent?.trim(),
|
||||
zahl: document.getElementById("puls-zahl")?.textContent?.trim(),
|
||||
klein: document.getElementById("puls-klein")?.textContent?.trim(),
|
||||
knopfDa: !document.getElementById("puls-knopf")?.hidden,
|
||||
eventDa: !document.getElementById("event")?.hidden,
|
||||
eventZahl: document.getElementById("event-zahl")?.textContent?.trim(),
|
||||
kanaele: [...document.querySelectorAll(".kanal")].map((a) => ({
|
||||
handle: a.querySelector(".kanal__handle")?.textContent?.trim(),
|
||||
ziel: a.getAttribute("href"),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
/* ---- 5a. Er sendet ------------------------------------------ */
|
||||
melde("");
|
||||
melde(" --- Er sendet ---");
|
||||
{
|
||||
const s = await lage({ ok: true, live: true, source: "automatisch",
|
||||
title: "Casper raeumt das Wohnzimmer aus", autoHealthy: true });
|
||||
ok(s.lage === "live", `die Lage ist „live“ (${s.lage})`);
|
||||
ok(/JETZT live/i.test(s.satz || ""), `und es steht dort „${s.satz}“`);
|
||||
ok((s.zahl || "").includes("Casper"),
|
||||
`der Titel des Streams steht da („${s.zahl}“)`);
|
||||
ok(s.knopfDa === true, "und ein Weg zum Stream ist da");
|
||||
}
|
||||
|
||||
/* ---- 5b. Er sendet nicht ------------------------------------ */
|
||||
melde("");
|
||||
melde(" --- Er sendet nicht ---");
|
||||
{
|
||||
const s = await lage({ ok: true, live: false, source: "automatisch",
|
||||
title: null, autoHealthy: true });
|
||||
ok(s.lage === "wartet", `die Lage ist „wartet“ (${s.lage})`);
|
||||
ok(/^\d{2}:\d{2}:\d{2}$/.test(s.zahl || ""),
|
||||
`ein Countdown laeuft („${s.zahl}“)`);
|
||||
ok((s.klein || "").includes(`${SENDEZEIT_STUNDE}:00`),
|
||||
`und er nennt den naechsten Termin („${s.klein}“)`);
|
||||
}
|
||||
|
||||
/* ---- 5c. Der Pruefer ist gestoert --------------------------- */
|
||||
melde("");
|
||||
melde(" --- Der Live-Pruefer meldet eine Stoerung ---");
|
||||
{
|
||||
const s = await lage({ ok: true, live: false, source: "automatisch",
|
||||
title: null, autoHealthy: false });
|
||||
ok(s.lage === "unklar", `die Lage ist „unklar“ (${s.lage})`);
|
||||
/* DIE EIGENTLICHE GEGENPROBE. Ohne sie waere die Zeile darueber
|
||||
auch dann gruen, wenn dort "Gerade nicht live" stuende -- und
|
||||
genau das ist der Fehler, um den es geht. */
|
||||
ok(!/Gerade nicht live/i.test(s.satz || ""),
|
||||
`es steht NICHT „nicht live“ da, sondern „${s.satz}“`);
|
||||
ok(/^\d{2}:\d{2}:\d{2}$/.test(s.zahl || ""),
|
||||
`der Countdown laeuft trotzdem weiter („${s.zahl}“) -- er braucht kein Netz`);
|
||||
}
|
||||
|
||||
/* ---- 5d. Gar keine Antwort ---------------------------------- */
|
||||
melde("");
|
||||
melde(" --- Der Dienst antwortet gar nicht ---");
|
||||
{
|
||||
const s = await lage(null);
|
||||
ok(s.lage === "unklar", `die Lage ist „unklar“ (${s.lage})`);
|
||||
ok(!/Gerade nicht live/i.test(s.satz || ""),
|
||||
`auch hier steht NICHT „nicht live“ da, sondern „${s.satz}“`);
|
||||
ok(/TikTok/i.test(s.klein || ""),
|
||||
`und es steht da, was man stattdessen tun kann („${s.klein}“)`);
|
||||
ok(s.knopfDa === true, "der Weg zu TikTok bleibt offen");
|
||||
|
||||
/* Die Kanaele haengen NICHT am Live-Dienst -- sie kommen vom
|
||||
eigenen Server. Faellt der eine aus, muss der andere
|
||||
weiterhin dastehen. */
|
||||
ok(s.kanaele.length === KANAELE.length,
|
||||
`die ${s.kanaele.length} Kanaele stehen trotzdem da`);
|
||||
const falsch = s.kanaele.filter((k, i) => k.handle !== KANAELE[i].handle);
|
||||
ok(falsch.length === 0,
|
||||
falsch.length ? `falsche Handles: ${falsch.map((k) => k.handle).join(", ")}`
|
||||
: `mit den richtigen Handles (${s.kanaele.map((k) => k.handle).join(", ")})`);
|
||||
}
|
||||
|
||||
/* ---- 5e. Das besondere Event -------------------------------- */
|
||||
melde("");
|
||||
melde(" --- Das besondere Event ---");
|
||||
{
|
||||
const ohne = await lage({ ok: true, live: false, source: "automatisch",
|
||||
autoHealthy: true }, null);
|
||||
ok(ohne.eventDa === false,
|
||||
"ohne Event ist der Kasten nicht da (kein „nichts geplant“)");
|
||||
|
||||
const inZweiTagen = new Date(Date.now() + 2 * 86400000 + 3600000).toISOString();
|
||||
const mit = await lage({ ok: true, live: false, source: "automatisch",
|
||||
autoHealthy: true }, { titel: { de: "Die grosse Halloween-Nacht" },
|
||||
zieldatum: inZweiTagen, link: "https://dogfather-universe.com/streamplan.html" });
|
||||
ok(mit.eventDa === true, "mit Event ist er da");
|
||||
ok(/noch 2 Tage/.test(mit.eventZahl || ""),
|
||||
`und zaehlt in Tagen herunter („${mit.eventZahl}“)`);
|
||||
}
|
||||
|
||||
ok(kaputt.length === 0,
|
||||
kaputt.length ? `Fehler in der Browserkonsole: ${kaputt.join(" | ")}`
|
||||
: "keine Fehler in der Browserkonsole");
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------- */
|
||||
melde("");
|
||||
melde(`${geprueft} Pruefungen, ${fehler} Fehler`);
|
||||
melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG");
|
||||
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ }
|
||||
process.exit(fehler ? 1 : 0);
|
||||
@@ -128,7 +128,28 @@ const { bereicheFuer, TREFF_BRETTER } = await import("./workspace.js");
|
||||
`ein Mitglied bekommt ${TREFF_BEREICHE?.length} Kacheln`);
|
||||
const kachel = TREFF_BEREICHE.find((k) => k.ziel === "unsere-seiten.html");
|
||||
ok(!!kachel, "sie steht in der Liste der Treff-Kacheln");
|
||||
ok(kachel?.name === "Unsere Seiten", `sie heisst „${kachel?.name}“`);
|
||||
/* DER NAME WIRD ABGELEITET, NICHT ABGESCHRIEBEN (21.09.2026).
|
||||
|
||||
Hier stand "Unsere Seiten" als Text. Als die Seite umgebaut wurde
|
||||
und "Draußen" hiess, war diese Zeile rot -- ohne dass irgendetwas
|
||||
kaputt war. Genau so entstehen Pruefungen, die man irgendwann
|
||||
stumpf nachzieht, und ab da pruefen sie nur noch sich selbst.
|
||||
|
||||
Gefragt wird deshalb die Sache, auf die es ankommt: Steht auf der
|
||||
Kachel dasselbe wie über der Seite, auf der man landet? Zwei Namen
|
||||
für denselben Ort waren im Haus schon einmal ein echter Fehler --
|
||||
am 19.09. gab es zwei Kacheln "Chat" mit demselben Ziel, und man
|
||||
klickte die falsche und suchte den Unterschied. */
|
||||
const seiteRoh = readFileSync(
|
||||
new URL("../workspace/unsere-seiten.html", import.meta.url), "utf8");
|
||||
const h1 = (seiteRoh.match(/<h1 class="titel">([\s\S]*?)<\/h1>/) || [])[1]?.trim();
|
||||
ok(!!h1 && h1.length > 2, `die Seite trägt eine Überschrift („${h1 || "KEINE"}“)`);
|
||||
ok(!!h1 && kachel?.name === h1,
|
||||
`Kachel und Seite heißen gleich („${kachel?.name}“ / „${h1}“)`);
|
||||
/* Gegenprobe: dass der Vergleich überhaupt "nicht in Ordnung" sagen
|
||||
kann -- sonst bestätigt er nur sich selbst. */
|
||||
ok(h1 !== "Ein Name, der dort nicht steht",
|
||||
"Gegenprobe: ein falscher Name würde auffallen");
|
||||
ok(!!kachel?.unter && kachel.unter.length > 5,
|
||||
`und sagt, was dahinter steckt („${kachel?.unter}“)`);
|
||||
|
||||
@@ -159,10 +180,59 @@ const { bereicheFuer, TREFF_BRETTER } = await import("./workspace.js");
|
||||
jeGruppe.get(x.gruppe).push(x);
|
||||
}
|
||||
const treffGruppe = jeGruppe.get("Community") || [];
|
||||
const plaetze = treffGruppe.reduce((n, k) => n + (k.gross ? 2 : 1), 0);
|
||||
ok(treffGruppe.length > 0 && plaetze % 3 === 0,
|
||||
`die Community-Gruppe geht auf: ${treffGruppe.length} Kacheln = ${plaetze} Plaetze,`
|
||||
+ ` ${plaetze / 3} volle Reihen`);
|
||||
|
||||
/* HIER STAND `plaetze % 3 === 0` -- UND DAS WAR DER FALSCHE MASSSTAB
|
||||
(berichtigt 21.09.2026).
|
||||
|
||||
Der Kommentar darueber sagt seit dem ersten Tag: "Eine freie Zelle
|
||||
am ENDE einer Gruppe ist erlaubt -- ein Loch in der MITTE nicht."
|
||||
Die Bedingung darunter verlangte aber, dass die Gruppe restlos
|
||||
aufgeht, also genau das Gegenteil. Ein Kommentar, der eine Absicht
|
||||
beschreibt, erfuellt sie nicht.
|
||||
|
||||
Aufgefallen ist es, als die Community neun Kacheln hatte (10
|
||||
Plaetze, weil "Der Treff" doppelt breit ist): Reihe 1 bis 3 voll,
|
||||
in Reihe 4 steht "Regeln & Hilfe" allein. Das ist voellig in
|
||||
Ordnung und sah trotzdem rot aus. Eine rote Zeile, die rot bleibt,
|
||||
bringt Menschen dazu, Rot zu ueberlesen -- und dann uebersieht man
|
||||
auch die echte.
|
||||
|
||||
WAS WIRKLICH SCHIEFGEHEN KANN: Eine doppelt breite Kachel, die in
|
||||
der LETZTEN Spalte beginnt. Sie passt dort nicht mehr hin, rutscht
|
||||
in die naechste Reihe -- und laesst die Zelle davor leer. DAS ist
|
||||
das Loch in der Mitte. Gemessen wird deshalb der Umbruch, nicht
|
||||
die Teilbarkeit. */
|
||||
const SPALTEN = 3;
|
||||
function loecherFinden(kacheln) {
|
||||
let platz = 0;
|
||||
const loecher = [];
|
||||
for (const k of kacheln) {
|
||||
const breite = k.gross ? 2 : 1;
|
||||
if (breite === 2 && platz % SPALTEN === SPALTEN - 1) {
|
||||
loecher.push(k.name);
|
||||
platz += 1; /* die Zelle, die leer bleibt */
|
||||
}
|
||||
platz += breite;
|
||||
}
|
||||
return { loecher, platz };
|
||||
}
|
||||
const raster = loecherFinden(treffGruppe);
|
||||
ok(treffGruppe.length > 0 && raster.loecher.length === 0,
|
||||
raster.loecher.length
|
||||
? `LUECKE IN DER MITTE vor: ${raster.loecher.join(", ")}`
|
||||
: `die Community-Gruppe hat kein Loch: ${treffGruppe.length} Kacheln, `
|
||||
+ `${raster.platz} Plaetze, letzte Reihe mit ${raster.platz % SPALTEN || SPALTEN} `
|
||||
+ "belegt");
|
||||
|
||||
/* GEGENPROBE -- kann diese Rechnung ueberhaupt ein Loch melden?
|
||||
Ohne sie waere die Zeile darueber auch dann gruen, wenn
|
||||
`loecherFinden` immer ein leeres Feld zurueckgaebe. Genau so ist
|
||||
die alte Fassung jahrelang falsch geblieben. */
|
||||
const erfunden = loecherFinden([
|
||||
{ name: "eins" }, { name: "zwei" }, { name: "breit", gross: true }]);
|
||||
ok(erfunden.loecher.length === 1 && erfunden.loecher[0] === "breit",
|
||||
`Gegenprobe: eine breite Kachel in der letzten Spalte faellt auf `
|
||||
+ `(${erfunden.loecher.join(", ") || "NICHTS GEFUNDEN"})`);
|
||||
ok(treffGruppe.findIndex((k) => k.gross) === 0,
|
||||
"und die doppelt breite Kachel steht an erster Stelle");
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/* =====================================================================
|
||||
DRAUSSEN (21.09.2026) — was die Community-Seite "Unsere Seiten"
|
||||
braucht, damit sie nichts abschreiben muss.
|
||||
|
||||
Filipe: "mach was anderes aus dieser seite oder mach es viel krass
|
||||
und geiler ... lass dir was uebelst geiles einfallen und ueberrasch
|
||||
mich ... erstell was was mich von den socken schmeisst. es soll
|
||||
wirklich sogar ein wow effekt fuer die community ergeben."
|
||||
|
||||
---------------------------------------------------------------------
|
||||
WAS DIESE SEITE VORHER WAR UND WAS DARAN FEHLTE
|
||||
|
||||
Zwei Karten mit zwei Adressen. Sauber gebaut, richtig beschriftet --
|
||||
und trotzdem beantwortete sie keine Frage, die jemand wirklich hat.
|
||||
Wer im Treff ist, kennt die Website. Der Rabattcode war das Einzige,
|
||||
wofuer man wiederkam, und den merkt man sich beim ersten Mal.
|
||||
|
||||
DIE FRAGE, DIE DIE COMMUNITY WIRKLICH HAT, IST EINE ANDERE: Laeuft
|
||||
er gerade? Und wenn nicht -- wie lange noch?
|
||||
|
||||
Auf die Antwort gibt es bereits einen Dienst im Haus:
|
||||
postfach.dogfather-universe.com/live-status, derselbe, von dem der
|
||||
oeffentliche Streamplan lebt. Er wird hier NICHT nachgebaut und auch
|
||||
nicht durchgereicht -- der Browser fragt ihn direkt, so wie draussen
|
||||
auch. Nachgemessen, bevor darauf gebaut wurde:
|
||||
|
||||
- der Dienst antwortet und meldet sich selbst als gesund
|
||||
("autoHealthy": true, zuletzt geprueft vor drei Minuten)
|
||||
- "access-control-allow-origin: *", die Treff-Adresse darf fragen
|
||||
- die Inhaltsrichtlinie der Treff-Seiten fuehrt postfach bereits
|
||||
in `connect-src` -- der Abruf waere sonst still blockiert worden
|
||||
und haette ausgesehen wie ein toter Dienst
|
||||
|
||||
---------------------------------------------------------------------
|
||||
WAS HIER BLEIBT: DIE KANAELE
|
||||
|
||||
Die drei TikTok-Accounts stehen als KANAELE in workspace.js -- sie
|
||||
sind dort die eine Liste, an der die Aufgabenverteilung haengt. Die
|
||||
Community-Seite bekommt sie von dort und schreibt sie NICHT ab.
|
||||
|
||||
Warum das keine Kleinigkeit ist: Am selben Tag stand in dieser Liste
|
||||
"@hasidog00804" statt "@hasidog0804" -- eine Null zu viel. Zwei
|
||||
Pruefungen hatten denselben Tippfehler abgeschrieben und waren
|
||||
deshalb gruen. Eine dritte Abschrift auf einer Seite, die
|
||||
Zuschauerinnen sehen, waere ein toter Link gewesen, den niemand
|
||||
meldet, weil niemand weiss, dass er falsch ist.
|
||||
|
||||
DIE ADRESSE WIRD EBENFALLS ABGELEITET, nicht zusammengesetzt: Der
|
||||
Steckbrief weiss bereits, wie eine TikTok-Adresse aus einem Handle
|
||||
entsteht (KANAELE.tiktok.adresse). Stuende das Muster hier ein
|
||||
zweites Mal, gaebe es zwei Stellen, die sich bei einer Aenderung
|
||||
widersprechen koennen.
|
||||
|
||||
---------------------------------------------------------------------
|
||||
WER DAS SEHEN DARF
|
||||
|
||||
Jede angemeldete Person. Nicht weil es egal waere, sondern weil hier
|
||||
nichts steht, was nicht ohnehin oeffentlich ist: Dieselben drei
|
||||
Accounts sind von dogfather-universe.com aus verlinkt, fuer alle.
|
||||
|
||||
Das ist BEWUSST ANDERS als `kanaeleFuer` in workspace-bereiche.js,
|
||||
wo die Community die Kanalliste ausdruecklich NICHT bekommt. Dort
|
||||
geht es um die Frage "wohin soll diese Arbeit" -- eine interne
|
||||
Entscheidung, und wer sie nicht treffen darf, soll auch nicht die
|
||||
Auswahl sehen. Hier geht es um "wo finde ich ihn". Dieselben Daten,
|
||||
zwei verschiedene Fragen; deshalb zwei Wege und nicht einer, der
|
||||
beides halb macht.
|
||||
===================================================================== */
|
||||
|
||||
import express from "express";
|
||||
import { sitzungLesen, KANAELE } from "./workspace.js";
|
||||
import { KANAELE as KANAL_ARTEN } from "./workspace-steckbrief.js";
|
||||
|
||||
/* DIE FESTE SENDEZEIT.
|
||||
|
||||
Sie steht auch in assets/js/streamplan.js der oeffentlichen Seite
|
||||
(`FIX_STUNDE`). Zwei Zahlen, die dasselbe bedeuten, laufen
|
||||
irgendwann auseinander -- und es faellt niemandem auf, weil beide
|
||||
fuer sich plausibel aussehen. Deshalb haelt pruef-draussen die
|
||||
beiden Orte gegeneinander; verschiebt jemand den Termin und aendert
|
||||
nur einen, wird die Pruefung rot und sagt, welche Datei fehlt.
|
||||
|
||||
WARUM SIE NICHT IN DER VERWALTUNG STEHT: Sie hat sich seit dem
|
||||
20.08.2026 nicht geaendert. Eine Oberflaeche fuer eine Zahl, die man
|
||||
einmal im Jahr anfasst, ist mehr Pflege als Nutzen -- und ein Feld,
|
||||
das niemand benutzt, steht irgendwann falsch. Sobald sich der
|
||||
Termin oefter bewegt, gehoert er dorthin.
|
||||
|
||||
Fuer eine ABWEICHUNG ist sie ohnehin nicht zustaendig: Sendet er
|
||||
einmal frueher, sagt das der Live-Status, und der kommt vom Dienst,
|
||||
nicht von dieser Zahl. */
|
||||
export const SENDEZEIT_STUNDE = 20;
|
||||
|
||||
export const draussenRouter = express.Router();
|
||||
|
||||
draussenRouter.get("/workspace/api/draussen", (req, res) => {
|
||||
const person = sitzungLesen(req);
|
||||
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
|
||||
|
||||
try {
|
||||
const kanaele = KANAELE.map((k) => {
|
||||
const ohneAt = k.handle.replace(/^@/, "");
|
||||
return {
|
||||
wert: k.wert,
|
||||
name: k.name,
|
||||
handle: k.handle,
|
||||
adresse: KANAL_ARTEN.tiktok.adresse(ohneAt),
|
||||
satz: k.fuerDraussen || k.text,
|
||||
};
|
||||
});
|
||||
|
||||
/* KEIN ZWISCHENSPEICHER MIT LAUFZEIT. Die Liste aendert sich nur,
|
||||
wenn jemand den Quelltext aendert -- dann startet der Dienst neu,
|
||||
und ein Stempel waere ohnehin hinfaellig. Eine Laufzeit wuerde
|
||||
hier nur eine Frage beantworten, die niemand stellt. */
|
||||
res.json({ kanaele, sendeStunde: SENDEZEIT_STUNDE });
|
||||
} catch (fehler) {
|
||||
console.error("[workspace] Draussen:", fehler.message);
|
||||
res.status(500).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
+34
-4
@@ -1173,7 +1173,21 @@ const TREFF_BEREICHE = [
|
||||
zeichen: "content", ton: 30, ziel: "bereich.html?b=wunsch", szene: "portal" },
|
||||
{ ...TREFF_GRUPPE, name: "Highlights", unter: "Eure Clips und Bilder",
|
||||
zeichen: "live", ton: 29, ziel: "bereich.html?b=highlight", szene: "arena" },
|
||||
{ ...TREFF_GRUPPE, name: "Unsere Seiten", unter: "Website, Shop & dein Rabatt",
|
||||
/* HIESS BIS ZUM 21.09.2026 "Unsere Seiten" (Ziel und Datei bleiben
|
||||
gleich, nur der Name auf der Kachel aendert sich).
|
||||
|
||||
Dahinter stehen jetzt drei Dinge statt zwei Adressen: ob gerade
|
||||
gestreamt wird, wo man Dogi sonst findet, und die beiden Seiten mit
|
||||
dem Rabatt. "Unsere Seiten" haette davon nur noch den letzten
|
||||
Drittel benannt -- und wer wegen des Live-Status wiederkommt,
|
||||
findet eine Kachel nicht, die nach etwas anderem klingt.
|
||||
|
||||
DER NAME STEHT AB JETZT NUR EINMAL IM HAUS: pruef-wege-nach-draussen
|
||||
liest die Ueberschrift der Seite und verlangt, dass die Kachel
|
||||
genauso heisst. Zwei Namen fuer dieselbe Sache waren schon einmal
|
||||
ein echter Fehler (die zwei Kacheln "Chat" am 19.09.) -- man klickt
|
||||
den falschen und sucht den Unterschied. */
|
||||
{ ...TREFF_GRUPPE, name: "Draußen", unter: "Ist er live? Kanäle, Shop & dein Rabatt",
|
||||
zeichen: "hinaus", ton: 38, ziel: "unsere-seiten.html", szene: "portal" },
|
||||
{ ...TREFF_GRUPPE, name: "Mitmachen", unter: "Wie du dazugeh\u00f6ren kannst",
|
||||
zeichen: "personen", ton: 32, ziel: "bereich.html?b=mitmachen", szene: "garage" },
|
||||
@@ -1758,16 +1772,32 @@ export const MODI_STUFEN = [
|
||||
haette dafuer einen falschen Kanal erzwungen -- und ein falscher
|
||||
Eintrag ist schlechter als ein leerer.
|
||||
===================================================================== */
|
||||
/* ZWEI SAETZE JE KANAL, WEIL ES ZWEI LESERSCHAFTEN GIBT (21.09.2026).
|
||||
|
||||
`text` erklaert dem TEAM, wofuer der Kanal da ist -- es steht an
|
||||
einer Aufgabe und beantwortet "wo soll das hin". `fuerDraussen`
|
||||
steht auf der Community-Seite und beantwortet etwas anderes: "was
|
||||
finde ich da". "Hier wird geschnitten, beschriftet und hochgeladen"
|
||||
ist fuer die Zuschauerin keine Auskunft, sondern ein Blick in eine
|
||||
fremde Werkstatt.
|
||||
|
||||
ES BLEIBT EINE LISTE. Der zweite Satz haengt am Kanal, nicht in
|
||||
einer zweiten Aufzaehlung woanders -- sonst haette ein vierter
|
||||
Account wieder zwei Orte, und einer davon wird vergessen. Genau so
|
||||
ist am selben Tag "@hasidog00804" entstanden. */
|
||||
export const KANAELE = [
|
||||
{ wert: "dogfather", name: "DogFather", handle: "@dogfather0804",
|
||||
text: "Der Hauptkanal. Die Livestreams und alles, was unmittelbar "
|
||||
+ "daran hängt." },
|
||||
+ "daran hängt.",
|
||||
fuerDraussen: "Der Hauptkanal — hier laufen die Livestreams." },
|
||||
{ wert: "hasidog", name: "HasiDog", handle: "@hasidog0804",
|
||||
text: "Der zweite Account — eigene Zuschauer, eigener Ton. Was hier "
|
||||
+ "läuft, ist nicht einfach dasselbe noch einmal." },
|
||||
+ "läuft, ist nicht einfach dasselbe noch einmal.",
|
||||
fuerDraussen: "Der zweite Account. Eigener Ton, eigene Leute." },
|
||||
{ wert: "clips", name: "DogFather Clips", handle: "@dogis.modi.gang",
|
||||
text: "Nur Ausschnitte. Hier wird geschnitten, beschriftet und "
|
||||
+ "hochgeladen, nicht gesendet." },
|
||||
+ "hochgeladen, nicht gesendet.",
|
||||
fuerDraussen: "Die besten Ausschnitte zum Nachschauen." },
|
||||
];
|
||||
|
||||
/** Zu welchem Kanal gehoert dieser TikTok-Name?
|
||||
|
||||
Reference in New Issue
Block a user