Files
dogfather-universe/server/helfer-tiktok.mjs
T
DogFatherGitandClaude Opus 5 10e24b0c93 Kampagnen-Kachel aus einem TikTok-Link -- Etappe 2 bis 4
Der Auftrag ist damit fertig: Im Agentur-Bereich gibt es ein Feld, in
das die ganze Einladungsnachricht eingefuegt wird -- und daraus
entsteht eine Agentur-Event-Kachel mit Titel, Zeitraum, Banner,
Aufgaben zum Abhaken, Regeln, Ligen und Preisen. Gemessen 38 ms vom
Einfuegen bis zur Kachel.

ETAPPE 2 -- DER WEG NACH DRAUSSEN (helfer-tiktok.mjs)
  linkAusText holt die Adresse aus dem Fliesstext, damit niemand sie
  heraussuchen muss. folgeUmleitung geht Sprung fuer Sprung
  (redirect "manual", hoechstens fuenf) und schickt JEDE
  Zwischenadresse durch istTikTok -- bisher folgte holeMitFrist den
  Umleitungen selbst, und istTikTok hatte nur die EINGABE gesehen.
  Das ist die einzige Stelle im Haus, an der unser Server eine
  fremdbestimmte Adresse abruft.

  holeSeite zieht die Grenze von 3 MB BEIM LESEN, nicht hinterher: Wer
  arrayBuffer() abwartet und dann die Laenge ansieht, hat die 50 MB
  schon im Speicher. Frist 12 s statt 8 -- gemessen 750 ms allein beim
  Ursprungsserver, dazu 1,1 MB Uebertragung.

  bildAdresseErlaubt kam beim Bauen dazu, nicht aus dem Bauplan: Die
  Banneradresse steht in der Seite, die TikTok ausliefert, ist also
  FREMDBESTIMMT. Ohne Schranke wuerde unser Server abrufen, was dort
  steht -- auch http://127.0.0.1:4100/ oder 169.254.169.254. Elf
  Faelle durchgemessen.

ETAPPE 3 -- DIE ROUTE (workspace-kampagne.js, 5 Spalten, 1 Index)
  Rechte aus istLeitung, nicht neu erfunden. Bremse 20 je Stunde, VOR
  dem ersten Abruf nach draussen -- eine Bremse hinter dem Abruf
  bremst den fremden Server nicht. Dublettenpruefung zweimal: im Code
  (faengt den Normalfall) und als eindeutiger Index (faengt den
  Wettlauf zweier gleichzeitiger Aufrufe).

  'kampagne_schon_da' zaehlt bei der Bremse MIT (gefunden von der
  Pruefung): Zuerst zaehlten nur Erfolg und Fehlschlag -- wer denselben
  Link wieder und wieder einfuegt, bekommt jedes Mal "schon da" und
  loeste jedes Mal einen Abruf aus, ohne gezaehlt zu werden. Gemessen:
  21 Versuche, 0 gebremst.

  Der Regeltext wird SICHTBAR gekuerzt, an einer Abschnittsgrenze.
  Gipfelstuermer ergibt 7474 Zeichen, das Feld fasst 6000 (TEXT_MAX),
  und die PUT-Route lehnt mehr ab: Ungekuerzt entstuende eine Kachel,
  die DogFather OEFFNEN, aber nicht SPEICHERN kann -- er aendert ein
  Wort und bekommt eine Fehlermeldung ueber etwas, das er nie getippt
  hat. Verloren geht nichts: kampagne_daten traegt das Gelesene
  gegliedert.

ETAPPE 4 -- DIE OBERFLAECHE
  Ein Textbereich (die Einladung ist mehrzeilig), der Knopf gibt sich
  nach 15 s von selbst frei, die Meldung steht neben dem Feld und
  nennt den Grund im Klartext. Ob jemand darf, sagt der Server ueber
  darf_kampagne_einlesen in /api/ich -- keine Rollenliste im Browser.

  Gemessen auf 412 und 1280 px (bild-kampagne.mjs, 20 Messungen):
  Knopf 44 px, Kontrast 7,68:1, nichts liegt auf dem Knopf, kein
  Ueberlauf, keine Bewegung bei prefers-reduced-motion. Die
  Antwortzeile steht auf .9rem statt der hausweiten .76rem -- dort
  erscheint, was der Server geantwortet hat, und eine Fehlermeldung in
  12 px liest niemand zweimal. Die hausweite Klasse bleibt unberuehrt.

