Files
dogfather-universe/server/helfer-tiktok.mjs
T
DogFatherGitandClaude Opus 5 b49a03d811 Es laeuft: TikTok baut die Seite nur fuer Handys -- und zwei Vorlagen statt einer
Filipes Kachel ging nicht. Der Grund war nicht TikTok, sondern EIN WORT
in unserer Kennung.

DAS WORT HEISST "Mobile"
  Gestern hiess es hier noch "17 KB, keine Daten, von jedem Weg aus".
  Nachgemessen mit einem Keksglas und fuenf Kennungen:

    Desktop-Kennung, ohne Kekse      17 KB   keine Daten
    Desktop-Kennung, MIT Keksen      17 KB   keine Daten
    Android-Kennung, ohne Kekse     939 KB   DATEN
    iPhone-Kennung,  mit Keksen     939 KB   DATEN
    TikTok-App,      mit Keksen      42 KB   keine Daten

  TikTok baut die Seite serverseitig NUR fuer die Handyfassung auf; am
  Rechner laedt sie sich mit JavaScript nach. Kekse, Sprache und
  Accept spielen keine Rolle -- einzeln nachgemessen.

  UND WIR BLEIBEN EHRLICH: Unser Name und unsere Adresse stehen hinten
  an der Kennung. Nachgemessen liefert TikTok damit dieselben 939 KB.
  Es kostet also nichts. Die Pruefung verlangt jetzt BEIDES: "Mobile"
  (sonst kommt nichts an) und unseren Namen (sonst ist es eine
  Verkleidung).

ZWEI VORLAGEN, NICHT EINE
  Filipes Einladung fuehrt zu "LIVE Glow Up" -- und die ist ganz
  anders gebaut als "Gipfelstuermer". Ihr fehlen gleich DREI
  Bausteine, auf denen der Bauplan beruht: live_rule_introduction,
  live_reward_introduction, live_campaign_intro. Stattdessen hat sie
  live_task_group (Aufgaben als Reiter) und live_secondary_gift.

  Der Leser kann jetzt beide. Gemessen:

    Gipfelstuermer  5 Aufgaben, 5 Geschenke, 6 Preisgruppen, 0 Hinweise
    LIVE Glow Up    2 Aufgaben, 1 Geschenk,  0 Preise,       2 Hinweise

  Die doppelten Reiter fallen weg: Die Vorlage legt je Reiter zwei
  Eintraege an (Creator und Zuschauer), und zweimal derselbe Haken
  waere ein Haken an zwei Stellen.

"HAT KEINE" IST ETWAS ANDERES ALS "KONNTE NICHT GELESEN WERDEN"
  Ohne diese Unterscheidung haette LIVE Glow Up vier Hinweise gemeldet,
  die alle nach einem Fehler klingen -- obwohl schlicht nichts da ist.
  Das eine schickt jemanden suchen, das andere sagt ihm, dass nichts
  fehlt. Entsprechend haengt der Regeltext auch kein pauschales "Bitte
  nachtragen" mehr an: Jeder Hinweis sagt selbst, ob etwas zu tun ist.

FUENFTE PRUEFDATEI
  kampagne-aufgabengruppe.html.br -- die echte Seite von LIVE Glow Up,
  unveraendert, unangemeldet geholt (uid "0", anchor_id leer),
  147 KB gepackt, sofort zurueckgelesen und Byte fuer Byte verglichen.
  Ohne sie koennte der Leser eine Vorlage und faellt bei der naechsten
  Einladung um.

GEPRUEFT
  pruef-kampagne-lesen 112 -> 128 (eigener Abschnitt fuer die zweite
  Vorlage, mit Gegenprobe: Gipfelstuermer meldet weiterhin nichts) ·
  pruef-kampagne 102 -> 103 · pruef-struktur, pruef-zeichen, pruef-video

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-07 18:15:50 +02:00

373 lines
16 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 -- UND WELCHE FASSUNG DER SEITE WIR WOLLEN
(07.10.2026, gemessen nachdem es NICHT lief).
Erst stand hier nur unser eigener Name. Damit kam von TikTok eine
leere Huelle: 17 KB, Titel "Campaign", kein Inhalt. Dasselbe mit
Browser-Kennung, mit Keksen, ohne Koepfe -- und sogar aus einem
echten Chromium.
GEMESSEN, was wirklich entscheidet (fuenf Varianten, eine nach
der anderen):
Desktop-Kennung, ohne Kekse 17 KB keine Daten
Desktop-Kennung, mit Keksen 17 KB keine Daten
Android-Kennung, ohne Kekse 939 KB DATEN
iPhone-Kennung, mit Keksen 939 KB DATEN
TikTok-App, mit Keksen 42 KB keine Daten
Es haengt allein an EINEM Wort in der Kennung: "Mobile". TikTok
baut die Seite serverseitig nur fuer die Handyfassung auf -- am
Rechner laedt sie sich mit JavaScript nach. Kekse, Sprache und
Accept spielen keine Rolle (einzeln nachgemessen).
UND WIR BLEIBEN TROTZDEM EHRLICH. Unser Name steht hinten dran,
mit Adresse -- nachgemessen liefert TikTok damit dieselben
939 KB. Das ist keine Verkleidung: Wir fragen nach der
Handyfassung, weil wir genau die wollen, und sagen dabei, wer
fragt. Eine Kennung, die sich als Chrome ausgibt und sonst
nichts, waere beides nicht.
WENN ES WIEDER KIPPT: Die Zeile unten ist der erste Ort zum
Nachsehen. Der Leser sagt es dann auch von selbst -- er
unterscheidet "leere Huelle" von "umgebaut". */
export const KENNUNG =
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) Chrome/140.0.0.0 Mobile Safari/537.36 "
+ "DogfatherUniverse-Workspace/1.0 "
+ "(+https://workspace.dogfather-universe.com; Kampagnen-Einleser)";
/** 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); }
}