diff --git a/server/bild-kampagne.mjs b/server/bild-kampagne.mjs new file mode 100644 index 00000000..59992010 --- /dev/null +++ b/server/bild-kampagne.mjs @@ -0,0 +1,264 @@ +/* Bild und Mass des Kastens „Kampagne aus einer Einladung" (07.10.2026) + + Etappe 4 des Bauplans. Die Zahlen sind gruen (pruef-kampagne, 94 + Pruefungen) -- das sagt, dass der Weg stimmt, aber nicht, wie es + AUSSIEHT und ob man es mit dem Daumen treffen kann. + + GEMESSEN WIRD AUF 412 UND 1280 PIXEL. Das sind nicht zwei beliebige + Breiten: An genau diesen beiden ist die Kopfleiste dieses Hauses am + 06.09.2026 zweimal an einem Tag gebrochen -- 412 px ist die + haeufigste Android-Breite, 1280 px die schmalste, auf der die Seite + als „Rechner" gilt. Wer nur bei 1400 nachsieht, sieht beides nicht. + + UND AUGENSCHONUNG WIRD GEMESSEN, NICHT ANGENOMMEN: Kontrast des + Hinweistextes, und dass bei `prefers-reduced-motion` keine Bewegung + laeuft. + + Aufrufen mit: node server/bild-kampagne.mjs +*/ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { notbremse } from "./helfer-notbremse.mjs"; +notbremse(240_000, "bild-kampagne"); + +const ordner = mkdtempSync(join(tmpdir(), "ws-kmpbild-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +const { portMussFreiSein } = await import("./helfer-port.mjs"); +const PORT = await portMussFreiSein(4338, "das Bildwerkzeug Kampagne"); +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 }); +}; +await import("./index.js"); +await new Promise((r) => setTimeout(r, 1200)); +const BASIS = `http://127.0.0.1:${PORT}`; + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +for (let i = 0; i < 60; i++) { + try { + const p = new DatabaseSync(process.env.WORKSPACE_DB); + try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); } + } catch { await new Promise((r) => setTimeout(r, 250)); } +} +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const j = new Date().toISOString(); +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 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, salt, 32768, j); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +anlegen("Filipe", "admin", "CODE-DOGI-0001"); +anlegen("Luna", "creator", "CODE-CREA-0001"); +d.close(); + +const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await pw.chromium.launch(); +const fehlerAufSeite = []; + +const anmelden = async (seite, rolle, code) => { + await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" }); + await seite.click(`.rolle[data-rolle="${rolle}"]`); + await seite.fill("#code", code); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 25000 }); +}; + +/** Der Kontrast zweier Farben nach WCAG. Gerechnet, nicht geschaetzt. */ +const leuchte = ([r, g, b]) => { + const f = (c) => { const x = c / 255; return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; }; + return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); +}; +const kontrast = (a, b) => { + const [x, y] = [leuchte(a), leuchte(b)].sort((p, q) => q - p); + return (x + 0.05) / (y + 0.05); +}; +const farbe = (s) => { + const m = /rgba?\(([^)]+)\)/.exec(s || ""); + if (!m) return null; + const t = m[1].split(",").map((x) => parseFloat(x)); + return [t[0], t[1], t[2]]; +}; + +let fehler = 0; +let geprueft = 0; +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +async function messen(breite, hoehe, name) { + const ctx = await browser.newContext({ + viewport: { width: breite, height: hoehe }, + deviceScaleFactor: 2, + hasTouch: breite < 700, isMobile: breite < 700, + }); + const seite = await ctx.newPage(); + seite.on("console", (m) => { if (m.type() === "error") fehlerAufSeite.push(m.text()); }); + seite.on("pageerror", (f) => fehlerAufSeite.push(String(f))); + await anmelden(seite, "admin", "CODE-DOGI-0001"); + await seite.goto(BASIS + "/workspace/bereich.html?b=agentur", { waitUntil: "networkidle" }); + await seite.waitForTimeout(1600); + + console.log(`\n=== ${name} (${breite} px) ===`); + const mass = await seite.evaluate(() => { + const k = document.getElementById("kampagneholen"); + if (!k || k.hidden) return { da: false }; + const r = (e) => { const b = e.getBoundingClientRect(); + return { x: Math.round(b.x), y: Math.round(b.y), + b: Math.round(b.width), h: Math.round(b.height) }; }; + const feld = document.getElementById("kampagne-text"); + const knopf = document.getElementById("kampagne-holen"); + const sagt = document.getElementById("kampagne-sagt"); + const titel = k.querySelector(".kampagneholen__titel"); + const st = getComputedStyle(sagt); + return { + da: true, + kasten: r(k), feld: r(feld), knopf: r(knopf), sagt: r(sagt), titel: r(titel), + knopfText: knopf.textContent.trim(), + sagtText: sagt.textContent.trim(), + textFarbe: st.color, + kastenFarbe: getComputedStyle(k).backgroundColor, + seitenFarbe: getComputedStyle(document.body).backgroundColor, + schrift: st.fontSize, + /* Ueberdeckt etwas den Knopf? Nicht gerechnet, sondern gefragt: + Was liegt an seinem Mittelpunkt wirklich obenauf? */ + obenAufKnopf: (() => { + const b = knopf.getBoundingClientRect(); + const e = document.elementFromPoint(b.x + b.width / 2, b.y + b.height / 2); + return e ? (e.id || e.className || e.tagName) : null; + })(), + ueberlauf: k.scrollWidth > document.documentElement.clientWidth + 1, + }; + }); + + ok(mass.da, "der Kasten ist da"); + if (!mass.da) { await ctx.close(); return; } + + console.log(` Kasten x=${mass.kasten.x} b=${mass.kasten.b} h=${mass.kasten.h}`); + console.log(` Titel „${"Kampagne aus einer Einladung anlegen"}" h=${mass.titel.h}`); + console.log(` Feld x=${mass.feld.x} b=${mass.feld.b} h=${mass.feld.h}`); + console.log(` Knopf x=${mass.knopf.x} b=${mass.knopf.b} h=${mass.knopf.h} „${mass.knopfText}"`); + console.log(` Hinweis x=${mass.sagt.x} b=${mass.sagt.b} h=${mass.sagt.h}`); + + /* 44 PIXEL. Die Zahl aus pruef-handy-teamdogi: Darunter trifft ein + Daumen nicht verlaesslich. Am 07.10.2026 lagen fuenf Flaechen in + diesem Haus bei 40 und wurden genau deshalb gezogen. */ + ok(mass.knopf.h >= 44, `der Knopf ist ${mass.knopf.h} px hoch (mindestens 44)`); + ok(mass.feld.b > 120, `das Textfeld ist ${mass.feld.b} px breit -- kein Schlitz`); + ok(mass.feld.h >= 72, `und ${mass.feld.h} px hoch, also mehrzeilig`); + + /* NICHTS LIEGT AUF DEM KNOPF. Am 06.09.2026 lag der Sicht-Umschalter + quer ueber dem Chat-Knopf: Wer auf „Meine Sicht" tippte, landete + im Chat -- auf zwoelf Seiten. */ + ok(String(mass.obenAufKnopf).includes("kampagne-holen"), + `am Mittelpunkt des Knopfes liegt der Knopf (${mass.obenAufKnopf})`); + + ok(!mass.ueberlauf, "der Kasten laeuft nicht aus der Seite heraus"); + + /* BRICHT ES UM, WENN DER PLATZ NICHT REICHT? Keine feste Schwelle, + sondern `flex-wrap` -- gemessen daran, ob Knopf und Hinweis bei + 412 px untereinander stehen. */ + const nebeneinander = Math.abs(mass.knopf.y - mass.sagt.y) < 10; + console.log(` Knopf und Hinweis ${nebeneinander ? "nebeneinander" : "untereinander"}`); + + /* AUGENSCHONEND, GERECHNET. */ + const vorn = farbe(mass.textFarbe); + const hinten = farbe(mass.kastenFarbe)?.[0] !== undefined + && farbe(mass.kastenFarbe) || farbe(mass.seitenFarbe); + if (vorn && hinten) { + const k = kontrast(vorn, hinten); + ok(k >= 4.5, `Kontrast des Hinweises ${k.toFixed(2)}:1 (mindestens 4,5)`); + } else { + ok(false, `Farben nicht messbar: ${mass.textFarbe} auf ${mass.kastenFarbe}`); + } + ok(parseFloat(mass.schrift) >= 13, + `Schriftgroesse ${mass.schrift} (mindestens 13 px)`); + + const kasten = await seite.$("#kampagneholen"); + await kasten.screenshot({ path: `server/bild-kampagne-${breite}.png` }); + + /* UND WIE ES WAEHREND DES HOLENS AUSSIEHT. */ + await seite.evaluate(() => { + document.getElementById("kampagne-form").dataset.laeuft = "ja"; + document.getElementById("kampagne-sagt").textContent = "Wird geholt …"; + }); + await seite.waitForTimeout(300); + await kasten.screenshot({ path: `server/bild-kampagne-${breite}-laeuft.png` }); + + await ctx.close(); +} + +await messen(1280, 900, "Am Rechner, schmalste Rechnerbreite"); +await messen(412, 915, "Auf dem Handy, haeufigste Android-Breite"); + +/* ---- Keine Bewegung, wenn jemand keine will -------------------------- */ +{ + const ctx = await browser.newContext({ + viewport: { width: 412, height: 915 }, deviceScaleFactor: 2, + hasTouch: true, isMobile: true, reducedMotion: "reduce", + }); + const seite = await ctx.newPage(); + await anmelden(seite, "admin", "CODE-DOGI-0001"); + await seite.goto(BASIS + "/workspace/bereich.html?b=agentur", { waitUntil: "networkidle" }); + await seite.waitForTimeout(1200); + const ruht = await seite.evaluate(() => { + const f = document.getElementById("kampagne-form"); + f.dataset.laeuft = "ja"; + const s = getComputedStyle(document.getElementById("kampagne-sagt")); + return { animation: s.animationName, dauer: s.animationDuration }; + }); + console.log("\n=== Wer keine Bewegung will ==="); + ok(ruht.animation === "none" || ruht.dauer === "0s", + `keine Bewegung bei prefers-reduced-motion (${ruht.animation}, ${ruht.dauer})`); +} + +/* ---- Und ein Creator sieht den Kasten gar nicht --------------------- */ +{ + const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const seite = await ctx.newPage(); + await anmelden(seite, "creator", "CODE-CREA-0001"); + await seite.goto(BASIS + "/workspace/bereich.html?b=agentur", { waitUntil: "networkidle" }); + await seite.waitForTimeout(1400); + const sicht = await seite.evaluate(() => { + const k = document.getElementById("kampagneholen"); + return { da: !!k, versteckt: k ? k.hidden : null, + sichtbar: k ? k.getBoundingClientRect().height > 0 : null }; + }); + console.log("\n=== Ein Creator ==="); + ok(sicht.versteckt === true && sicht.sichtbar === false, + `der Kasten bleibt zu (hidden=${sicht.versteckt}, Hoehe>0=${sicht.sichtbar})`); + await ctx.close(); +} + +/* ---- Und auf einem anderen Brett steht er nicht --------------------- */ +{ + const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const seite = await ctx.newPage(); + await anmelden(seite, "admin", "CODE-DOGI-0001"); + await seite.goto(BASIS + "/workspace/bereich.html?b=technik", { waitUntil: "networkidle" }); + await seite.waitForTimeout(1400); + const sicht = await seite.evaluate(() => { + const k = document.getElementById("kampagneholen"); + return k ? k.hidden : null; + }); + console.log("\n=== Auf dem Brett Technik ==="); + ok(sicht === true, `dort bleibt der Kasten zu (hidden=${sicht})`); + await ctx.close(); +} + +console.log("\n=== Browser-Meldungen ==="); +ok(fehlerAufSeite.length === 0, + fehlerAufSeite.length ? fehlerAufSeite.slice(0, 4).join(" | ") : "keine Fehler"); + +console.log(`\n${geprueft} Messungen, ${fehler} Fehler`); +console.log("Bilder: server/bild-kampagne-1280.png, -412.png und je -laeuft.png"); +await browser.close(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Rest */ } +process.exit(fehler ? 1 : 0); diff --git a/server/helfer-tiktok.mjs b/server/helfer-tiktok.mjs index cb97b6af..a9c9d84e 100644 --- a/server/helfer-tiktok.mjs +++ b/server/helfer-tiktok.mjs @@ -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). diff --git a/server/index.js b/server/index.js index bda1ec38..89d6aade 100644 --- a/server/index.js +++ b/server/index.js @@ -68,6 +68,7 @@ import { pushRouter, pushStarten } from "./workspace-push.js"; import { chatRouter, toeneNachruestenStarten } from "./workspace-chat.js"; import { anrufRouter } from "./workspace-anruf.js"; import { videoRouter, videoWacheStarten } from "./workspace-video.js"; +import { kampagneRouter } from "./workspace-kampagne.js"; import { bewerbungRouter } from "./workspace-bewerbung.js"; import { leistungRouter } from "./workspace-leistung.js"; import { managerZieleRouter } from "./workspace-manager-ziele.js"; @@ -307,6 +308,20 @@ app.use(anrufRouter); derselbe Fehler, der heute frueh beim Antwort-Weg drei Anlaeufe gekostet hat. */ app.use(videoRouter); +/* EINE KAMPAGNE AUS EINEM TIKTOK-LINK (07.10.2026). + + Steht NACH `app.use("/workspace/api", gleicheHerkunft)` weiter + oben -- und zwar nicht zufaellig: Express arbeitet die Schichten in + der Reihenfolge ab, in der sie eingehaengt werden. Ein Router, der + VOR der Herkunftswache steht, ist von ihr nicht geschuetzt. Am + 07.10.2026 war genau das der Grund, warum die Wache in EINER Zeile + in index.js steht und nicht in jedem Router einzeln. + + Eigener Pfad (`/workspace/api/kampagne`), nicht `/bereich/...`: + Dort horcht der Bereichs-Router auf `/:bereich/:id` und wuerde + "kampagne" fuer eine Nummer halten -- derselbe Fehler, der beim + Video drei Anlaeufe gekostet hat. */ +app.use(kampagneRouter); /* LEISTUNG und FRUEHWARNUNG (06.09.2026, Stufe 1+2 des Plans). Die Fruehwarnung liest aus `leistung` -- ohne Zahlen hat sie nichts zu melden, deshalb stehen sie beieinander. */ diff --git a/server/kampagne-lesen.mjs b/server/kampagne-lesen.mjs index 3cb6f9a5..4e1b2db8 100644 --- a/server/kampagne-lesen.mjs +++ b/server/kampagne-lesen.mjs @@ -540,9 +540,24 @@ export function leseKampagne(html) { /* Banner: `props.imageUrl[0].url`, NICHT `props.url` und nicht `pageInfo.topImageInfo` -- die zeigen auf das Vorlagenbild. */ const bildBaustein = erster("live_top_image"); + /* HIER STEHT ABSICHTLICH KEIN "nur https" (berichtigt 07.10.2026). + Vorher stand hier `/^https:/`. Das war eine ENTSCHEIDUNG an einer + Stelle, die nur LESEN soll -- und sie stand damit zum zweiten Mal + im Haus, denn `bildAdresseErlaubt` in helfer-tiktok.mjs prueft + Protokoll, Port und private Adressen ohnehin, bevor etwas geholt + wird. + + Zwei Fassungen derselben Regel laufen auseinander, und hier tat es + sofort weh: Keine Pruefung konnte einen nachgebauten Bilddienst + auf 127.0.0.1 benutzen -- der Leser verwarf die Adresse, bevor die + Schranke sie je sah. Die Pruefung haette dafuer das echte TikTok + anfragen muessen. + + Der Leser nimmt deshalb jede absolute Adresse. Ob sie abgerufen + werden DARF, entscheidet die Schranke. */ const bannerUrl = (bildBaustein?.props?.imageUrl || []) .map((b) => b?.url || b?.urlSG) - .find((u) => typeof u === "string" && /^https:\/\//i.test(u)) || null; + .find((u) => typeof u === "string" && /^https?:\/\//i.test(u)) || null; if (!bannerUrl) hinweise.push("Das Banner konnte nicht gelesen werden."); /* Laender */ @@ -586,6 +601,23 @@ export function leseKampagne(html) { kurznummer: aktion.short_id ?? null, laender, bausteine: [...teile.keys()], + /* DAS GELESENE SELBST GEHOERT MIT HINEIN (07.10.2026, beim Bauen + der Route aufgefallen). + + Der Regeltext einer echten Kampagne ist 7474 Zeichen lang; das + Feld in der Kachel fasst 6000 (TEXT_MAX in + workspace-bereiche.js, und die PUT-Route lehnt mehr ab -- eine + Kachel mit 7474 Zeichen koennte DogFather oeffnen, aber nicht + speichern). Der Text wird deshalb an einer Abschnittsgrenze + gekuerzt. Damit dabei nichts VERLOREN geht, steht hier das + Gelesene in seiner gegliederten Form. + + Das ist genau der Zweck, den der Bauplan fuer diese Spalte + nennt: Wenn in drei Monaten auffaellt, dass die Ligen auch + gebraucht werden, steht die Antwort schon da -- und eine + abgelaufene Kampagne antwortet dann vielleicht nicht mehr. */ + einleitung, aufgaben, geschenke, preise, klapptexte, + banner_url: bannerUrl, woerterbuch_eintraege: Object.keys(woerterbuch).length, platzhalter_ohne_eintrag: ohneEintrag.length, }, @@ -656,8 +688,16 @@ export function regelnText(kuer, hinweise = []) { /* Was nicht gelesen werden konnte, steht IN der Kachel -- nicht nur in einer Antwort, die niemand mehr sieht. Eine Kachel, die - unvollstaendig ist, soll das selbst sagen. */ - for (const h of hinweise || []) bloecke.push(`* ${h} Bitte nachtragen.`); + unvollstaendig ist, soll das selbst sagen. - return bloecke.join("\n\n").trim(); + UND ZWAR GANZ OBEN (berichtigt 07.10.2026). Vorher standen die + Hinweise am Ende. Gemessen: Der Regeltext von "Gipfelstuermer" ist + 7474 Zeichen lang, das Feld fasst 6000 -- es wird also gekuerzt, + und gekuerzt wird von HINTEN. Die Hinweise waren damit genau das + Erste, was wegfiel: Eine Kachel, der die Preise fehlten, sagte es + nicht mehr. Die wichtigste Zeile darf nicht die sein, die als + erste verschwindet. */ + const vorweg = (hinweise || []).map((h) => `* ${h} Bitte nachtragen.`); + + return [...vorweg, ...bloecke].join("\n\n").trim(); } diff --git a/server/mess-anleitung-crew.mjs b/server/mess-anleitung-crew.mjs index f9364ef6..67f82c21 100644 --- a/server/mess-anleitung-crew.mjs +++ b/server/mess-anleitung-crew.mjs @@ -170,7 +170,19 @@ for (const [rolle, wie] of ROLLEN) { weiterreicht. Der Hostname bleibt damit `crew.` -- und nur daran erkennt der Server das Haus. */ const { zertifikatBauen } = await import("./helfer-openssl.mjs"); - const HP = PORT + 1; + /* DIE ZWEITE NUMMER WIRD ABGELEITET, NICHT GERECHNET + (berichtigt 07.10.2026, gefunden von pruef-portnummern). + + Hier stand `PORT + 1`. Das sieht aus wie eine Ableitung und ist + keine: `portNummer` UEBERSPRINGT gesperrte Nummern. Liegt einmal + eine dazwischen, ist `PORT + 1` die Nummer der naechsten DATEI -- + und zwei Laeufe teilen sich still einen Port. Heute liegt dort + keine gesperrte Nummer; das ist Glueck, kein Entwurf. + + `eigenerPort(import.meta, …, 1)` gibt die zweite der beiden + Nummern, die dieser Datei gehoeren -- und ueberspringt dabei + dasselbe wie die erste. */ + const HP = await eigenerPort(import.meta, "der HTTPS-Vorbau", 1); const schl = join(ordner, "b.key"), zert = join(ordner, "b.crt"); zertifikatBauen(schl, zert, CREW); const vorbau = httpsServer({ key: readFileSync(schl), cert: readFileSync(zert) }, diff --git a/server/pruef-anleitung.mjs b/server/pruef-anleitung.mjs index 3eabf484..f7c39da7 100644 --- a/server/pruef-anleitung.mjs +++ b/server/pruef-anleitung.mjs @@ -39,6 +39,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import vm from "node:vm"; import { eigenerPort } from "./helfer-port.mjs"; +import { heuteLokal } from "./helfer-tag.mjs"; const PORT = await eigenerPort(import.meta, "pruef-anleitung"); const ordner = mkdtempSync(join(tmpdir(), "ws-anl-")); @@ -1120,8 +1121,20 @@ melde("\n=== „Neu für dich“ beim Aufstieg ==="); const satz = d.prepare( `INSERT INTO eintraege (bereich, art, titel, datum, erstellt, erstellt_von) VALUES ('highlight','clip',?,?,?,?)`); + /* DER TAG IN ORTSZEIT, der Zeitstempel daneben in UTC + (berichtigt 07.10.2026, gefunden von pruef-struktur). Hier + stand zweimal dieselbe Zeichenkette: `datum` bekam den + UTC-Tag, die Anwendung sucht aber nach dem lokalen. Zwischen + 00:00 und 02:00 deutscher Zeit legte diese Pruefung ihre + Proben damit auf GESTERN und wunderte sich, dass sie heute + keine findet -- zwei Stunden am Tag rot, ohne dass etwas + kaputt ist. Genau der Fall vom 06.09.2026. + + `heuteLokal` kommt aus helfer-tag.mjs und wird nicht hier + abgeschrieben: Eine Pruefung, die ihren eigenen Kalender + mitbringt, misst irgendwann einen anderen Tag als das Haus. */ const heute = new Date().toISOString(); - for (let i = 1; i <= 3; i++) satz.run(`Probe ${i}`, heute.slice(0, 10), heute, gastId); + for (let i = 1; i <= 3; i++) satz.run(`Probe ${i}`, heuteLokal(), heute, gastId); d.close(); } const aStamm = await aufstieg("gast", CREW_HOST); diff --git a/server/pruef-kampagne.mjs b/server/pruef-kampagne.mjs new file mode 100644 index 00000000..ee848d57 --- /dev/null +++ b/server/pruef-kampagne.mjs @@ -0,0 +1,879 @@ +/* ===================================================================== + EINE KAMPAGNEN-KACHEL AUS EINEM TIKTOK-LINK (07.10.2026) + + Prüft Etappe 2 und 3 des Bauplans: den Weg nach draußen + (`helfer-tiktok.mjs`) und die Route (`workspace-kampagne.js`). + Das Auslesen selbst prüft `pruef-kampagne-lesen.mjs` — ohne Server, + ohne Netz, gegen gespeicherte Seiten. + + --------------------------------------------------------------------- + DER NACHGEBAUTE DIENST LIEGT AUF 127.0.0.1 + + Umgestellt über `TIKTOK_KAMPAGNE_BASIS` — genau wie + `TIKTOK_OEMBED_BASIS` beim Video. Eine Prüfung gegen das echte + TikTok wäre langsam, hinge am Netz und würde irgendwann rot, ohne + dass etwas kaputt ist. Sie wäre die Sorte Prüfung, die man abschaltet. + + Ausgeliefert werden die ECHTEN Seiten aus `server/pruefdaten/`. + Verändert wird daran genau eine Sache: die Banneradresse zeigt auf + diesen Dienst statt auf TikToks CDN. Ohne das würde diese Prüfung + bei jedem Lauf ein Bild aus dem Internet holen — und wäre damit + genau das, was sie vermeiden soll. + + --------------------------------------------------------------------- + KEINE TESTDATEN IM LIVE-SYSTEM + + Alles läuft auf einer frisch angelegten Datenbank in einem + Wegwerfordner. Es entsteht nie eine Kachel auf + workspace.dogfather-universe.com. + + KEINE WANDUHR ALS ANNAHME bei den Daten, die geprüft werden: Die + Kampagnen in den gespeicherten Seiten haben feste Zeitstempel, und + genau die werden nachgerechnet (1791064800000 → 2026-10-04 in + Europe/Berlin). Das ist eine EINGABE, keine Annahme über „jetzt“ — + der Kalender kann es nicht einholen. + + Aufrufen mit: node server/pruef-kampagne.mjs + ===================================================================== */ +import { mkdtempSync, rmSync, readFileSync, existsSync } from "node:fs"; +import { brotliDecompressSync } from "node:zlib"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { heuteLokal } from "./helfer-tag.mjs"; + +const HIER = dirname(fileURLToPath(import.meta.url)); +const DATEN = join(HIER, "pruefdaten"); + +const ordner = mkdtempSync(join(tmpdir(), "ws-kmp-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); + +import { notbremse } from "./helfer-notbremse.mjs"; +const { eigenerPort } = await import("./helfer-port.mjs"); +const PORT = await eigenerPort(import.meta, "die Kampagnen-Pruefung"); +const TIKPORT = await eigenerPort(import.meta, "der nachgebaute TikTok-Dienst", 1); + +/* DER SCHALTER. Er macht zweierlei: `istTikTok` lässt 127.0.0.1 durch, + und `bildAdresseErlaubt` ebenfalls. Beides nur hier — im Betrieb + steht die Variable nicht, und dann gilt die echte Regel. */ +process.env.TIKTOK_KAMPAGNE_BASIS = "http://127.0.0.1:" + TIKPORT; +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 }); +}; +await import("./index.js"); +notbremse(300_000, "pruef-kampagne"); +await new Promise((r) => setTimeout(r, 1000)); +const BASIS = "http://127.0.0.1:" + PORT; + +let fehler = 0; +let geprueft = 0; +const melde = (z) => console.log(z); +const ok = (b, t) => { geprueft++; melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +function nichtNachsehbar(grund, rat) { + melde(""); + melde("KONNTE NICHT NACHSEHEN"); + melde(" Grund: " + grund); + melde(" Was tun: " + rat); + process.exit(2); +} + +/* ===================================================================== + DIE GESPEICHERTEN SEITEN + ===================================================================== */ +function seiteLesen(name) { + const pfad = join(DATEN, name); + if (!existsSync(pfad)) { + nichtNachsehbar(`die Prüfdatei ${name} fehlt in ${DATEN}`, + "Sie ist in Gitea versioniert: git checkout -- server/pruefdaten/"); + } + try { + return name.endsWith(".br") + ? brotliDecompressSync(readFileSync(pfad)).toString("utf8") + : readFileSync(pfad, "utf8"); + } catch (f) { + return nichtNachsehbar(`${name} lässt sich nicht lesen (${f.message})`, + "Aus Gitea neu holen."); + } +} + +/** Macht das Datenpaket einer gespeicherten Seite auf, lässt daran + * ändern, schreibt es zurück. Steht hier und nicht im Leser: Der + * Leser liest, er baut nicht. */ +function umbauen(html, aendern) { + const marke = html.indexOf('id="__MODERN_ROUTER_DATA__"'); + const a = html.indexOf(">", marke) + 1; + const e = html.indexOf("", a); + const daten = JSON.parse(html.slice(a, e)); + aendern(daten.loaderData.home_page.COSMIC_D_K.value, daten); + return html.slice(0, a) + JSON.stringify(daten) + html.slice(e); +} + +const BANNER_HIER = `http://127.0.0.1:${TIKPORT}/banner.png`; +const TEXT_STATT_BILD = `http://127.0.0.1:${TIKPORT}/banner.txt`; + +/** Die Banneradresse auf unseren Dienst biegen — der EINZIGE Eingriff + * an den echten Seiten. */ +function bannerHierher(html, ziel = BANNER_HIER) { + return umbauen(html, (w) => { + const s = JSON.parse(w.globalData.activityInfo.ac_schema_with_interaction_rules + ?? JSON.stringify(w.schema)); + let getroffen = 0; + (function lauf(k, t = 0) { + if (!k || typeof k !== "object" || t > 20) return; + if (typeof k.type === "string" && k.type.includes("live_top_image") + && Array.isArray(k.props?.imageUrl)) { + for (const b of k.props.imageUrl) { b.url = ziel; b.urlSG = ziel; getroffen++; } + } + for (const v of Object.values(k)) { + if (Array.isArray(v)) { for (const x of v) lauf(x, t + 1); } + else if (v && typeof v === "object") lauf(v, t + 1); + } + })(s); + if (!getroffen) throw new Error("kein Bildbaustein gefunden"); + if (w.globalData.activityInfo.ac_schema_with_interaction_rules) { + w.globalData.activityInfo.ac_schema_with_interaction_rules = JSON.stringify(s); + } else { + w.schema = typeof w.schema === "string" ? JSON.stringify(s) : s; + } + }); +} + +const ID_GIPFEL = "7691493175936699143"; +const ID_BEENDET = "7657456323453405959"; +const ID_OHNEPREISE = "7670899782609996552"; +const ID_TEXTBANNER = "7600000000000000001"; + +const SEITEN = { + [ID_GIPFEL]: bannerHierher(seiteLesen("kampagne-gipfelstuermer.html.br")), + [ID_BEENDET]: bannerHierher(seiteLesen("kampagne-beendet.html.br")), + [ID_OHNEPREISE]: bannerHierher(seiteLesen("kampagne-ohne-preise.html.br")), +}; +const ROH_GIPFEL = seiteLesen("kampagne-gipfelstuermer.html.br"); +const SEITE_KAPUTT = seiteLesen("kampagne-kaputt.html"); + +/* Eine Kampagne, deren Banner eine TEXTDATEI ist. Für die Gegenprobe + zu `bildArtVon`: Was von draußen kommt, ist von draußen -- und der + fremde Server behauptet den Typ. */ +SEITEN[ID_TEXTBANNER] = umbauen(bannerHierher(ROH_GIPFEL, TEXT_STATT_BILD), (w) => { + w.globalData.activityInfo.activity_id = ID_TEXTBANNER; + w.globalData.activityId = ID_TEXTBANNER; + w.globalData.pageInfo.title = "Kampagne mit kaputtem Banner"; +}); + +/* Eine Kampagne ohne Enddatum: Pflichtfeld fehlt, es darf KEINE Kachel + entstehen. */ +const SEITE_OHNE_ENDE = umbauen(ROH_GIPFEL, (w) => { + delete w.globalData.activityInfo.end_time; +}); + +/* ===================================================================== + DER NACHGEBAUTE DIENST + ===================================================================== */ +const { createServer } = await import("node:http"); +const { pngBauen } = await import("./helfer-png-bauen.mjs"); +const BANNERBILD = pngBauen(750, 500, (x, y, b, h) => { + const g = Math.max(0, 1 - Math.hypot(x / b - .5, y / h - .5) * 1.6); + return [Math.round(30 + g * 120), Math.round(40 + g * 90), Math.round(20 + g * 60)]; +}); + +let abrufe = 0; +const gesehen = []; +const tikDienst = createServer((anfrage, antwort) => { + abrufe++; + const u = new URL(anfrage.url, "http://127.0.0.1"); + gesehen.push({ pfad: u.pathname, kennung: anfrage.headers["user-agent"] || "" }); + const leite = (nach) => { antwort.writeHead(302, { location: nach }); antwort.end(); }; + + if (u.pathname === "/t/gipfel") return leite(`/falcon/home?activityId=${ID_GIPFEL}&campaign_traffic_id=1`); + if (u.pathname === "/t/gipfel-zweiter-versand") return leite(`/falcon/home?activityId=${ID_GIPFEL}&campaign_traffic_id=2`); + if (u.pathname === "/t/beendet") return leite(`/falcon/home?activityId=${ID_BEENDET}`); + if (u.pathname === "/t/ohnepreise") return leite(`/falcon/home?activityId=${ID_OHNEPREISE}`); + if (u.pathname === "/t/textbanner") return leite(`/falcon/home?activityId=${ID_TEXTBANNER}`); + if (u.pathname === "/t/fremd") return leite("https://beispiel.de/ganz-woanders"); + if (u.pathname === "/t/kreis") return leite("/t/kreis"); + if (u.pathname === "/t/kaputt") return leite("/falcon/kaputt"); + if (u.pathname === "/t/ohneende") return leite("/falcon/ohneende"); + if (u.pathname === "/t/stumm") return leite("/falcon/stumm"); + if (u.pathname === "/t/riesig") return leite("/falcon/riesig"); + + if (u.pathname === "/banner.png") { + antwort.writeHead(200, { "content-type": "image/png", + "content-length": BANNERBILD.length }); + return antwort.end(BANNERBILD); + } + if (u.pathname === "/banner.txt") { + const b = Buffer.from("Das ist kein Bild, sondern Text."); + /* Der Typ wird BEHAUPTET -- genau darum geht es: Die Byteprüfung + glaubt dem fremden Server nicht. */ + antwort.writeHead(200, { "content-type": "image/png", "content-length": b.length }); + return antwort.end(b); + } + if (u.pathname === "/falcon/stumm") { + /* Nie antworten. Die Verbindung bleibt offen, bis die Frist greift. */ + return; + } + if (u.pathname === "/falcon/riesig") { + antwort.writeHead(200, { "content-type": "text/html" }); + /* OHNE content-length (gestückelt): Die Grenze muss beim LESEN + greifen, nicht über einen Kopf, den man auch weglassen kann. */ + const brocken = Buffer.alloc(256 * 1024, 0x41); + let n = 0; + const weiter = () => { + if (antwort.writableEnded || antwort.destroyed) return; + if (n++ >= 20) { antwort.end(); return; } /* 5 MB */ + if (antwort.write(brocken)) setImmediate(weiter); + else antwort.once("drain", weiter); + }; + return weiter(); + } + if (u.pathname === "/falcon/kaputt") { + antwort.writeHead(200, { "content-type": "text/html" }); + return antwort.end(SEITE_KAPUTT); + } + if (u.pathname === "/falcon/ohneende") { + antwort.writeHead(200, { "content-type": "text/html" }); + return antwort.end(SEITE_OHNE_ENDE); + } + if (u.pathname === "/falcon/home") { + const id = u.searchParams.get("activityId"); + if (SEITEN[id]) { + antwort.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + return antwort.end(SEITEN[id]); + } + } + antwort.writeHead(404, { "content-type": "text/plain" }); + antwort.end("nicht da"); +}); +await new Promise((f, s) => { + tikDienst.once("error", s); + tikDienst.listen(TIKPORT, "127.0.0.1", f); +}); +const TIK = `http://127.0.0.1:${TIKPORT}`; + +/* ===================================================================== + PERSONEN UND ZUGANG + ===================================================================== */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {}); +for (let i = 0; i < 60; i++) { + try { + const p = new DatabaseSync(process.env.WORKSPACE_DB); + try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); } + } catch { await new Promise((r) => setTimeout(r, 250)); } +} +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 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, salt, 32768, jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +const idDogi = anlegen("Dogfather", "admin", "CODE-DOGI-0001"); +anlegen("Luna", "creator", "CODE-CREA-0001"); +anlegen("Mara", "manager", "CODE-MANA-0001"); +anlegen("Bremser", "manager", "CODE-BREM-0001"); + +/* EIN EINTRAG IM ANDEREN HAUS -- damit „Crew unverändert“ nicht eine + Behauptung über eine leere Menge ist. `every()` auf einem leeren Feld + meldet `true`; eine Zählung über null Zeilen ebenso. */ +d.prepare("INSERT INTO eintraege (titel, text, bereich, art, status, datum," + + " dringlichkeit, erstellt, erstellt_von, haus)" + + " VALUES (?,?,'treff','frage','offen',?, 'mittel', ?, ?, 'crew')") + /* DER TAG IN ORTSZEIT (berichtigt 07.10.2026, gefunden von + pruef-struktur). Hier stand `jetzt.slice(0, 10)` -- das ist der + UTC-Tag. Zwischen 00:00 und 02:00 deutscher Zeit legt das den + Eintrag auf gestern; harmlos fuer DIESE Pruefung, aber genau die + Gewohnheit, die anderswo zwei Stunden am Tag rot macht. */ + .run("Etwas vom Rudel", "Gehört Team Dogi.", heuteLokal(), jetzt, idDogi); +d.close(); + +const { request } = await import("node:http"); +const HOST = "workspace.dogfather-universe.com"; +const CREW_HOST = "crew.dogfather-universe.com"; +function roh(pfad, o = {}) { + return new Promise((f, s) => { + const daten = o.rumpf === undefined ? null : Buffer.from(JSON.stringify(o.rumpf)); + const wirt = o.wirt || HOST; + const a = request({ + host: "127.0.0.1", port: PORT, path: pfad, + method: o.methode || (daten ? "POST" : "GET"), + headers: { + Host: wirt, Origin: "https://" + wirt, + ...(o.keks ? { Cookie: o.keks } : {}), + ...(daten ? { "Content-Type": "application/json", "Content-Length": daten.length } : {}), + ...(o.kopf || {}), + }, + }, (r) => { + const t = []; + r.on("data", (x) => t.push(x)); + r.on("end", () => f({ code: r.statusCode, kopf: r.headers, + text: Buffer.concat(t).toString("utf8") })); + }); + a.on("error", s); + if (daten) a.write(daten); + a.end(); + }); +} +async function anmelden(rolle, code, wirt = HOST) { + const a = await roh("/workspace/api/anmelden", { rumpf: { rolle, code }, wirt }); + if (a.code !== 200) { + nichtNachsehbar(`Anmeldung ${rolle} auf ${wirt} ergab ${a.code}`, + "Ohne Anmeldung lässt sich nichts von dem hier messen."); + } + return [].concat(a.kopf["set-cookie"] || []).map((c) => c.split(";")[0]).join("; "); +} +const keksDogi = await anmelden("admin", "CODE-DOGI-0001"); +const keksLuna = await anmelden("creator", "CODE-CREA-0001"); +const keksMara = await anmelden("manager", "CODE-MANA-0001"); +const keksBrem = await anmelden("manager", "CODE-BREM-0001"); +const keksDogiCrew = await anmelden("admin", "CODE-DOGI-0001", CREW_HOST); + +/** Eine Einladung einlesen -- so, wie es die Oberfläche tut. */ +const einlesen = (text, keks = keksDogi, wirt = HOST) => + roh("/workspace/api/kampagne/einlesen", { rumpf: { text }, keks, wirt }) + .then((a) => ({ ...a, j: JSON.parse(a.text || "{}") })); + +const dbLesen = (sql, ...w) => { + const x = new DatabaseSync(process.env.WORKSPACE_DB); + try { return x.prepare(sql).all(...w); } finally { x.close(); } +}; + +/* ===================================================================== + 1. DEN LINK AUS DEM GANZEN TEXT HOLEN + ===================================================================== */ +melde("=== 1. Der Link steckt mitten im Fließtext ==="); + +const { linkAusText, istTikTok, hostVon, bildAdresseErlaubt, + FRIST_SEITE_MS, SEITE_MAX, UMLEITUNGEN_MAX, KENNUNG, holeSeite, folgeUmleitung } + = await import("./helfer-tiktok.mjs"); + +{ + const einladung = "Dein Creator Network lädt dich ein, an einer Kampagne " + + "teilzunehmen.\nKlicke hier, um weitere Informationen zu erhalten: " + + "https://www.tiktok.com/t/ZSbqPsFa8/"; + ok(linkAusText(einladung) === "https://www.tiktok.com/t/ZSbqPsFa8/", + `aus 150 Zeichen Einladung: ${linkAusText(einladung)}`); + + ok(linkAusText("Schau mal: https://www.tiktok.com/t/ZSbqPsFa8/.") === "https://www.tiktok.com/t/ZSbqPsFa8/", + "ein Satzpunkt dahinter gehört nicht zur Adresse"); + ok(linkAusText("Hier (https://www.tiktok.com/t/ABC/) steht er") === "https://www.tiktok.com/t/ABC/", + "eine Klammer dahinter auch nicht"); + ok(linkAusText("Ohne jede Adresse.") === null, + "ein Text ohne Link ergibt nichts"); + ok(linkAusText("https://example.com/x https://www.tiktok.com/t/B/") === "https://www.tiktok.com/t/B/", + "eine fremde Adresse davor wird übersprungen"); + + /* DIE GEGENPROBE, DIE ZÄHLT. "tiktok.com.beispiel.de" enthält + "tiktok.com" -- wer mit einer Zeichenkettensuche arbeitet, ruft + einen fremden Server auf. */ + ok(linkAusText("https://tiktok.com.beispiel.de/t/X/") === null, + "tiktok.com.beispiel.de wird NICHT genommen"); + ok(istTikTok("https://tiktok.com.beispiel.de/") === false, + " und istTikTok lehnt es einzeln ebenso ab"); + ok(istTikTok("https://www.tiktok.com/t/X/") === true, + " die echte Adresse dagegen nicht"); + ok(linkAusText("http://www.tiktok.com/t/X/") === null, + "und ohne Verschlüsselung gar nicht"); + ok(hostVon("https://www.tiktok.com/t/X/") === "www.tiktok.com", + "der Rechnername für die Meldung an den Menschen stimmt"); +} + +/* ===================================================================== + 2. DIE UMLEITUNG, STATION FÜR STATION + ===================================================================== */ +melde(""); +melde("=== 2. Jede Station wird einzeln geprüft ==="); + +{ + const gut = await folgeUmleitung(`${TIK}/t/gipfel`); + ok(gut.ok && gut.url.includes(`activityId=${ID_GIPFEL}`), + `der Kurzlink führt zur Kampagne (${gut.weg.length} Stationen)`); + + const weg = await folgeUmleitung(`${TIK}/t/fremd`); + ok(weg.ok === false && weg.grund === "fremd" && hostVon(weg.url) === "beispiel.de", + `eine Umleitung von TikTok weg bricht ab und nennt das Ziel (${hostVon(weg.url)})`); + + const kreis = await folgeUmleitung(`${TIK}/t/kreis`); + /* weg.length ist UMLEITUNGEN_MAX + 1: Der Weg beginnt mit der + eingegebenen Adresse, danach kommen fuenf Spruenge. Hier stand + zuerst "=== UMLEITUNGEN_MAX" -- geraten, nicht gezaehlt. */ + ok(kreis.ok === false && kreis.grund === "zu_viele" + && kreis.weg.length === UMLEITUNGEN_MAX + 1, + `eine Schleife endet nach ${UMLEITUNGEN_MAX} Spruengen (${kreis.weg.length} Stationen)`); + + ok(UMLEITUNGEN_MAX === 5 && FRIST_SEITE_MS === 12_000 && SEITE_MAX === 3 * 1024 * 1024, + `die drei Grenzen stehen: ${UMLEITUNGEN_MAX} Sprünge, ` + + `${FRIST_SEITE_MS / 1000} s, ${SEITE_MAX / 1048576} MB`); + + /* EIN EHRLICHER NAME. Kein Abruf gibt sich als Browser aus. */ + ok(gesehen.length > 0 && gesehen.every((g) => g.kennung.startsWith("DogfatherUniverse-Workspace/")), + `alle ${gesehen.length} Abrufe nennen, wer fragt`); + ok(/workspace\.dogfather-universe\.com/.test(KENNUNG) && !/Mozilla/i.test(KENNUNG), + "und die Kennung ist keine Browser-Verkleidung"); +} + +/* ===================================================================== + 3. FRIST UND GRÖSSE + ===================================================================== */ +melde(""); +melde("=== 3. Ein Server, der nicht antwortet, und einer, der zu viel schickt ==="); + +{ + /* MIT KURZER FRIST GEMESSEN, nicht mit zwölf Sekunden: Geprüft wird + der MECHANISMUS. Dass im Betrieb 12 s gelten, steht oben als + eigene Zeile -- und die Route selbst wird unten noch einmal gegen + den stummen Server geschickt, dort mit der echten Frist. */ + const t0 = Date.now(); + const stumm = await holeSeite(`${TIK}/falcon/stumm`, { frist: 700 }); + const dauer = Date.now() - t0; + ok(stumm.ok === false && stumm.grund === "frist" && dauer < 3000, + `ein stummer Server wird nach ${dauer} ms abgebrochen (${stumm.grund})`); + + const t1 = Date.now(); + const riesig = await holeSeite(`${TIK}/falcon/riesig`, { maxBytes: 512 * 1024 }); + const dauer1 = Date.now() - t1; + ok(riesig.ok === false && riesig.grund === "zu_gross" && riesig.angesagt === false, + `5 MB ohne angesagte Größe werden beim LESEN abgebrochen (nach ${dauer1} ms)`); + + /* DIE GEGENPROBE: Derselbe Abruf unter der Grenze geht durch. Ohne + sie bewiese die Zeile darüber nur, dass irgendetwas scheitert. */ + const passt = await holeSeite(`${TIK}/falcon/home?activityId=${ID_GIPFEL}`); + ok(passt.ok === true && passt.bytes > 1_000_000 && passt.text.includes("Gipfelstürmer"), + `eine echte Seite (${Math.round(passt.bytes / 1024)} KB) kommt ganz an`); +} + +/* ===================================================================== + 4. DIE KACHEL ENTSTEHT + ===================================================================== */ +melde(""); +melde("=== 4. Aus der Einladung wird eine Agentur-Event-Kachel ==="); + +let kachelId = null; +{ + const t0 = Date.now(); + const a = await einlesen( + "Dein Creator Network lädt dich ein, an einer Kampagne teilzunehmen.\n" + + `Klicke hier, um weitere Informationen zu erhalten: ${TIK}/t/gipfel`); + const dauer = Date.now() - t0; + ok(a.code === 201 && Number.isFinite(a.j.id), + `HTTP ${a.code}, Kachel #${a.j.id}, in ${dauer} ms`); + ok(dauer < 15_000, `und das in unter 15 Sekunden (${(dauer / 1000).toFixed(1)} s)`); + kachelId = a.j.id; + ok(a.j.titel === "Gipfelstürmer" && a.j.von === "2026-10-04" && a.j.bis === "2026-10-07", + `die Antwort nennt „${a.j.titel}“, ${a.j.von} bis ${a.j.bis}`); + ok(Array.isArray(a.j.hinweise) && a.j.hinweise.length === 0, + `nichts fehlte (${(a.j.hinweise || []).length} Hinweise)`); + + const z = dbLesen("SELECT * FROM eintraege WHERE id = ?", kachelId)[0]; + ok(!!z, "der Eintrag steht in der Datenbank"); + ok(z.bereich === "agentur" && z.art === "kampagne", + `Bereich ${z.bereich}, Art ${z.art}`); + /* DER TAG IN DER ZONE DER KAMPAGNE, nicht in UTC und nicht nach der + Serverzeit. `toISOString()` ergäbe hier den 03.10. */ + ok(z.datum === "2026-10-04", + `Beginn ${z.datum} -- nicht 2026-10-03, wie UTC es ergäbe`); + ok(z.event_ende === "2026-10-07", `Ende ${z.event_ende}`); + ok(z.kampagne_zone === "Europe/Berlin", `Zone ${z.kampagne_zone}`); + ok(z.kampagne_id === ID_GIPFEL, `Nummer ${z.kampagne_id}`); + ok(z.kampagne_quelle === `${TIK}/t/gipfel`, + "der eingefügte Link steht unverändert dabei"); + ok(!!z.kampagne_geholt_am && Math.abs(Date.parse(z.kampagne_geholt_am) - Date.now()) < 120_000, + "mit Zeitstempel"); + ok(z.nur_leitung === 0, "eine Kampagne ist für alle, nicht nur für die Leitung"); + ok(z.titel === "Gipfelstürmer" && (z.text || "").includes("Alpen-Vibes"), + "Titel und Einleitung stehen drin"); + + /* KEIN FELD LÄNGER ALS DAS FORMULAR ERLAUBT. Sonst entstünde eine + Kachel, die DogFather öffnen, aber nicht speichern kann. */ + for (const feld of ["text", "event_aufgaben", "event_regeln"]) { + ok((z[feld] || "").length <= 6000, + ` ${feld}: ${(z[feld] || "").length} Zeichen (höchstens 6000)`); + } + ok((z.event_regeln || "").includes("Der Regeltext war länger"), + "und wo gekürzt wurde, sagt die Kachel es selbst"); + + /* DER AUSZUG BEWAHRT, WAS NICHT INS FELD PASST. */ + const auszug = JSON.parse(z.kampagne_daten || "{}"); + ok(auszug.preise?.length === 6 && auszug.geschenke?.length === 5 + && auszug.klapptexte?.length === 2, + `die Rohdaten haben alles: ${auszug.preise?.length} Preisgruppen, ` + + `${auszug.geschenke?.length} Geschenke, ${auszug.klapptexte?.length} Klapptexte`); +} + +/* ===================================================================== + 5. FÜNF HAKEN ZUM ABHAKEN + ===================================================================== */ +melde(""); +melde("=== 5. Die Aufgaben werden zu Haken ==="); + +{ + const b = JSON.parse((await roh("/workspace/api/bereich/agentur", { keks: keksDogi })).text); + const k = (b.eintraege || []).find((e) => e.id === kachelId); + ok(!!k, "die Kachel steht auf dem Agentur-Brett"); + ok(Array.isArray(k.event_punkte) && k.event_punkte.length === 5, + `${(k.event_punkte || []).length} Haken`); + ok((k.event_punkte || [])[0]?.text === "1 Diamant = 1 Punkt", + `der erste heißt „${(k.event_punkte || [])[0]?.text}“`); + ok((k.event_punkte || []).every((p) => !/^[-–—•·*▪◦‣>+]/.test(p.text)), + "keiner trägt ein Aufzählungszeichen, das niemand getippt hat"); + + /* EIN HAKEN BLEIBT STEHEN. Abnahmepunkt 2 des Bauplans. */ + const s = k.event_punkte[0].schluessel; + const setzen = await roh(`/workspace/api/eintrag/${kachelId}/punkt/${s}`, + { methode: "PUT", rumpf: { an: true }, keks: keksLuna }); + ok(setzen.code === 200, `ein Creator setzt einen Haken (HTTP ${setzen.code})`); + const b2 = JSON.parse((await roh("/workspace/api/bereich/agentur", { keks: keksLuna })).text); + const k2 = (b2.eintraege || []).find((e) => e.id === kachelId); + /* DAS FELD HEISST `meiner`, nicht `an` (berichtigt 07.10.2026). + Abgeschrieben aus workspace-bereiche.js statt geraten -- hier + stand zuerst `an`, und die Pruefung meldete einen Fehler an einer + Stelle, an der keiner war. */ + ok((k2?.event_punkte || []).find((p) => p.schluessel === s)?.meiner === true, + "und er steht beim nächsten Laden noch da"); +} + +/* ===================================================================== + 6. DER WICHTIGSTE NACHWEIS: DAS SCHEMA, NICHT DAS WÖRTERBUCH + ===================================================================== */ +melde(""); +melde("=== 6. „1 schenkende Person“ steht NICHT in der Kachel ==="); + +{ + const z = dbLesen("SELECT * FROM eintraege WHERE id = ?", kachelId)[0]; + const alles = [z.titel, z.text, z.event_aufgaben, z.event_regeln, z.kampagne_daten] + .join("\n"); + /* GEGENPROBE ZUERST: Der Satz steht sehr wohl in der Seite, die wir + gelesen haben. Ohne diese Zeile wäre die nächste auch dann grün, + wenn gar nichts gelesen worden wäre. */ + ok(ROH_GIPFEL.includes("schenkende Person"), + "Gegenprobe: in der gelesenen Seite kommt er vor"); + ok(alles.length > 3000 && !alles.includes("schenkende"), + `in ${alles.length} Zeichen Kachelinhalt kommt er nicht vor`); +} + +/* ===================================================================== + 7. DAS BANNER LIEGT BEI UNS + ===================================================================== */ +melde(""); +melde("=== 7. Das Banner wird geholt, nicht verlinkt ==="); + +{ + const dat = dbLesen("SELECT * FROM dateien WHERE eintrag_id = ?", kachelId); + ok(dat.length === 1, `${dat.length} Datei am Eintrag`); + ok(dat[0]?.typ === "image/png" && dat[0]?.groesse > 1000, + `als ${dat[0]?.typ}, ${dat[0]?.groesse} Bytes`); + ok(existsSync(join(ordner, "dateien", dat[0]?.name_datei || "x")), + "und sie liegt wirklich auf der Platte"); + ok(!(dat[0]?.name_datei || "").startsWith("http"), + "gespeichert ist ein Dateiname, keine fremde Adresse"); + + /* SICHTBAR AUCH FÜR EINEN CREATOR. Am 03.10.2026 war genau das + schon einmal ein 404. */ + /* DER LESWEG IST /api/dateien/:id/bild (berichtigt 07.10.2026). + Hier stand /api/bereich/agentur/:id/bild -- das ist der Weg zum + HOCHLADEN (POST), und als GET gibt es ihn nicht. Abgeschrieben aus + workspace-bereiche.js statt geraten. */ + const bild = await roh(`/workspace/api/dateien/${dat[0]?.id}/bild`, { keks: keksLuna }); + ok(bild.code === 200, `ein Creator bekommt das Banner (HTTP ${bild.code})`); + /* GEGENPROBE: ohne Anmeldung nicht. Sonst bewiese die Zeile darueber + nur, dass irgendeine Datei ausgeliefert wird. */ + const ohne = await roh(`/workspace/api/dateien/${dat[0]?.id}/bild`); + ok(ohne.code === 401 || ohne.code === 403, + `Gegenprobe: ohne Anmeldung nicht (HTTP ${ohne.code})`); + + /* GEGENPROBE: Eine Textdatei mit `content-type: image/png` wird + abgelehnt -- die Byteprüfung glaubt dem fremden Server nicht. */ + const t = await einlesen(`Einladung: ${TIK}/t/textbanner`); + ok(t.code === 201, `eine Kampagne mit Textdatei als Banner entsteht trotzdem (HTTP ${t.code})`); + const datT = dbLesen("SELECT * FROM dateien WHERE eintrag_id = ?", t.j.id); + ok(datT.length === 0, `aber ohne Datei (${datT.length})`); + ok((t.j.hinweise || []).some((h) => h.includes("Banner")), + "und die Kachel sagt, dass das Banner fehlt"); + + /* DIE SCHRANKE GEGEN FREMDE ZIELE. */ + ok(bildAdresseErlaubt("https://p16.tiktokcdn.com/x.png") === true + && bildAdresseErlaubt("https://169.254.169.254/latest/") === false + && bildAdresseErlaubt("http://p16.tiktokcdn.com/x.png") === false, + "eine Banneradresse ins eigene Netz würde abgelehnt"); +} + +/* ===================================================================== + 8. DIESELBE KAMPAGNE NUR EINMAL + ===================================================================== */ +melde(""); +melde("=== 8. Zwei Einladungen zur selben Kampagne ergeben EINE Kachel ==="); + +{ + const vorher = dbLesen("SELECT COUNT(*) AS n FROM eintraege WHERE kampagne_id = ?", ID_GIPFEL)[0].n; + const zweit = await einlesen(`Noch eine Einladung: ${TIK}/t/gipfel-zweiter-versand`); + ok(zweit.code === 200 && zweit.j.schon === true && zweit.j.id === kachelId, + `ein ZWEITER Kurzlink auf dieselbe Kampagne: HTTP ${zweit.code}, ` + + `dieselbe Kachel #${zweit.j.id}`); + const nachher = dbLesen("SELECT COUNT(*) AS n FROM eintraege WHERE kampagne_id = ?", ID_GIPFEL)[0].n; + ok(vorher === 1 && nachher === 1, `und es blieb bei ${nachher} Eintrag`); + + /* WELCHE DER ZWEI SICHERUNGEN HAT GEGRIFFEN? (nachgetragen 07.10.2026) + Es gibt zwei: die Pruefung im Code (faengt den Normalfall) und den + eindeutigen Index (faengt den Wettlauf). Beide antworten gleich -- + und genau daran ist eine Gegenprobe gescheitert: Nimmt man die + Pruefung im Code heraus, faengt der Index es auf, die Antwort ist + dieselbe, und die Pruefung blieb gruen. + Das war kein Schaden, aber ein blinder Fleck. Unterschieden wird + deshalb am Protokolltext: Der Index vermerkt "Wettlauf, Index". + Steht das im Normalfall da, fehlt die Pruefung im Code. */ + const wie = dbLesen("SELECT detail FROM protokoll WHERE aktion = 'kampagne_schon_da'" + + " ORDER BY id DESC LIMIT 1")[0]; + ok(/-> Eintrag [0-9]+$/.test((wie?.detail || "").trim()), + `gefangen hat es die Pruefung im Code: „${wie?.detail}“`); + ok(!/Wettlauf/.test(wie?.detail || ""), + " nicht erst der Index -- sonst waere die Pruefung im Code wirkungslos"); + + /* GEGENPROBE: Eine ANDERE Kampagne entsteht sehr wohl. */ + const andere = await einlesen(`Einladung: ${TIK}/t/beendet`); + ok(andere.code === 201 && andere.j.id !== kachelId, + `Gegenprobe: eine andere Kampagne wird angelegt (#${andere.j.id}, ` + + `„${andere.j.titel}“)`); + /* Und sie kommt aus der ZWEITEN Schemaquelle -- eine beendete + Kampagne liefert das Interaktionsschema nicht mehr. */ + const az = dbLesen("SELECT kampagne_daten FROM eintraege WHERE id = ?", andere.j.id)[0]; + ok(JSON.parse(az.kampagne_daten).gelesen_aus === "value.schema", + " gelesen aus value.schema, weil sie beendet ist"); + + /* DER EINDEUTIGE INDEX fängt den Wettlauf, den der Code nicht sieht. */ + const idx = dbLesen("SELECT sql FROM sqlite_master WHERE name = 'idx_eintraege_kampagne'"); + ok(idx.length === 1 && /UNIQUE/i.test(idx[0].sql) && /kampagne_id IS NOT NULL/i.test(idx[0].sql), + "und ein eindeutiger Index steht dahinter, nicht nur die Prüfung im Code"); +} + +/* ===================================================================== + 9. DREI VERSCHIEDENE, VERSTÄNDLICHE MELDUNGEN + ===================================================================== */ +melde(""); +melde("=== 9. Was schiefgehen kann, wird verständlich gesagt ==="); + +{ + const vorher = dbLesen("SELECT COUNT(*) AS n FROM eintraege")[0].n; + + const ohneLink = await einlesen("Hier steht gar keine Adresse drin."); + ok(ohneLink.code === 400 && /keine TikTok-Adresse/i.test(ohneLink.j.fehler || ""), + `kein Link: HTTP ${ohneLink.code} — „${ohneLink.j.fehler}“`); + + const fremd = await einlesen(`Klick: ${TIK}/t/fremd`); + ok(fremd.code === 400 && /beispiel\.de/.test(fremd.j.fehler || ""), + `fremdes Ziel: HTTP ${fremd.code} — „${fremd.j.fehler}“`); + + const kaputt = await einlesen(`Klick: ${TIK}/t/kaputt`); + ok(kaputt.code === 502 && /__MODERN_ROUTER_DATA__/.test(kaputt.j.fehler || ""), + `umgebaute Seite: HTTP ${kaputt.code} — „${(kaputt.j.fehler || "").slice(0, 90)}…“`); + + const ohneEnde = await einlesen(`Klick: ${TIK}/t/ohneende`); + ok(ohneEnde.code === 502 && /Ende/.test(ohneEnde.j.fehler || ""), + `ohne Enddatum: HTTP ${ohneEnde.code} — „${(ohneEnde.j.fehler || "").slice(0, 90)}…“`); + + /* DIE DREI MELDUNGEN SIND WIRKLICH VERSCHIEDEN. Eine Prüfung, die + nur „es kam ein Fehler“ sagt, wäre auch grün, wenn überall + dasselbe stünde. */ + const saetze = new Set([ohneLink.j.fehler, fremd.j.fehler, + kaputt.j.fehler, ohneEnde.j.fehler]); + ok(saetze.size === 4, `${saetze.size} verschiedene Sätze für vier Fälle`); + ok([...saetze].every((s) => s && !/\b50\d\b/.test(s) && s.length > 25), + "keiner davon sagt „Fehler 502“, alle sind ganze Sätze"); + + const nachher = dbLesen("SELECT COUNT(*) AS n FROM eintraege")[0].n; + ok(vorher === nachher, + `und keiner der vier hat eine halbe Kachel hinterlassen (${vorher} = ${nachher})`); + + /* OHNE PREISE: 201 MIT HINWEIS, nicht 502. Unvollständig ist etwas + anderes als falsch. */ + const op = await einlesen(`Klick: ${TIK}/t/ohnepreise`); + ok(op.code === 201 && (op.j.hinweise || []).some((h) => h.includes("Preise")), + `ohne Preise: HTTP ${op.code} mit Hinweis „${(op.j.hinweise || [])[0]}“`); + const opZ = dbLesen("SELECT event_regeln FROM eintraege WHERE id = ?", op.j.id)[0]; + ok(/Bitte nachtragen/.test(opZ.event_regeln || ""), + "und die Kachel selbst sagt, dass etwas nachzutragen ist"); +} + +/* ===================================================================== + 10. EIN STUMMER SERVER HÄNGT DIE ROUTE NICHT AUF + ===================================================================== */ +melde(""); +melde("=== 10. Die echte Frist am echten Weg ==="); + +{ + const t0 = Date.now(); + const a = await einlesen(`Klick: ${TIK}/t/stumm`); + const dauer = Date.now() - t0; + ok(a.code === 502 && /12 s/.test(a.j.fehler || ""), + `HTTP ${a.code} nach ${(dauer / 1000).toFixed(1)} s — „${a.j.fehler}“`); + ok(dauer >= 11_000 && dauer < 20_000, + `die Frist greift bei 12 s, nicht früher und nicht nie (${(dauer / 1000).toFixed(1)} s)`); +} + +/* ===================================================================== + 11. WER DARF DAS + ===================================================================== */ +melde(""); +melde("=== 11. Kampagnen anlegen dürfen DogFather und die Manager ==="); + +{ + const creator = await einlesen(`Klick: ${TIK}/t/gipfel`, keksLuna); + ok(creator.code === 403, `ein Creator darf nicht (HTTP ${creator.code})`); + ok(/DogFather und die Manager/.test(creator.j.fehler || ""), + `und erfährt warum: „${creator.j.fehler}“`); + + const ohne = await roh("/workspace/api/kampagne/einlesen", { rumpf: { text: "x" } }); + ok(ohne.code === 401, `ohne Anmeldung gar nicht (HTTP ${ohne.code})`); + + /* GEGENPROBE: Eine Managerin darf -- sonst bewiese die Zeile oben + nur, dass die Route überhaupt ablehnt. */ + const mara = await einlesen(`Klick: ${TIK}/t/gipfel`, keksMara); + ok(mara.code === 200 && mara.j.schon === true, + `eine Managerin darf (HTTP ${mara.code}, Kampagne war schon da)`); + + /* UND DIE OBERFLÄCHE FRAGT DIESELBE REGEL. Keine Rollenliste im + Browser -- am 10.09.2026 widersprachen sich zwei solche Listen + drei Zeilen auseinander. */ + const ichDogi = JSON.parse((await roh("/workspace/api/ich", { keks: keksDogi })).text); + const ichLuna = JSON.parse((await roh("/workspace/api/ich", { keks: keksLuna })).text); + const ichMara = JSON.parse((await roh("/workspace/api/ich", { keks: keksMara })).text); + ok(ichDogi.darf_kampagne_einlesen === true && ichMara.darf_kampagne_einlesen === true + && ichLuna.darf_kampagne_einlesen === false, + "/api/ich sagt der Oberfläche dasselbe wie die Schranke der Route"); + + /* FREMDE HERKUNFT. Die Wache steht in index.js, VOR diesem Router. */ + const fremd = await new Promise((f, s) => { + const daten = Buffer.from(JSON.stringify({ text: "x" })); + const a = request({ + host: "127.0.0.1", port: PORT, path: "/workspace/api/kampagne/einlesen", + method: "POST", + headers: { Host: HOST, Origin: "https://boeswillig.example", Cookie: keksDogi, + "Content-Type": "application/json", "Content-Length": daten.length }, + }, (r) => { r.resume(); r.on("end", () => f({ code: r.statusCode })); }); + a.on("error", s); a.write(daten); a.end(); + }); + ok(fremd.code === 403, `eine Anfrage von fremder Seite wird abgewiesen (HTTP ${fremd.code})`); +} + +/* ===================================================================== + 12. DAS ANDERE HAUS + ===================================================================== */ +melde(""); +melde("=== 12. Team Dogi bleibt unberührt ==="); + +{ + const crewVorher = dbLesen("SELECT COUNT(*) AS n FROM eintraege WHERE haus = 'crew'")[0].n; + ok(crewVorher === 1, + `es gibt ${crewVorher} Crew-Eintrag -- gezählt wird über etwas, nicht über nichts`); + + const alle = dbLesen("SELECT haus, COUNT(*) AS n FROM eintraege" + + " WHERE kampagne_id IS NOT NULL GROUP BY haus"); + ok(alle.length === 1 && alle[0].haus === "agentur", + `alle ${alle[0]?.n} Kampagnen liegen im Haus „${alle[0]?.haus}“`); + + /* VON DER CREW-ADRESSE AUS: nicht sichtbar. */ + const crewBrett = await roh("/workspace/api/bereich/agentur", + { keks: keksDogiCrew, wirt: CREW_HOST }); + const sichtbar = JSON.parse(crewBrett.text || "{}").eintraege || []; + ok(!sichtbar.some((e) => e.id === kachelId), + `die Kampagne erscheint auf der Crew-Adresse NICHT (${sichtbar.length} Einträge dort)`); + + /* GEGENPROBE: Auf der Agentur-Adresse sehr wohl. */ + const wsBrett = JSON.parse((await roh("/workspace/api/bereich/agentur", { keks: keksDogi })).text); + ok((wsBrett.eintraege || []).some((e) => e.id === kachelId), + "Gegenprobe: auf der Agentur-Adresse schon"); + + const crewNachher = dbLesen("SELECT COUNT(*) AS n FROM eintraege WHERE haus = 'crew'")[0].n; + ok(crewVorher === crewNachher, + `und die Zahl der Crew-Einträge ist unverändert (${crewVorher} = ${crewNachher})`); +} + +/* ===================================================================== + 13. DAS PROTOKOLL + ===================================================================== */ +melde(""); +melde("=== 13. Jeder Versuch steht im Protokoll, nicht nur der Erfolg ==="); + +{ + const p = dbLesen("SELECT aktion, COUNT(*) AS n FROM protokoll" + + " WHERE aktion LIKE 'kampagne%' GROUP BY aktion ORDER BY aktion"); + const nach = Object.fromEntries(p.map((z) => [z.aktion, z.n])); + ok((nach.kampagne_eingelesen || 0) >= 3, + `${nach.kampagne_eingelesen || 0} erfolgreiche Einlesevorgänge`); + ok((nach.kampagne_gescheitert || 0) >= 5, + `${nach.kampagne_gescheitert || 0} gescheiterte -- genau die, die sonst spurlos wären`); + ok((nach.kampagne_schon_da || 0) >= 2, + `${nach.kampagne_schon_da || 0}× „war schon da“`); + + const einer = dbLesen("SELECT detail FROM protokoll WHERE aktion = 'kampagne_gescheitert'" + + " ORDER BY id DESC LIMIT 1")[0]; + ok((einer?.detail || "").length > 5, + `und der Grund steht dabei: „${(einer?.detail || "").slice(0, 70)}“`); +} + +/* ===================================================================== + 14. DIE BREMSE + ===================================================================== */ +melde(""); +melde("=== 14. Zwanzig in einer Stunde, dann ist Schluss ==="); + +{ + /* EINE EIGENE PERSON dafür: Die Bremse zählt je Person, und die + Versuche der Abschnitte darüber gehören DogFather. */ + const { KAMPAGNEN_JE_STUNDE } = await import("./workspace-kampagne.js"); + ok(KAMPAGNEN_JE_STUNDE === 20, `die Grenze steht bei ${KAMPAGNEN_JE_STUNDE}`); + + let letzterGute = null; + for (let i = 1; i <= KAMPAGNEN_JE_STUNDE; i++) { + letzterGute = await einlesen(`Klick: ${TIK}/t/gipfel`, keksBrem); + if (letzterGute.code === 429) break; + } + ok(letzterGute && letzterGute.code !== 429, + `der ${KAMPAGNEN_JE_STUNDE}. Versuch geht noch durch (HTTP ${letzterGute?.code})`); + + const zuviel = await einlesen(`Klick: ${TIK}/t/gipfel`, keksBrem); + ok(zuviel.code === 429, `der ${KAMPAGNEN_JE_STUNDE + 1}. wird gebremst (HTTP ${zuviel.code})`); + ok(/Minute/.test(zuviel.j.fehler || ""), + `und sagt, wann es weitergeht: „${zuviel.j.fehler}“`); + + /* GEGENPROBE: Eine andere Person ist NICHT gebremst. Die Bremse ist + persönlich, keine Sperre für alle. */ + const andere = await einlesen(`Klick: ${TIK}/t/gipfel`, keksMara); + ok(andere.code !== 429, `eine andere Person darf weiter (HTTP ${andere.code})`); + + /* UND DIE BREMSE VERLÄNGERT SICH NICHT SELBST: Ihre eigene Meldung + zählt nicht mit, sonst käme man nie wieder heraus. */ + const bremsZaehler = dbLesen("SELECT COUNT(*) AS n FROM protokoll" + + " WHERE aktion = 'kampagne_bremse'")[0].n; + ok(bremsZaehler >= 1, `${bremsZaehler} Bremsvorgänge stehen im Protokoll`); + const gezaehlt = dbLesen("SELECT COUNT(*) AS n FROM protokoll" + + " WHERE aktion IN ('kampagne_eingelesen','kampagne_gescheitert','kampagne_schon_da')" + + " AND person_id = (SELECT id FROM personen WHERE name = 'Bremser')")[0].n; + ok(gezaehlt === KAMPAGNEN_JE_STUNDE, + `gezählt werden genau ${gezaehlt} von ${KAMPAGNEN_JE_STUNDE} Versuchen` + + " -- die Bremsmeldungen selbst nicht"); +} + +/* ===================================================================== + SCHLUSS + ===================================================================== */ +melde(""); +melde(`${abrufe} Abrufe beim nachgebauten Dienst, keiner im echten Netz.`); +melde(`${geprueft} Pruefungen, ${fehler} Fehler`); +melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG"); + +tikDienst.close(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Rest */ } +process.exit(fehler ? 1 : 0); diff --git a/server/workspace-anleitung.js b/server/workspace-anleitung.js index 1f0b4693..98892cf8 100644 --- a/server/workspace-anleitung.js +++ b/server/workspace-anleitung.js @@ -33,7 +33,7 @@ ===================================================================== */ import express from "express"; -import { db, sitzungLesen, AUSSEN_ROLLEN, bereicheFuer } from "./workspace.js"; +import { db, sitzungLesen, AUSSEN_ROLLEN, bereicheFuer, tagLokalVon } from "./workspace.js"; import { darfSeite } from "./rechte.js"; import { STUFEN, PFLEGE_ROLLEN, fassungenFuer, @@ -191,7 +191,12 @@ function stufenkarte(person) { if (!erstellt) return null; const t = Date.parse(erstellt); if (!Number.isFinite(t)) return null; - return new Date(t + tage * 86400_000).toISOString().slice(0, 10); + /* ORTSZEIT, NICHT UTC (berichtigt 07.10.2026, gefunden von + pruef-struktur). Hier stand `.toISOString().slice(0, 10)`. + Wer zwischen 00:00 und 02:00 deutscher Zeit beitritt, liegt + in UTC noch im Vortag -- die Seite nannte dann einen Termin, + der einen Tag zu frueh war. */ + return tagLokalVon(t, tage); }; return { jetzt, diff --git a/server/workspace-kampagne.js b/server/workspace-kampagne.js new file mode 100644 index 00000000..e79a0e8f --- /dev/null +++ b/server/workspace-kampagne.js @@ -0,0 +1,509 @@ +/* ===================================================================== + EINE KAMPAGNEN-KACHEL AUS EINEM TIKTOK-LINK (07.10.2026) — Etappe 3 + + Haus: workspace.dogfather-universe.com (Agentur / Spicy Media). + Team Dogi bleibt unberührt — `hausFuerNeuenEintrag` entscheidet das, + nicht diese Datei. + + Der Auftrag in einem Satz: Im Agentur-Bereich gibt es ein Feld, in + das die GANZE Einladungsnachricht eingefügt wird — und daraus + entsteht eine fertige Agentur-Event-Kachel mit Titel, Zeitraum, + Banner, Aufgaben zum Abhaken, Regeln, Ligen und Preisen. Ohne einen + einzigen Handgriff danach. + + Eingabe ist genau das, was TikTok schickt, mitsamt dem Fließtext: + + Dein Creator Network lädt dich ein, an einer Kampagne + teilzunehmen. Klicke hier, um weitere Informationen zu + erhalten: https://www.tiktok.com/t/ZSbqPsFa8/ + + --------------------------------------------------------------------- + HIER WIRD WENIG ERFUNDEN + + Die Kachel gibt es schon: Bereich `agentur`, Art `kampagne`, Tabelle + `eintraege` mit `event_ende`, `event_aufgaben`, `event_regeln`, + Haken über `event_punkte`, Banner über `dateien.eintrag_id`. Diese + Datei füllt vorhandene Felder — sie baut keine neuen. + + `workspace-bereiche.js` wird NICHT angefasst. Fiele beim Bauen auf, + dass doch etwas nötig ist, wäre das ein Zeichen, dass die Abbildung + nicht passt — dann lieber die Abbildung ändern als die + Bereichslogik. + + Gelesen wird in `kampagne-lesen.mjs` (kein Netz, keine Datenbank), + geholt wird mit `helfer-tiktok.mjs` (kein Schreiben). Hier steht nur + die Reihenfolge, die Rechtefrage und das Schreiben. + + --------------------------------------------------------------------- + DIE REIHENFOLGE — und warum sie so ist + + 1. Link aus dem Text holen ohne Netz, ohne Datenbank + 2. Bremse VOR dem ersten Abruf nach draußen + 3. Umleitung Station für Station jede Zwischenadresse geprüft + 4. Kampagnennummer erst aus der Adresse (billig) + 5. Dublettenprüfung spart die ganze Seite + 6. Seite holen 12 s, 3 MB beim Lesen + 7. Lesen drei Ausgänge + 8. Banner herunterladen eigene Frist, Byteprüfung + 9. Schreiben in EINER Transaktion + 10. Protokoll + + Die Bremse steht an Stelle 2 und nicht an Stelle 9: Eine Bremse, die + erst nach dem Abruf greift, bremst den fremden Server nicht. + + Die Dublettenprüfung steht zweimal da — einmal hier (Stelle 5, fängt + den Normalfall) und einmal als eindeutiger Index in der Datenbank + (fängt den Wettlauf zweier gleichzeitiger Aufrufe). Beides, nicht + eins davon. + + --------------------------------------------------------------------- + JEDER FEHLSCHLAG WIRD PROTOKOLLIERT, nicht nur der Erfolg + + Sonst lässt sich auf „warum kam meine Kampagne nicht an?" nicht + einmal sagen, OB der Versuch den Server erreicht hat. Genau diese + Reihenfolge hat am 06.09.2026 im Shop zwei Stunden gekostet: Die + Frage „warum kommt die Mail nicht an?" setzt voraus, dass „wurde sie + überhaupt abgeschickt?" schon beantwortet ist. + + Aufrufen: POST /workspace/api/kampagne/einlesen { text } + ===================================================================== */ + +import express from "express"; +import { randomBytes } from "node:crypto"; +import { join } from "node:path"; +import { mkdirSync, writeFileSync, unlinkSync } from "node:fs"; +import { + db, protokolliere, echteIp, sitzungLesen, istLeitung, + DATEN_ORDNER, hausFuerNeuenEintrag } from "./workspace.js"; +import { bildArtVon } from "./workspace-dateien.js"; +import { + linkAusText, folgeUmleitung, holeSeite, holeMitFrist, bildAdresseErlaubt, + hostVon, FRIST_SEITE_MS } from "./helfer-tiktok.mjs"; +import { leseKampagne, regelnText, aufgabenText, KampagneUnlesbar } + from "./kampagne-lesen.mjs"; + +export const kampagneRouter = express.Router(); + +const ORDNER = join(DATEN_ORDNER, "dateien"); +try { mkdirSync(ORDNER, { recursive: true }); } catch { /* gibt es schon */ } + +/* WIE OFT IN EINER STUNDE? Wir lesen eine Seite, zu der wir eingeladen + wurden, einmal je Link, ausgelöst durch einen Menschen. Zwanzig ist + reichlich für einen Menschen und wenig für ein Skript. Die Zahl + steht hier und nicht in der Route, damit sie an genau einer Stelle + steht -- die Prüfung liest sie mit. Vorbild: BEITRAEGE_JE_STUNDE + in workspace-bereiche.js. */ +export const KAMPAGNEN_JE_STUNDE = 20; + +/* Das Banner. Dieselbe Grenze wie beim Video-Cover: Was von draußen + kommt, ist von draußen. */ +const BANNER_MAX = 4 * 1024 * 1024; + +/* Ab wann drängt es? Der Bauplan: „hoch", wenn das Ende weniger als + sieben Tage entfernt ist. Gerechnet, nicht geraten -- und hier, nicht + im Leser: Eine Lesefunktion, die die Wanduhr befragt, liefert zu + verschiedenen Zeiten verschiedene Ergebnisse auf dieselbe Eingabe. */ +const DRAENGT_AB_TAGEN = 7; + +/* SO VIEL FASST EIN TEXTFELD DER KACHEL. Nicht hier erfunden: + TEXT_MAX in workspace-bereiche.js, und die PUT-Route dort lehnt + mehr mit "Die Regeln sind zu lang." ab. + + WARUM DAS UEBERHAUPT EINE FRAGE IST: Der Regeltext von + "Gipfelstuermer" ist gemessen 7474 Zeichen lang -- Geschenke, sechs + Preisgruppen, zwei Klapptexte mit Ligen und Teilnahmebedingungen. + Wer das ungekuerzt hineinschreibt, baut eine Kachel, die DogFather + OEFFNEN, aber nicht SPEICHERN kann: Er aendert ein Wort, drueckt + Speichern und bekommt eine Fehlermeldung ueber etwas, das er nie + getippt hat. Ein Formular, das der Server ablehnt, laesst den + Menschen davor sich selbst fuer den Fehler halten. */ +const FELD_MAX = 6000; + +/** Kuerzt an einer ABSCHNITTSGRENZE und sagt, dass gekuerzt wurde. + * + * Mitten im Wort abzuschneiden waere das Schlimmere von zweien: Es + * sieht nicht nach einer Grenze aus, sondern nach einem Fehler beim + * Einlesen -- und danach sucht jemand im Code statt im Textfeld. + * + * Verloren geht nichts: Das Gelesene steht gegliedert in + * kampagne_daten. */ +function kuerzen(text, max = FELD_MAX) { + const s = String(text || ""); + if (s.length <= max) return s; + const fussnote = "\n\n* Der Regeltext war länger als das Feld fasst und endet " + + "hier. Vollständig steht er in den Rohdaten der Kampagne."; + const platz = max - fussnote.length; + const schnitt = s.lastIndexOf("\n\n", platz); + /* Gibt es keine Abschnittsgrenze, dann die letzte Zeilengrenze -- + und erst wenn auch die fehlt, hart. */ + const bis = schnitt > platz * 0.4 ? schnitt + : (s.lastIndexOf("\n", platz) > platz * 0.4 ? s.lastIndexOf("\n", platz) : platz); + return s.slice(0, bis).trimEnd() + fussnote; +} + +const jetztIso = () => new Date().toISOString(); + +function angemeldet(req, res, next) { + const person = sitzungLesen(req); + if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); + req.person = person; + next(); +} +kampagneRouter.use("/workspace/api/kampagne", angemeldet); + +/** Kurz und für Menschen. Steht im Protokoll, nicht auf dem Bildschirm. */ +const kurz = (s, n = 180) => String(s ?? "").slice(0, n); + +/** Die Kampagnennummer aus einer Adresse -- oder null. + * `new URL()`, kein regulärer Ausdruck: Ein Muster, das `activityId=` + * irgendwo im Text sucht, findet es auch in einem Pfadstück. */ +export function nummerAusAdresse(roh) { + try { + const n = new URL(String(roh)).searchParams.get("activityId"); + return n && /^\d{6,25}$/.test(n) ? n : null; + } catch { return null; } +} + +/** Wie viele Einlesevorgänge hatte diese Person in der letzten Stunde? + * + * GEZÄHLT WIRD IM PROTOKOLL, nicht in `eintraege`. Ein Versuch, der + * scheitert, erzeugt keinen Eintrag -- wer Einträge zählt, bremst + * genau den nicht, der in einer Schleife hängt und bei jedem Versuch + * eine fremde Seite abruft. Gezählt werden deshalb VERSUCHE, und das + * Protokoll ist die Stelle, an der beide Ausgänge stehen. + * + * UND "kampagne_schon_da" GEHOERT DAZU (nachgetragen 07.10.2026, + * gefunden von der Pruefung). Zuerst standen hier nur "eingelesen" + * und "gescheitert". Damit war die Bremse genau fuer den haeufigsten + * Missbrauchsfall blind: Wer denselben Link wieder und wieder + * einfuegt, bekommt jedes Mal "schon da" -- und loeste jedes Mal + * einen Abruf nach draussen aus, ohne gezaehlt zu werden. Gemessen: + * 21 Versuche, 0 gebremst. + * + * "kampagne_bremse" steht BEWUSST nicht in der Liste: Sonst zaehlte + * die Bremse ihre eigenen Meldungen mit und verlaengerte sich + * selbst -- man kaeme nie wieder heraus. */ +function versucheLetzteStunde(personId) { + const seit = new Date(Date.now() - 3600_000).toISOString(); + const z = db().prepare( + `SELECT COUNT(*) AS anzahl, MIN(zeitpunkt) AS aeltester + FROM protokoll + WHERE person_id = ? AND zeitpunkt >= ? + AND aktion IN ('kampagne_eingelesen','kampagne_gescheitert', + 'kampagne_schon_da')`) + .get(personId, seit); + return { anzahl: z?.anzahl || 0, aeltester: z?.aeltester || null }; +} + +/** Das Banner holen und ablegen. Gibt `{ name, groesse, art }` zurück + * -- oder `null`, wenn es kein Bild ist. + * + * WARUM HERUNTERLADEN UND NICHT VERLINKEN: Beim Video ist es bewiesen + * (`x-expires`, gemessen 48 Stunden). Bei den Kampagnenbildern ist + * nicht einmal nachweisbar, OB sie eine Frist tragen -- die Adressen + * sehen anders aus. Das ist kein Grund, es zu riskieren: Ein fremdes + * CDN kann jederzeit umziehen, und wer die Adresse gespeichert hat, + * merkt es nicht beim Bauen und nicht beim Testen, sondern Wochen + * später an leeren Kacheln. + * + * Und es ist nicht nur Haltbarkeit: Als Datei mit `eintrag_id` greift + * automatisch `darfEintragSehen()`, und das Bild ist für Creator + * sichtbar. Am 03.10.2026 war genau das schon einmal ein 404. */ +async function bannerAblegen(adresse) { + if (!adresse) return null; + /* Die Adresse kommt aus einer FREMDEN Seite. Siehe bildAdresseErlaubt. */ + if (!bildAdresseErlaubt(adresse)) return null; + let a; + try { a = await holeMitFrist(adresse, {}, { frist: FRIST_SEITE_MS }); } + catch { return null; } + if (!a.ok) return null; + const roh = a.roh; + if (!roh.length || roh.length > BANNER_MAX) return null; + /* DIESELBE BYTEPRÜFUNG WIE BEI HOCHGELADENEN DATEIEN. `dateien.typ` + wäre hier sogar noch wertloser als sonst: Den Typ behauptet dann + ein fremder Server. */ + const art = bildArtVon(roh.subarray(0, 16)); + if (!art) return null; + const endung = { "image/png": ".png", "image/jpeg": ".jpg", + "image/gif": ".gif", "image/webp": ".webp" }[art] || ".bin"; + const name = `${Date.now().toString(36)}-${randomBytes(8).toString("hex")}${endung}`; + writeFileSync(join(ORDNER, name), roh, { flag: "wx" }); + return { name, groesse: roh.length, art }; +} + +/* ===================================================================== + DIE ROUTE + ===================================================================== */ + +kampagneRouter.post("/workspace/api/kampagne/einlesen", async (req, res) => { + const person = req.person; + const ip = echteIp(req); + let abgelegt = null; + + /** Ein Fehlschlag, immer gleich: protokollieren UND antworten. + * Zwei getrennte Stellen dafür wären zwei Stellen, an denen man das + * Protokollieren vergessen kann. */ + const schiefgegangen = (status, nachricht, grund) => { + protokolliere("kampagne_gescheitert", { + personId: person.id, rolle: person.rolle, ip, + detail: kurz(grund), + }); + return res.status(status).json({ fehler: nachricht }); + }; + + try { + /* ---- Recht -------------------------------------------------- + Eine Kampagne ist eine Ansage an das ganze Team. Wer sie anlegen + darf, ist dieselbe Frage wie „wer darf ein Agentur-Event + anlegen" -- also wird die VORHANDENE Regel gefragt, nicht eine + neue erfunden. Eine zweite Rechteregel neben einer ersten ist + die Sorte Dopplung, die beim nächsten Umbau auseinanderläuft. */ + if (!istLeitung(person)) { + return schiefgegangen(403, + "Kampagnen anlegen dürfen DogFather und die Manager.", + `keine Leitung (${person.rolle})`); + } + + const text = String(req.body?.text ?? ""); + if (!text.trim()) { + return schiefgegangen(400, + "Da war kein Text. Füge die ganze Einladung ein.", + "leerer Text"); + } + + /* ---- 1. Den Link herauslösen -------------------------------- */ + const link = linkAusText(text); + if (!link) { + return schiefgegangen(400, + "In diesem Text steht keine TikTok-Adresse. " + + "Füge die Einladung noch einmal ein — der ganze Text reicht.", + `kein Link in ${text.length} Zeichen`); + } + + /* ---- 2. Bremse, VOR dem ersten Abruf nach draußen ------------ */ + { + const { anzahl, aeltester } = versucheLetzteStunde(person.id); + if (anzahl >= KAMPAGNEN_JE_STUNDE) { + const frei = new Date(Date.parse(aeltester) + 3600_000); + const min = Math.max(1, Math.ceil((frei.getTime() - Date.now()) / 60000)); + /* Hier NICHT über `schiefgegangen`: Sonst zählte die Bremse + ihre eigene Meldung mit und verlängerte sich selbst. */ + protokolliere("kampagne_bremse", { + personId: person.id, rolle: person.rolle, ip, + detail: `${anzahl} Versuche in einer Stunde`, + }); + return res.status(429).json({ + fehler: `Du hast in der letzten Stunde ${anzahl} Kampagnen eingelesen. ` + + `In ${min} ${min === 1 ? "Minute" : "Minuten"} geht es weiter.`, + }); + } + } + + /* ---- 3. Umleitung Station für Station ------------------------ */ + const weg = await folgeUmleitung(link); + if (!weg.ok) { + if (weg.grund === "fremd") { + return schiefgegangen(400, + `Dieser Link führt zu ${hostVon(weg.url)}, nicht zu TikTok.`, + `Umleitung verlässt TikTok: ${weg.url}`); + } + if (weg.grund === "zu_viele") { + return schiefgegangen(400, + "Dieser Link führt im Kreis. Bitte die Einladung noch einmal öffnen.", + `mehr als 5 Umleitungen ab ${link}`); + } + if (weg.grund === "frist") { + return schiefgegangen(502, + "TikTok hat nicht geantwortet (12 s). Bitte später noch einmal.", + `Frist bei der Umleitung ab ${link}`); + } + return schiefgegangen(502, + "TikTok war nicht erreichbar. Bitte später noch einmal.", + `Umleitung ${weg.grund}: ${weg.fehler || weg.url}`); + } + const adresse = weg.url; + + /* ---- 4./5. Kampagnennummer und Dublettenprüfung -------------- + Erst aus der ADRESSE, weil das nichts kostet: Steht sie da und + kennen wir die Kampagne schon, sparen wir die ganze Seite. + Fehlt sie dort, wird sie unten aus der Seite gelesen -- und dann + noch einmal geprüft. */ + const vorab = nummerAusAdresse(adresse); + if (vorab) { + const da = db().prepare( + "SELECT id FROM eintraege WHERE kampagne_id = ?").get(vorab); + if (da) { + protokolliere("kampagne_schon_da", { + personId: person.id, rolle: person.rolle, ip, + detail: kurz(`${vorab} -> Eintrag ${da.id}`), + }); + return res.status(200).json({ id: da.id, schon: true }); + } + } + + /* ---- 6. Die Seite holen -------------------------------------- */ + const seite = await holeSeite(adresse); + if (!seite.ok) { + if (seite.grund === "frist") { + return schiefgegangen(502, + "TikTok hat nicht geantwortet (12 s). Bitte später noch einmal.", + `Frist beim Holen von ${adresse}`); + } + if (seite.grund === "zu_gross") { + return schiefgegangen(502, + "Die Seite von TikTok war unerwartet groß und wurde abgebrochen.", + `über 3 MB (${seite.bytes}, angesagt: ${seite.angesagt})`); + } + if (seite.grund === "status") { + return schiefgegangen(502, + `TikTok hat die Seite nicht herausgegeben (${seite.status}).`, + `HTTP ${seite.status} bei ${adresse}`); + } + return schiefgegangen(502, + "TikTok war nicht erreichbar. Bitte später noch einmal.", + `Abruf ${seite.grund}: ${seite.fehler || adresse}`); + } + + /* ---- 7. Lesen ------------------------------------------------ */ + let gelesen; + try { + gelesen = leseKampagne(seite.text); + } catch (fehler) { + if (fehler instanceof KampagneUnlesbar) { + /* Die beiden Sorten werden verschieden beantwortet -- aber + beide mit 502 und beide mit dem GRUND im Klartext. Eine + Kachel ohne Zeitraum wäre eine Falschauskunft; sie behauptet + einen Termin, den sie nicht kennt. */ + const satz = fehler.art === "pflicht" + ? `Die Seite wurde gelesen, aber ${fehler.grund}. Bitte von Hand anlegen.` + : `Die Seite konnte nicht gelesen werden: ${fehler.grund}. ` + + "Vermutlich hat TikTok die Seite umgebaut."; + return schiefgegangen(502, satz, `${fehler.art}: ${fehler.grund}`); + } + throw fehler; + } + + /* Die Nummer aus der Seite ist die verbindliche. Falls die Adresse + keine trug, wird jetzt zum ersten Mal auf Dubletten geprüft. */ + const nummer = gelesen.pflicht.id; + if (!vorab) { + const da = db().prepare( + "SELECT id FROM eintraege WHERE kampagne_id = ?").get(nummer); + if (da) { + protokolliere("kampagne_schon_da", { + personId: person.id, rolle: person.rolle, ip, + detail: kurz(`${nummer} -> Eintrag ${da.id} (aus der Seite)`), + }); + return res.status(200).json({ id: da.id, schon: true }); + } + } + + /* ---- 8. Banner ------------------------------------------------ */ + abgelegt = await bannerAblegen(gelesen.kuer.bannerUrl); + const hinweise = [...gelesen.hinweise]; + if (gelesen.kuer.bannerUrl && !abgelegt) { + hinweise.push("Das Banner konnte nicht geholt werden."); + } + + /* ---- 9. Schreiben, in EINER Transaktion ---------------------- + Eintrag und Banner gehören zusammen. Bricht das zweite INSERT + ab, darf das erste nicht stehen bleiben: Eine Kachel mit einem + Bild, das es nicht gibt, sieht aus wie ein Fehler im Anzeigen -- + und man sucht ihn dort. */ + const jetzt = jetztIso(); + const tageBisEnde = Math.round( + (Date.parse(`${gelesen.pflicht.ende}T12:00:00Z`) - Date.parse(jetzt)) / 86400_000); + const dringlichkeit = tageBisEnde < DRAENGT_AB_TAGEN ? "hoch" : "mittel"; + + const d = db(); + let eintragId = null; + let meine = false; + try { + try { d.exec("BEGIN IMMEDIATE"); meine = true; } catch { meine = false; } + const { lastInsertRowid } = d.prepare(` + INSERT INTO eintraege + (bereich, art, titel, text, datum, event_ende, + event_aufgaben, event_regeln, dringlichkeit, status, nur_leitung, + erstellt, erstellt_von, haus, + kampagne_id, kampagne_quelle, kampagne_geholt_am, kampagne_zone, + kampagne_daten) + VALUES ('agentur','kampagne', ?,?,?,?, ?,?,?, 'offen', 0, ?,?,?, ?,?,?,?,?)`) + .run( + gelesen.pflicht.titel.slice(0, 160), + kuerzen(gelesen.kuer.einleitung || ""), + /* Der TAG der Kampagne in IHRER Zone -- nicht heuteLokal(). + Eine Kampagne, die morgen beginnt, steht sonst heute in der + Liste. */ + gelesen.pflicht.start, + gelesen.pflicht.ende, + kuerzen(aufgabenText(gelesen.kuer)), + kuerzen(regelnText(gelesen.kuer, hinweise)), + dringlichkeit, + jetzt, person.id, + /* NICHT von Hand auf "agentur" gesetzt: Das Haus entscheidet + die Adresse, über die jemand hereinkommt. Wer es hier + festschreibt, baut die Trennung ein zweites Mal -- und + zwei Fassungen derselben Regel laufen auseinander. */ + hausFuerNeuenEintrag(person, "agentur"), + nummer, link, jetzt, gelesen.pflicht.zone, + JSON.stringify(gelesen.auszug)); + eintragId = Number(lastInsertRowid); + + if (abgelegt) { + d.prepare(` + INSERT INTO dateien (name_original, name_datei, groesse, typ, status, notiz, + hochgeladen_von, erstellt, eintrag_id, haus) + VALUES (?,?,?,?, 'entwurf', NULL, ?,?,?,?)`) + .run(`banner-${nummer}${abgelegt.name.slice(abgelegt.name.lastIndexOf("."))}`, + abgelegt.name, abgelegt.groesse, abgelegt.art, + person.id, jetzt, eintragId, person.haus || null); + abgelegt = null; /* ab hier gehört die Datei dem Eintrag */ + } + if (meine) d.exec("COMMIT"); + } catch (fehler) { + if (meine) { try { d.exec("ROLLBACK"); } catch { /* schon zurück */ } } + /* Der eindeutige Index hat zugeschlagen: zwei gleichzeitige + Aufrufe für dieselbe Kampagne. Das ist kein Fehler, das ist + genau sein Zweck -- und die Antwort ist dieselbe wie bei der + Prüfung oben. */ + if (/UNIQUE|constraint/i.test(String(fehler?.message))) { + const da = db().prepare( + "SELECT id FROM eintraege WHERE kampagne_id = ?").get(nummer); + if (da) { + protokolliere("kampagne_schon_da", { + personId: person.id, rolle: person.rolle, ip, + detail: kurz(`${nummer} -> Eintrag ${da.id} (Wettlauf, Index)`), + }); + return res.status(200).json({ id: da.id, schon: true }); + } + } + throw fehler; + } + + /* ---- 10. Protokoll ------------------------------------------- */ + protokolliere("kampagne_eingelesen", { + personId: person.id, rolle: person.rolle, ip, + detail: kurz(`${nummer} "${gelesen.pflicht.titel}" ` + + `${gelesen.pflicht.start}..${gelesen.pflicht.ende} -> Eintrag ${eintragId}` + + (hinweise.length ? ` (${hinweise.length} Hinweise)` : "")), + }); + + return res.status(201).json({ + id: eintragId, + titel: gelesen.pflicht.titel, + von: gelesen.pflicht.start, + bis: gelesen.pflicht.ende, + hinweise, + }); + } catch (fehler) { + console.error("[kampagne] einlesen:", fehler?.message); + /* Ein abgelegtes Banner ohne Eintrag wäre eine Datei, die niemand + je wieder findet -- und die Ablage wächst still. */ + if (abgelegt) { + try { unlinkSync(join(ORDNER, abgelegt.name)); } catch { /* weg ist weg */ } + } + return schiefgegangen(500, + "Beim Anlegen ist etwas schiefgegangen. Bitte noch einmal versuchen.", + `unerwartet: ${fehler?.message}`); + } +}); diff --git a/server/workspace.js b/server/workspace.js index fe3bc769..59f07fe9 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -4548,6 +4548,39 @@ function umstellungen(d) { sichtbar (siehe hausWo). Eine woechentliche Serie haette die Trennung damit Woche fuer Woche neu unterlaufen. */ ["termin_serien", "haus", "TEXT"], + + /* ================================================================ + EINE KAMPAGNE AUS EINEM TIKTOK-LINK (07.10.2026), Etappe 3 + + Fuenf Spalten an einer Tabelle, die es schon gibt -- KEINE neue + Tabelle. Eine 'kampagnen'-Tabelle neben 'eintraege' waere ein + zweiter Ort fuer dasselbe, und beim naechsten Umbau liefe einer + von beiden aus. Die Kachel IST ein Eintrag; was dazukommt, ist + nur ihre Herkunft. + + ADD COLUMN und kein Tabellenneubau: Es aendert sich kein CHECK. + Der Neubau vom 06.09. war noetig, weil dort der erlaubte + Wertebereich von 'bereich' wuchs -- und er ist der Weg, bei dem + man Inhalte verlieren kann. + + WARUM 'kampagne_daten' die Rohwerte mitnimmt: Wenn in drei + Monaten auffaellt, dass auch die Ligen gebraucht werden, steht + die Antwort schon da. Ohne diese Spalte muesste jede Kampagne + neu geholt werden -- und eine abgelaufene antwortet dann + vielleicht nicht mehr. Gemessen am 07.10.2026: Bei einer + beendeten Kampagne liefert TikTok das Interaktionsschema gar + nicht mehr. Gespeichert wird ein AUSZUG, nicht die 1,1 MB. + + WARUM 'kampagne_zone' eine eigene Spalte ist und nicht gerechnet + wird: Der Kalendertag einer Kampagne haengt an IHRER Zone, nicht + an der des Servers. Wer sie nicht mitspeichert, kann spaeter + nicht mehr richtig rechnen -- und eine Zahl, die um eins + danebenliegt, sieht aus wie eine Zahl. */ + ["eintraege", "kampagne_id", "TEXT"], // activityId -- Dublettenschluessel + ["eintraege", "kampagne_quelle", "TEXT"], // der eingefuegte Link, unveraendert + ["eintraege", "kampagne_geholt_am", "TEXT"], // ISO-Zeitstempel + ["eintraege", "kampagne_zone", "TEXT"], // tz_location_name, z.B. Europe/Berlin + ["eintraege", "kampagne_daten", "TEXT"], // JSON-Auszug der Rohwerte ]) { try { const vorhanden = d.prepare(`PRAGMA table_info(${tabelle})`).all().map((s) => s.name); @@ -4565,6 +4598,33 @@ function umstellungen(d) { Tabelle ohne Spalte und meldet einen Fehler, den niemand braucht. */ haeuserNachtragen(d); + /* ================================================================ + EINE KAMPAGNE NUR EINMAL (07.10.2026) + + WARUM EIN EINDEUTIGER INDEX UND NICHT NUR DIE PRUEFUNG IM CODE: + Zwei gleichzeitige Aufrufe laufen BEIDE durch das SELECT, bevor + einer schreibt. Die Pruefung im Code faengt den Normalfall, der + Index faengt den Wettlauf. Beides, nicht eins davon. + + Und WARUM auf kampagne_id und nicht auf der Adresse: Es gibt + MEHRERE Kurzlinks auf dieselbe Kampagne -- jeder Versand erzeugt + eine eigene campaign_traffic_id --, und dieselbe Kampagne laesst + sich auch ueber die lange Adresse oeffnen. Wer auf quelle_url + prueft (so macht es das Video, dort richtig), bekommt fuer zwei + Einladungen zur selben Kampagne zwei Kacheln. + + WHERE kampagne_id IS NOT NULL: Alle anderen Eintraege -- Aufgaben, + Termine, Videos, Aushaenge -- haben dort NULL. Ohne diese + Bedingung waere nach dem ersten davon kein zweiter mehr moeglich. + Steht hier unten und nicht oben im Bauplan, weil die Spalte erst + durch die Schleife darueber entsteht. */ + try { + d.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_eintraege_kampagne" + + " ON eintraege (kampagne_id) WHERE kampagne_id IS NOT NULL"); + } catch (fehler) { + console.error("[workspace] Index idx_eintraege_kampagne:", fehler?.message); + } + /* Der Nachfüller fragt bei jedem Lauf "welche Ausprägungen dieser Serie gibt es schon?". Ohne diesen Verbund-Index liest SQLite dafür die ganze Termintabelle. Er steht hier unten und nicht oben im @@ -7608,6 +7668,19 @@ workspaceRouter.get("/workspace/api/ich", (req, res) => { `ich.rolle === 'admin'`, und dieselbe Zeile schaltete auch das Loeschen frei; die beiden gehoeren nicht zusammen. */ darf_rollen_wechseln: darfRollenWechseln(person), + /* OB DER KASTEN "KAMPAGNE AUS EINER EINLADUNG" ERSCHEINT + (07.10.2026). Aus derselben Regel wie die Schranke dahinter + (istLeitung in workspace-kampagne.js) -- die Oberflaeche + vergleicht keine Rollennamen selbst. + + WARUM NICHT ["admin","spicy","manager"] IM BROWSER: Das waere + die vierte abgeschriebene Liste in diesem Haus. Am 10.09.2026 + standen zwei solche Listen drei Zeilen auseinander und + widersprachen einander -- Spicy Media sah einen Knopf nicht, + obwohl der Weg dahinter offen war. Und die umgekehrte Richtung + ist schlimmer: Ein Feld, das erscheint und dann 403 bekommt, + laesst den Menschen davor sich selbst fuer den Fehler halten. */ + darf_kampagne_einlesen: istLeitung(person), /* ZWEI LISTEN STATT EINER RECHNUNG IM BROWSER (20.09.2026). Die Rollenwahl nahm bisher die Knoepfe des Anlege-Formulars -- @@ -7896,10 +7969,35 @@ export function heuteLokal() { * schweigen -- sonst wandert der Unsinn in die Datenbank und in * die Anzeige, und gefunden wird er Wochen später. */ export function tagLokal(versatz = 0) { + return tagLokalVon(Date.now(), versatz); +} + +/** Derselbe Kalendertag, aber von einem BELIEBIGEN Zeitpunkt aus. + * + * WOZU (07.10.2026, gefunden von pruef-struktur): In + * workspace-anleitung.js stand + * + * new Date(t + tage * 86400_000).toISOString().slice(0, 10) + * + * -- gerechnet wurde also ab dem Beitrittstag, der Tag aber in UTC + * gebildet. Wer zwischen 00:00 und 02:00 deutscher Zeit beitritt, + * liegt in UTC noch im Vortag: Die Seite nannte dann einen Termin, + * der einen Tag zu frueh war. Ein Datum, das um eins danebenliegt, + * sieht immer noch wie ein Datum aus -- es faellt niemandem auf. + * + * WARUM HIER UND NICHT DORT: Es waere die DRITTE Abschrift + * derselben drei Zeilen gewesen. `tagLokal` ist jetzt selbst nur + * noch ein Aufruf dieser Funktion mit `Date.now()` -- es gibt + * also WENIGER Rechenwege als vorher, nicht mehr. */ +export function tagLokalVon(zeitpunkt, versatz = 0) { if (!Number.isFinite(Number(versatz))) { - throw new TypeError(`tagLokal: "${versatz}" ist keine Zahl von Tagen.`); + throw new TypeError(`tagLokalVon: "${versatz}" ist keine Zahl von Tagen.`); } - const d = new Date(Date.now() + versatz * 86400_000); + const ms = Number(zeitpunkt); + if (!Number.isFinite(ms)) { + throw new TypeError(`tagLokalVon: "${zeitpunkt}" ist kein Zeitpunkt.`); + } + const d = new Date(ms + versatz * 86400_000); const p = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; } diff --git a/workspace/anleitung.html b/workspace/anleitung.html index a60bcfe5..58105806 100644 --- a/workspace/anleitung.html +++ b/workspace/anleitung.html @@ -7,17 +7,17 @@ - - - - - + + + + + - - + +
@@ -289,16 +289,16 @@ Und anleitung.js braucht es doppelt: Symbol, Farbe, Name und Adresse jeder Karte kommen von dort, nicht vom Server. --> - + - - - - - - + + + + + +