VIERZEHN SABOTAGEN, VIERZEHN TREFFER
  Sieben an kampagne-lesen.mjs (Etappe 1) und sieben an Route und Weg:
  Umleitung ungeprueft folgen, Tag nach Serverzeit, Dublettenpruefung
  weglassen, dem fremden Server den Bildtyp glauben, Bremse
  abschalten, Bremse wieder blind fuer Dubletten, jeden einlesen
  lassen. Jede wurde bemerkt, jede im erwarteten Abschnitt.

  EINE BLIEB ZUERST BLIND, und das war lehrreich: Nimmt man die
  Dublettenpruefung aus dem Code, faengt der INDEX es auf -- die
  Antwort ist dieselbe, die Pruefung blieb gruen. Kein Schaden, aber
  ein blinder Fleck. Jetzt unterscheidet die Pruefung am
  Protokolltext, WELCHE der beiden Sicherungen gegriffen hat.

VIER VORBESTEHENDE BEFUNDE MIT ERLEDIGT
  willkommen.html hatte kein theme-color, kein Manifest, kein
  apple-touch-icon. Drei Zeilen sind billiger und haltbarer als eine
  Ausnahme in der Pruefung.

  workspace-anleitung.js bildete den Tag "ab wann geht mehr" aus UTC.
  Wer zwischen 00:00 und 02:00 beitritt, bekam einen Termin, der einen
  Tag zu frueh war. workspace.js hat dafuer jetzt tagLokalVon(zeitpunkt,
  versatz) -- und tagLokal ist nur noch ein Aufruf davon. Es gibt also
  WENIGER Rechenwege als vorher, nicht mehr.

  pruef-anleitung.mjs legte Testdaten auf den UTC-Tag und waere
  zwischen 00:00 und 02:00 rot gewesen, ohne dass etwas kaputt ist.

  mess-anleitung-crew.mjs rechnete seinen zweiten Port als PORT + 1.
  Das sieht aus wie eine Ableitung und ist keine: portNummer
  ueberspringt gesperrte Nummern, und dann gehoert PORT + 1 der
  naechsten DATEI.

  pruef-struktur ist damit zum ersten Mal vollstaendig gruen.

GEPRUEFT (alle gruen, keine Zahl gesunken)
  pruef-kampagne 96 (neu) · pruef-kampagne-lesen 108 · pruef-video 74 ·
  pruef-eventkarte 94 · pruef-agentur 62 · pruef-anleitung 196 ·
  pruef-portnummern 41 (war 40 mit einem Fehler) · pruef-struktur,
  pruef-zeichen 8, pruef-ports 10, pruef-buehnen-fest 13,
  pruef-css-klassen 39, pruef-haus-trennung 107, pruef-lesbarkeit,
  pruef-tippziele, pruef-deutsche-texte 12, pruef-formulare,
  pruef-fingermass 5, pruef-workspace-seiten, pruef-tempo-workspace

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-07 17:14:26 +02:00

341 lines
15 KiB
JavaScript

