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]>
This commit is contained in:
+247
-9
@@ -1,10 +1,11 @@
|
||||
/* =====================================================================
|
||||
DER WEG NACH DRAUSSEN ZU TIKTOK — gemeinsam genutzt (07.10.2026)
|
||||
|
||||
Diese Datei ist 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.
|
||||
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
|
||||
@@ -29,11 +30,6 @@
|
||||
Kampagnenprüfung die echte Regel umgehen und prüfte damit eine
|
||||
andere als die, die im Betrieb gilt.
|
||||
|
||||
Sonst nichts. Kein neues Verhalten, keine neue Grenze, keine neue
|
||||
Entscheidung. Wer hier etwas vermisst (Umleitungen Station für
|
||||
Station, Größengrenze beim Lesen), findet es in Etappe 2 — nicht
|
||||
hier, und nicht halb.
|
||||
|
||||
---------------------------------------------------------------------
|
||||
KEINE DATENBANK, KEIN EXPRESS
|
||||
|
||||
@@ -68,6 +64,248 @@ export function istTikTok(roh) {
|
||||
} 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).
|
||||
|
||||
Reference in New Issue
Block a user