/* ===================================================================== EIN VIDEO IN DIE APP HOLEN (17.09.2026) — Stufe A des Video-Plans Filipe: „damit meine videos auch da auf der app rein kommen … sobald ich ein video poste erscheint es sofort in der app für die ganze community … dass man das Coverbild und den Text sieht … für meinen Account, dann DogFather Clips und HasiDog." --------------------------------------------------------------------- DER EIGENTLICHE PUNKT STEHT IN ABSCHNITT 6 TikToks Coverbild-Adresse ist signiert und läuft ab — gemessen am 17.09.2026 an einem echten Video: `x-expires` in 48 Stunden. Wer sie nur speichert, hat übermorgen schwarze Kacheln. Beim Bauen merkt man das nicht, beim Testen auch nicht: Es fällt erst am übernächsten Tag auf, und dann sieht die ganze App kaputt aus. Deshalb wird das Bild KOPIERT. Und genau das lässt sich beweisen: Der nachgebaute TikTok-Dienst wird im letzten Abschnitt ABGESCHALTET. Danach muss die alte Cover-Adresse ins Leere laufen und unsere weiterhin ein Bild liefern. Wäre es nur verlinkt, wären beide tot — genau wie übermorgen im Betrieb. Deshalb steht das Abschalten auch GANZ HINTEN: Jeder Abschnitt davor braucht den Dienst noch. --------------------------------------------------------------------- OHNE ECHTES TIKTOK Eine Prüfung, die das Netz braucht, ist langsam, hängt an fremder Verfügbarkeit und wird irgendwann rot, ohne dass etwas kaputt ist — und dann schaltet man sie ab. Hier läuft ein nachgebauter Dienst auf 127.0.0.1. Die Adresse dorthin kommt aus einer Umgebungsvariablen, die es im Betrieb nicht gibt; steht sie nicht da, gilt TikTok. Aufrufen mit: node server/pruef-video.mjs ===================================================================== */ import { mkdtempSync, rmSync, existsSync, readdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createServer } from "node:http"; import { portMussFreiSein } from "./helfer-port.mjs"; const PORT = await portMussFreiSein(4499, "pruef-video"); const TIKTOK = await portMussFreiSein(4501, "pruef-video (nachgebautes TikTok)"); const ordner = mkdtempSync(join(tmpdir(), "ws-video-")); process.env.WORKSPACE_DB = join(ordner, "workspace.db"); process.env.PORT = String(PORT); process.env.SITE_ACCESS_SECRET = "lokaler-test"; process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; process.env.TIKTOK_OEMBED_BASIS = `http://127.0.0.1:${TIKTOK}/oembed`; /* DER ABLAGEORT wird NICHT abgeschrieben, sondern abgeleitet: In workspace.js steht `DATEN_ORDNER = dirname(DB_PFAD)`. Eine zweite Rechnung hier wäre eine zweite Wahrheit -- und die veraltet. */ const ABLAGE = join(ordner, "dateien"); /* ---------- Das nachgebaute TikTok ----------------------------------- */ const PNG = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", "base64"); let coverAbrufe = 0; let oembedAbrufe = 0; const coverAdresse = (was) => `http://127.0.0.1:${TIKTOK}/${was}` + `?x-expires=${Math.floor(Date.now() / 1000) + 172800}&x-signature=abc`; const tiktok = createServer((q, a) => { const u = new URL(q.url, `http://127.0.0.1:${TIKTOK}`); if (u.pathname === "/oembed") { oembedAbrufe++; const ziel = u.searchParams.get("url") || ""; /* Welcher Account? Aus der angefragten Adresse -- beim echten Dienst ist es genauso. */ const wer = (ziel.match(/@([^/?]+)/) || [])[1] || ""; if (!wer) { a.writeHead(404); a.end("{}"); return; } /* Welches Cover? Am Videonamen. So lassen sich die Sonderfälle auslösen, ohne den Dienst umzubauen. */ let bild = coverAdresse("cover.jpg"); if (/kaputt/.test(ziel)) bild = coverAdresse("kein-bild.jpg"); if (/ohnecover/.test(ziel)) bild = null; a.writeHead(200, { "content-type": "application/json" }); a.end(JSON.stringify({ version: "1.0", type: "video", title: "Casper springt ins Bild \u{1F604} #fyp #hund #dogfather", author_url: `https://www.tiktok.com/@${wer}`, /* DER ANZEIGENAME ist bewusst bei allen derselbe -- wer ihn als Schlüssel nimmt statt des Accounts, fällt in Abschnitt 3 auf. */ author_name: "Irgendein Anzeigename", thumbnail_url: bild, thumbnail_width: 576, thumbnail_height: 1024, })); return; } if (u.pathname === "/cover.jpg") { coverAbrufe++; a.writeHead(200, { "content-type": "image/jpeg" }); a.end(PNG); return; } if (u.pathname === "/kein-bild.jpg") { /* EINE HTML-SEITE, DIE SICH ALS BILD AUSGIBT. Genau der Fall, für den die Byteprüfung gebaut wurde: Der Typ kommt hier von einem FREMDEN Server, er ist also noch weniger wert als eine Behauptung des eigenen Browsers. */ a.writeHead(200, { "content-type": "image/jpeg" }); a.end("kein Bild, sondern HTML"); return; } a.writeHead(404); a.end("{}"); }); await new Promise((r) => tiktok.listen(TIKTOK, "127.0.0.1", r)); const express = (await import("express")).default; const ec = express.response.cookie; express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; import { notbremse } from "./helfer-notbremse.mjs"; await import("./index.js"); notbremse(180_000, "pruef-video"); await new Promise((r) => setTimeout(r, 700)); const BASIS = `http://127.0.0.1:${PORT}`; let fehler = 0, geprueft = 0; const melde = (t) => console.log(t); const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; const { DatabaseSync } = await import("node:sqlite"); const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); const d = new DatabaseSync(process.env.WORKSPACE_DB); const jetzt = new Date().toISOString(); const sch = randomBytes(32).toString("hex"); d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") .run("code_kennung_schluessel", sch, jetzt); const salt = randomBytes(16).toString("hex"); const hash = scryptSync("CODE-DOGI-0001", 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, code_kennung, aktiv, erstellt)" + " VALUES (?,?,?,?,?,?,1,?)") .run("Filipe", "admin", hash, salt, 32768, createHmac("sha256", sch).update("CODE-DOGI-0001").digest("hex"), jetzt); d.close(); const an = await fetch(BASIS + "/workspace/api/anmelden", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rolle: "admin", code: "CODE-DOGI-0001" }), }); const keks = (an.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; "); const kopf = { "Content-Type": "application/json", cookie: keks, origin: BASIS }; async function holen(url, bereich = "highlight") { const a = await fetch(`${BASIS}/workspace/api/bereich/${bereich}/video`, { method: "POST", headers: kopf, body: JSON.stringify({ url }), }); return { status: a.status, daten: await a.json().catch(() => null) }; } async function liste(bereich = "highlight") { const a = await fetch(`${BASIS}/workspace/api/bereich/${bereich}`, { headers: { cookie: keks } }); return (await a.json().catch(() => null))?.eintraege || []; } const videoVon = (handle, nr = "123") => `http://127.0.0.1:${TIKTOK}/@${handle}/video/${nr}`; const dateienImOrdner = () => { try { return readdirSync(ABLAGE).length; } catch { return 0; } }; /* ======================================================================= 1. EIN LINK WIRD ZU EINEM EINTRAG ======================================================================= */ melde(""); melde("=== 1. Der Link ==="); let eintragId = 0, bildId = 0; { const a = await holen(videoVon("dogfather0804")); ok(a.status === 201, `ein Video von DogFather wird eingelesen (${a.status} ${a.daten?.fehler || ""})`); eintragId = a.daten?.id || 0; ok(a.daten?.kanal === "dogfather", `und dem richtigen Kanal zugeordnet (${a.daten?.kanal})`); const e = (await liste()).find((x) => x.id === eintragId); ok(/Casper springt ins Bild/.test(e?.titel || ""), `der Text des Videos steht da ("${(e?.titel || "").slice(0, 40)}…")`); ok(e?.quelle_url === videoVon("dogfather0804"), "die Adresse zum Video auch -- sonst käme niemand zum Liken hin"); ok(e?.quelle_kanal === "dogfather", `und der Kanal am Eintrag (${e?.quelle_kanal})`); ok((e?.bilder || []).length === 1, `ein Coverbild hängt dran (${(e?.bilder || []).length})`); bildId = e?.bilder?.[0]?.id || 0; ok(coverAbrufe === 1, `das Cover wurde genau einmal geholt (${coverAbrufe})`); } /* ======================================================================= 2. DAS COVER LIEGT BEI UNS ======================================================================= */ melde(""); melde("=== 2. Wo das Bild liegt ==="); { const r = await fetch(`${BASIS}/workspace/api/dateien/${bildId}/bild`, { headers: { cookie: keks } }); ok(r.status === 200 && (r.headers.get("content-type") || "").startsWith("image/"), `es kommt über unseren eigenen Weg (${r.status}, ${r.headers.get("content-type")})`); const bytes = Buffer.from(await r.arrayBuffer()); ok(bytes.length === PNG.length && bytes.equals(PNG), `Byte für Byte dasselbe Bild (${bytes.length} von ${PNG.length})`); /* UND IN DER AUSKUNFT STEHT KEINE FREMDE ADRESSE. Ein Eintrag, dessen Bildadresse nach draußen zeigt, wäre genau der Fehler, den Abschnitt 6 misst -- nur fiele er dort dann nicht mehr auf, weil die Kachel ja "ein Bild hat". */ const e = (await liste()).find((x) => x.id === eintragId); const alsText = JSON.stringify(e?.bilder || []); ok(!/tiktok|x-expires|127\.0\.0\.1/i.test(alsText), `und keine abgelaufene Fremdadresse (${alsText.slice(0, 60)})`); ok(dateienImOrdner() === 1, `eine Datei liegt in der Ablage (${dateienImOrdner()})`); } /* ======================================================================= 3. ALLE DREI KANÄLE, JEDER AN SEINEN PLATZ ======================================================================= */ melde(""); melde("=== 3. Die drei Kanäle ==="); { /* NICHT ABGESCHRIEBEN: Die Handles kommen aus KANAELE selbst. Eine eigene Liste hier wäre eine zweite Wahrheit -- genau die Falle, die in diesem Haus schon dreimal zugeschlagen hat. */ const { KANAELE } = await import("./workspace.js"); ok(KANAELE.length === 3, `es sind drei Kanäle (${KANAELE.map((k) => k.wert).join(", ")})`); let n = 500; for (const k of KANAELE.filter((x) => x.wert !== "dogfather")) { const a = await holen(videoVon(k.handle.replace(/^@/, ""), String(++n))); ok(a.status === 201 && a.daten?.kanal === k.wert, `${k.handle} landet auf „${k.name}" (${a.status}, ${a.daten?.kanal})`); } /* UND DER ANZEIGENAME SPIELT KEINE ROLLE. Der nachgebaute Dienst schickt bei allen denselben ("Irgendein Anzeigename"). Wäre er der Schlüssel, lägen jetzt alle drei auf demselben Kanal. */ const kanaele = new Set((await liste()).map((e) => e.quelle_kanal).filter(Boolean)); ok(kanaele.size === 3, `drei verschiedene Kanäle in der Liste (${[...kanaele].join(", ")})`); } /* ======================================================================= 4. WAS NICHT HINEINKOMMT ======================================================================= */ melde(""); melde("=== 4. Die Gegenprobe ==="); { const vorher = (await liste()).length; /* EIN FREMDER ACCOUNT. Das ist nicht bloß falsch einsortiert -- es wäre fremder Inhalt unter Filipes Namen. */ const f = await holen(videoVon("irgendwerAnders", "777")); ok(f.status === 403, `ein fremder Account wird abgelehnt (${f.status})`); ok(/keiner deiner Kan/i.test(f.daten?.fehler || ""), `und es steht auch da, warum ("${(f.daten?.fehler || "").slice(0, 48)}…")`); /* KEINE TIKTOK-ADRESSE. Geprüft wird der Rechnername, nicht die Zeichenkette -- "tiktok.com.beispiel.de" gehört jemand anderem. */ for (const boese of [ "https://tiktok.com.beispiel.de/@dogfather0804/video/1", "https://beispiel.de/@dogfather0804/video/1", "nicht mal eine Adresse", "", ]) { const x = await holen(boese); ok(x.status === 400, `abgelehnt: ${boese.slice(0, 44) || "(leer)"} (${x.status})`); } /* DERSELBE LINK ZWEIMAL. Wer den Knopf zweimal drückt, meint nicht zwei Videos. */ const wieder = await holen(videoVon("dogfather0804")); ok(wieder.status === 200 && wieder.daten?.schon === true, `derselbe Link gibt denselben Eintrag zurück (${wieder.status}, schon=${wieder.daten?.schon})`); ok(wieder.daten?.id === eintragId, `und zwar wirklich denselben (${wieder.daten?.id})`); ok(coverAbrufe === 3, `und holt das Cover kein zweites Mal (${coverAbrufe} Abrufe für 3 Videos)`); const nachher = (await liste()).length; ok(nachher === vorher, `kein Eintrag ist dazugekommen (${nachher} von ${vorher})`); } /* ======================================================================= 5. WAS SICH ALS BILD AUSGIBT, ABER KEINS IST ======================================================================= */ melde(""); melde("=== 5. Die Byteprüfung ==="); { const vorherDateien = dateienImOrdner(); /* Der Dienst schickt HTML mit `content-type: image/jpeg`. Ein fremder Server darf den Typ behaupten -- geglaubt wird ihm nicht. */ const a = await holen(videoVon("dogfather0804", "kaputt")); ok(a.status === 201, `das Video kommt trotzdem rein (${a.status})`); const e = (await liste()).find((x) => x.id === a.daten?.id); ok((e?.bilder || []).length === 0, `aber ohne Cover -- die Bytes waren kein Bild (${(e?.bilder || []).length})`); ok(dateienImOrdner() === vorherDateien, `und nichts wurde abgelegt (${dateienImOrdner()} von ${vorherDateien})`); ok(/Casper/.test(e?.titel || ""), "der Text ist da -- ein fehlendes Cover wirft nicht das ganze Video weg"); /* UND EIN VIDEO GANZ OHNE COVER stürzt nicht ab. */ const b = await holen(videoVon("dogfather0804", "ohnecover")); ok(b.status === 201, `ein Video ohne Coverbild geht auch (${b.status})`); } /* ======================================================================= 6. UND ES ÜBERLEBT, WENN TIKTOK WEG IST ← der eigentliche Punkt ======================================================================= */ melde(""); melde("=== 6. Wenn die Quelle verschwindet ==="); { /* Die Adresse, die TikTok uns gegeben hat -- mit ihrem Ablaufstempel. */ const fremd = coverAdresse("cover.jpg"); const vorher = await fetch(fremd).then((r) => r.ok).catch(() => false); ok(vorher, "vorher liefert die TikTok-Adresse noch ein Bild"); /* JETZT WIRD ABGESCHALTET. Dasselbe passiert übermorgen im Betrieb, wenn `x-expires` abläuft: Die Adresse antwortet nicht mehr. */ await new Promise((r) => tiktok.close(r)); await new Promise((r) => setTimeout(r, 200)); const nachher = await fetch(fremd).then((r) => r.ok).catch(() => false); ok(!nachher, "danach ist sie tot -- genau wie nach 48 Stunden"); const r = await fetch(`${BASIS}/workspace/api/dateien/${bildId}/bild`, { headers: { cookie: keks } }); ok(r.status === 200, `unser Cover ist trotzdem noch da (${r.status})`); const bytes = Buffer.from(await r.arrayBuffer()); ok(bytes.equals(PNG), `und unverändert (${bytes.length} von ${PNG.length} Bytes)`); ok(existsSync(ABLAGE), "es liegt als Datei in unserer eigenen Ablage"); /* UND DIE KACHEL ZEIGT IHN WEITERHIN: Der Eintrag hat sein Bild noch, die Liste kommt ohne den fremden Dienst aus. */ const e = (await liste()).find((x) => x.id === eintragId); ok((e?.bilder || []).length === 1, `die Kachel hat ihr Bild noch (${(e?.bilder || []).length})`); ok(oembedAbrufe >= 5, `der fremde Dienst wurde beim Einlesen gefragt, nie beim Anzeigen (${oembedAbrufe})`); } melde(""); melde(`${geprueft} Prüfungen, ${fehler} Fehler`); melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG"); try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ } process.exit(fehler ? 1 : 0);