/* =====================================================================
DER WEG NACH DRAUSSEN ZU TIKTOK — gemeinsam genutzt (07.10.2026)
Diese Datei begann als ein VERSCHIEBEN, keine Neuerfindung.
`istTikTok` und `holeMitFrist` standen bis heute in
`workspace-video.js` und haben sich dort bewährt — inklusive der
Reparatur vom 05.10.2026, bei der auffiel, dass die Frist nur den
Anfang der Anfrage bewachte.
Ab jetzt holt auch die Kampagnen-Kachel eine Seite von TikTok. Zwei
Fassungen derselben Regel wären der Fehler, den CLAUDE.md am
11.09.2026 beschreibt: eine abgeschriebene Liste, die auseinander-
läuft. Gerade bei `istTikTok` wäre das gefährlich — es ist die eine
Stelle, die entscheidet, welchen fremden Rechner unser Server
überhaupt anfragt. Die zweite Fassung würde beim nächsten Fund
vergessen.
---------------------------------------------------------------------
WAS SICH BEIM VERSCHIEBEN GEÄNDERT HAT — und nur das
1. `holeMitFrist` nimmt die Frist jetzt als Zusatz entgegen, mit
8000 ms als Vorgabe. Das Video bleibt damit Zeichen für Zeichen
beim alten Verhalten; die Kampagnenseite braucht mehr (1,1 MB
statt einer kleinen Auskunft) und sagt das am Aufrufort, nicht
über eine zweite Konstante irgendwo.
2. `istTikTok` erkennt die Prüfumgebung jetzt an BEIDEN
Umstellschaltern — `TIKTOK_OEMBED_BASIS` (Video) und
`TIKTOK_KAMPAGNE_BASIS` (Kampagne). Ohne das zweite müsste die
Kampagnenprüfung die echte Regel umgehen und prüfte damit eine
andere als die, die im Betrieb gilt.
---------------------------------------------------------------------
KEINE DATENBANK, KEIN EXPRESS
Absichtlich: Diese Datei lässt sich in einer Prüfung einzeln
einbinden, ohne dass ein Server hochfährt. Dasselbe Argument wie bei
`helfer-tag.mjs`.
===================================================================== */
/* GRENZEN FÜR DEN WEG NACH DRAUSSEN. Ein fremder Dienst, der nicht
antwortet, darf unsere Anfrage nicht mit sich ziehen -- genau daran
ist in diesem Haus schon ein ganzer Pruefdurchlauf drei Stunden lang
haengengeblieben. */
export const FRIST_MS = 8000;
/** Ist das ueberhaupt eine TikTok-Adresse? Keine Zeichenkettensuche,
* sondern der Rechnername -- "tiktok.com.beispiel.de" enthaelt
* "tiktok.com" und gehoert jemand anderem. */
export function istTikTok(roh) {
try {
const u = new URL(String(roh));
const h = u.hostname.toLowerCase();
/* NUR IN EINER PRUEFUNG: Dann liegt der nachgebaute Dienst auf
127.0.0.1, und die Adresse des "Videos" auch. Ohne diese Zeile
muesste die Pruefung die echte Regel umgehen -- und pruefte
damit eine andere als die, die im Betrieb gilt. */
if ((process.env.TIKTOK_OEMBED_BASIS || process.env.TIKTOK_KAMPAGNE_BASIS)
&& (h === "127.0.0.1" || h === "localhost")) {
return true;
}
if (u.protocol !== "https:") return false;
return h === "tiktok.com" || h.endsWith(".tiktok.com");
} catch { return false; }
}
/* =====================================================================
DER WEG ZU EINER GANZEN SEITE (07.10.2026) — Etappe 2
Bis hierher holte dieses Haus bei TikTok nur kleine Auskuenfte: eine
JSON-Antwort zu einem Video, ein Vorschaubild. Eine Kampagnenseite
ist etwas anderes -- 1,1 MB serverseitig gerendertes HTML, und die
Adresse dahin kommt aus einem Text, den ein Mensch eingefuegt hat.
DAS IST DIE EINZIGE STELLE IM GANZEN HAUS, an der unser Server eine
FREMDBESTIMMTE Adresse abruft. Ueberall sonst steht das Ziel im
Quelltext. Deshalb stehen hier drei Grenzen, und jede hat einen
gemessenen Grund:
1. JEDE STATION EINZELN.
holeMitFrist folgt Umleitungen selbst (redirect "follow"). Das
heisst: istTikTok() hat nur die EINGABE gesehen, und wohin der
Kurzlink wirklich fuehrt, weiss niemand. Hier wird deshalb
Sprung fuer Sprung gegangen (redirect "manual", hoechstens
fuenf), und JEDE Zwischenadresse geht durch istTikTok().
Verlaesst die Kette TikTok, bricht sie ab und sagt, wohin.
2. ZWOELF SEKUNDEN, NICHT ACHT.
Gemessen am 07.10.2026: allein 750 ms beim Ursprungsserver, dazu
die Uebertragung von 1,1 MB. Acht Sekunden reichen an einem
langsamen Nachmittag nicht, und eine Frist, die im Normalfall
zuschlaegt, ist keine Grenze, sondern ein Fehler.
3. DREI MEGABYTE, GEZOGEN BEIM LESEN.
Nicht hinterher geprueft. Wer erst arrayBuffer() abwartet und
dann die Laenge ansieht, hat die 50 MB schon im Speicher, bevor
er sie ablehnt -- die Grenze schuetzt dann genau nichts. Gelesen
wird deshalb stueckweise, und beim Ueberschreiten wird die
Verbindung abgebrochen.
Dazu ein ehrlicher Erkennungsname: Wer gefragt wird, soll sehen,
wer fragt. Ein Abruf, der sich als Browser ausgibt, ist eine Luege
gegenueber einem Dienst, der uns eingeladen hat.
===================================================================== */
/* Fuer eine ganze Seite. Siehe Grund 2 oben. */
export const FRIST_SEITE_MS = 12_000;
/* Siehe Grund 3. 1,1 MB ist der gemessene Normalfall, 3 MB lassen
Luft fuer eine Kampagne mit mehr Bausteinen -- und sind weit unter
dem, was einem Server wehtut. */
export const SEITE_MAX = 3 * 1024 * 1024;
/* Hoechstens fuenf Spruenge. Ein Kurzlink braucht einen, TikToks
Weiterleitung auf die Langform einen zweiten. Wer fuenf braucht,
hat kein Ziel, sondern eine Schleife. */
export const UMLEITUNGEN_MAX = 5;
/* WER FRAGT DA? Keine Browser-Verkleidung. Steht eine Adresse dahinter,
kann TikTok nachsehen, wer wir sind, statt zu raten. */
export const KENNUNG =
"DogfatherUniverse-Workspace/1.0 (+https://workspace.dogfather-universe.com; "
+ "Kampagnen-Einleser, ein Abruf je eingefuegtem Link)";
/** Der Rechnername einer Adresse, fuer Meldungen an Menschen.
* Gibt einen Ausschnitt der Eingabe zurueck, wenn sie keine ist. */
export function hostVon(roh) {
try { return new URL(String(roh)).hostname; } catch { return String(roh).slice(0, 60); }
}
/* Adressen in einem Fliesstext. Das Muster FINDET nur -- geprueft wird
mit new URL() und istTikTok(). Ein regulaerer Ausdruck, der
entscheidet, ob eine Adresse zu TikTok gehoert, ist derselbe Fehler
wie eine Zeichenkettensuche nach "tiktok.com". */
const LINK_MUSTER = /https?:\/\/[^\s"'<>)\]}]+/gi;
/** Die erste TikTok-Adresse in einem Text -- oder null.
*
* WARUM DER GANZE TEXT HINEINDARF: Die Einladung kommt so, wie TikTok
* sie schickt, mit Fliesstext drumherum:
*
* Dein Creator Network laedt dich ein, an einer Kampagne
* teilzunehmen. Klicke hier, um weitere Informationen zu
* erhalten: https://www.tiktok.com/t/ZSbqPsFa8/
*
* Niemand soll den Link erst heraussuchen muessen. */
export function linkAusText(text) {
for (const treffer of String(text || "").matchAll(LINK_MUSTER)) {
/* Satzzeichen am Ende gehoeren zum Satz, nicht zur Adresse --
mit einem Punkt dahinter waere es eine andere Seite. */
const sauber = treffer[0].replace(/[.,;:!?"'»«)\]]+$/u, "");
if (istTikTok(sauber)) return sauber;
}
return null;
}
/* Was NICHT abgerufen wird, egal wer es vorschlaegt. Buchstabenform,
keine Namensaufloesung: Eine Pruefung, die erst DNS fragt, kann
zwischen Frage und Abruf ueberholt werden (DNS-Rebinding). Was
hier steht, faengt den realistischen Fall -- eine Adresse, die auf
unseren eigenen Rechner oder ins Hausnetz zeigt. */
const PRIVATE_NAMEN = /^(localhost|.*\.local|.*\.internal|.*\.home\.arpa)$/i;
const PRIVATE_ZAHLEN = new RegExp([
"^127\\.", // Rueckschleife
"^10\\.", // privates Netz
"^192\\.168\\.", // privates Netz
"^172\\.(1[6-9]|2\\d|3[01])\\.", // privates Netz
"^169\\.254\\.", // Link-lokal, darunter 169.254.169.254
"^0\\.", "^::1$", "^\\[?::1\\]?$", "^f[cd]", "^fe80:",
].join("|"), "i");
/** Darf unser Server dieses BILD holen?
*
* WARUM ES DIESE FRAGE GIBT: Die Banneradresse steht in der Seite,
* die TikTok ausliefert -- sie ist FREMDBESTIMMT. Ohne Schranke
* wuerde unser Server abrufen, was dort steht, auch
* http://127.0.0.1:4100/... oder http://169.254.169.254/ (die
* Metadatenadresse vieler Rechenzentren). Das ist der Lehrbuchfall
* SSRF, und er kostet nichts zu verhindern.
*
* WAS DAS NICHT KANN: Ein Name, der auf eine private Adresse
* zeigt, faellt hier nicht auf -- dafuer muesste aufgeloest werden,
* und zwischen Aufloesung und Abruf laesst sich die Antwort
* tauschen. Das steht hier, damit niemand mehr Schutz annimmt, als
* da ist. */
export function bildAdresseErlaubt(roh) {
let u;
try { u = new URL(String(roh)); } catch { return false; }
const h = u.hostname.toLowerCase().replace(/^\[|\]$/g, "");
/* NUR IN EINER PRUEFUNG: dann liegt der nachgebaute Bilddienst
auf 127.0.0.1. Derselbe Schalter wie bei istTikTok -- eine
Pruefung, die die echte Regel umgeht, prueft eine andere. */
if (process.env.TIKTOK_KAMPAGNE_BASIS && (h === "127.0.0.1" || h === "localhost")) {
return true;
}
if (u.protocol !== "https:") return false;
if (u.port && u.port !== "443") return false;
if (PRIVATE_NAMEN.test(h)) return false;
if (PRIVATE_ZAHLEN.test(h)) return false;
return true;
}
/** Folgt einer Umleitung Station fuer Station.
*
* Gibt { ok: true, url, weg } oder { ok: false, grund, ... } mit
* grund aus: "fremd" (verlaesst TikTok), "zu_viele", "unlesbar",
* "frist", "netz".
*
* Der Koerper jeder Zwischenantwort wird WEGGEWORFEN, nicht gelesen:
* Sonst bleibt die Verbindung offen und wir laden jede Zwischenseite
* umsonst herunter. */
export async function folgeUmleitung(roh, { frist = FRIST_SEITE_MS,
schritte = UMLEITUNGEN_MAX } = {}) {
let hier = String(roh || "");
if (!istTikTok(hier)) {
return { ok: false, grund: "fremd", url: hier, host: hostVon(hier), weg: [] };
}
const weg = [hier];
/* EINE Frist fuer die GANZE Kette. Fuenf Spruenge mit je zwoelf
Sekunden waeren eine Minute -- und ein Mensch, der auf einen Knopf
gedrueckt hat, wartet keine Minute. */
const abbruch = new AbortController();
const uhr = setTimeout(() => abbruch.abort(), frist);
try {
for (let i = 0; i < schritte; i++) {
let a;
try {
a = await fetch(hier, {
method: "GET", redirect: "manual", signal: abbruch.signal,
headers: { "user-agent": KENNUNG },
});
} catch (f) {
return { ok: false, grund: abbruch.signal.aborted ? "frist" : "netz",
url: hier, host: hostVon(hier), weg, fehler: f?.message };
}
try { await a.body?.cancel(); } catch { /* egal */ }
const ort = a.headers.get("location");
if (!ort) return { ok: true, url: hier, weg, status: a.status };
let naechste;
try { naechste = new URL(ort, hier).toString(); }
catch { return { ok: false, grund: "unlesbar", url: ort, host: hostVon(ort), weg }; }
if (!istTikTok(naechste)) {
return { ok: false, grund: "fremd", url: naechste, host: hostVon(naechste), weg };
}
hier = naechste;
weg.push(hier);
}
return { ok: false, grund: "zu_viele", url: hier, host: hostVon(hier), weg };
} finally { clearTimeout(uhr); }
}
/** Holt eine ganze Seite -- mit Frist UND mit einer Grenze, die beim
* LESEN gezogen wird.
*
* Gibt { ok: true, status, text, bytes } oder { ok: false, grund }
* mit grund aus: "frist", "zu_gross", "netz", "status". Ein Aufrufer,
* der jeden dieser Faelle verschieden beantworten muss, soll sie
* nicht aus einer Fehlermeldung raten. */
export async function holeSeite(adresse, { frist = FRIST_SEITE_MS,
maxBytes = SEITE_MAX } = {}) {
const abbruch = new AbortController();
const uhr = setTimeout(() => abbruch.abort(), frist);
try {
let a;
try {
a = await fetch(adresse, {
signal: abbruch.signal, redirect: "error",
headers: { "user-agent": KENNUNG, accept: "text/html" },
});
} catch (f) {
return { ok: false, grund: abbruch.signal.aborted ? "frist" : "netz",
fehler: f?.message };
}
if (!a.ok) {
try { await a.body?.cancel(); } catch { /* egal */ }
return { ok: false, grund: "status", status: a.status };
}
/* Ein Kopf, der die Groesse schon ansagt, erspart das Herunterladen
-- aber er ist eine BEHAUPTUNG des fremden Servers und kann
fehlen oder luegen. Deshalb beides: hier nachsehen UND unten
beim Lesen mitzaehlen. */
const angesagt = Number(a.headers.get("content-length"));
if (Number.isFinite(angesagt) && angesagt > maxBytes) {
try { await a.body?.cancel(); } catch { /* egal */ }
return { ok: false, grund: "zu_gross", bytes: angesagt, angesagt: true };
}
const leser = a.body?.getReader?.();
if (!leser) return { ok: false, grund: "netz", fehler: "kein Koerper" };
const stuecke = [];
let gesamt = 0;
for (;;) {
let stueck;
try { stueck = await leser.read(); }
catch (f) {
return { ok: false, grund: abbruch.signal.aborted ? "frist" : "netz",
fehler: f?.message };
}
if (stueck.done) break;
gesamt += stueck.value.byteLength;
if (gesamt > maxBytes) {
try { await leser.cancel(); } catch { /* egal */ }
return { ok: false, grund: "zu_gross", bytes: gesamt, angesagt: false };
}
stuecke.push(Buffer.from(stueck.value));
}
return { ok: true, status: a.status, bytes: gesamt,
text: Buffer.concat(stuecke).toString("utf8") };
} finally { clearTimeout(uhr); }
}
/** Holt etwas von draussen -- MIT Frist ueber den ganzen Vorgang.
*
* DIE ALTE FASSUNG HAT NUR DEN ANFANG BEWACHT (behoben 05.10.2026).
*
* Sie gab die Antwort zurueck, sobald die Kopfzeilen da waren, und
* raeumte im `finally` die Uhr weg. Was danach kam -- `a.json()` beim
* Abruf der Videodaten, `a.arrayBuffer()` beim Vorschaubild -- lief
* OHNE jede Frist. Bleibt der fremde Server mitten im Koerper
* stehen, wartet unsere Anfrage fuer immer.
*
* Genau das sieht man als "der Knopf bleibt auf `wird geholt ...`
* stehen": keine Fehlermeldung, kein Eintrag, kein Protokolleintrag
* -- weil der Vorgang nie endet. Und es braucht keine Aenderung bei
* uns, um anzufangen: Es haengt am fremden Netz. "Es ging doch immer
* bis jetzt" ist bei so einem Fehler die Regel, nicht die Ausnahme.
*
* Der Koerper wird deshalb GELESEN, solange die Uhr laeuft, und erst
* danach abgeraeumt. Die Frist gilt damit fuer alles zusammen. */
export async function holeMitFrist(adresse, kopf = {}, { frist = FRIST_MS } = {}) {
const abbruch = new AbortController();
const uhr = setTimeout(() => abbruch.abort(), frist);
try {
const a = await fetch(adresse, { signal: abbruch.signal, headers: kopf, redirect: "follow" });
/* Bei einem Fehlschlag interessiert der Koerper nicht -- aber er
muss trotzdem weg, sonst bleibt die Verbindung offen. */
const roh = Buffer.from(await a.arrayBuffer());
return {
ok: a.ok, status: a.status, headers: a.headers, roh,
text: () => roh.toString("utf8"),
};
} finally { clearTimeout(uhr); }
}