diff --git a/.gitignore b/.gitignore index 5e56979d..f56f2e20 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,6 @@ tools/.nachpruef* tools/.handy-detail.txt tools/.rest* + +videos/ +tools/.vid/ diff --git a/server/pruef-kachelraster.mjs b/server/pruef-kachelraster.mjs index 598aff98..44f60d6b 100644 --- a/server/pruef-kachelraster.mjs +++ b/server/pruef-kachelraster.mjs @@ -372,6 +372,80 @@ try { ok(ende.zellen === 8 && ende.minimum === 3, "auch die Sicht eines Zuschauers (7 Kacheln) geht auf drei Reihen auf"); } + /* ===================================================================== + JEDE KACHEL IST AUCH BREIT GENUG (27.09.2026) + ===================================================================== + + Gefunden beim Filmen der Startseite fuer TikTok, also mit dem + Auge und nicht mit einer Pruefung. Auf einem 320er Geraet waren + „Rudel-Chat" und „Anschlagbrett" DREI PIXEL breit -- ein + senkrechter Strich mit abgeschnittenem Text. + + Die Ursache stand in start.css: Unter 380 px wird das Raster + einspaltig, die Willkommenskachel spannte aber weiter ueber zwei + Spalten. Ein Gitter mit einer erklaerten Spalte und einem Kind, + das zwei braucht, erfindet die zweite -- und teilt den Platz + 3 zu 281. + + WARUM ES KEINE PRUEFUNG GEMERKT HAT, und das ist der eigentliche + Befund: pruef-handy misst Ueberhang und Beruehrziele. Eine 3 px + breite Kachel ragt nicht hinaus, und ihr Link ist 142 px HOCH -- + die Mindestgroesse fuer den Finger war also erfuellt. Beide + Pruefungen waren gruen, und die Kachel war unbenutzbar. + + Gemessen wird deshalb ab jetzt die BREITE, und zwar bei den drei + Geraetebreiten, die das Haus ohnehin fuehrt. Die Grenze ist + abgeleitet und nicht gesetzt: Eine Kachel muss mindestens ein + Drittel der Inhaltsbreite haben -- schmaler waere sie auch bei + drei Spalten nicht. + ===================================================================== */ + + /* Eigene Anmeldung: `messen()` haelt seinen Keks in seinem eigenen + Gueltigkeitsbereich, und dieser Abschnitt steht daneben, nicht + darin. Genommen wird der Modi -- er ist die Rolle in dieser + Pruefung, die eine Kachel mit `gross: true` hat, und nur an der + kann der Fehler ueberhaupt auftreten. */ + const keksBreite = await anmelden("creator", "CODE-MODI-0001"); + + for (const breite of [320, 360, 390]) { + const k2 = await browser.newContext({ + viewport: { width: breite, height: 900 }, reducedMotion: "reduce", + }); + await k2.addCookies(keksBreite.split("; ").map((k) => { + const [name, ...rest] = k.split("="); + return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" }; + })); + const s2 = await k2.newPage(); + await s2.goto(`${BASIS}/workspace/start.html`, { waitUntil: "networkidle" }); + await s2.waitForSelector(".kacheln .kachel", { timeout: 15_000 }); + await s2.waitForTimeout(400); + const m2 = await s2.evaluate(() => { + const kacheln = [...document.querySelectorAll("li.kachel")]; + const raster = [...document.querySelectorAll(".kacheln")]; + const inhalt = document.querySelector(".kacheln")?.getBoundingClientRect().width || 0; + return { + anzahl: kacheln.length, + inhalt: Math.round(inhalt), + schmalste: Math.round(Math.min(...kacheln.map((x) => x.getBoundingClientRect().width))), + wer: kacheln.map((x) => ({ + n: (x.querySelector(".kachel__name")?.textContent || "?").slice(0, 18), + b: Math.round(x.getBoundingClientRect().width), + })).sort((a, b2) => a.b - b2.b).slice(0, 2), + /* Und eine erfundene Spalte faellt hier auf: mehr Spuren, als + die Regel erklaert. */ + spuren: raster.map((r) => getComputedStyle(r).gridTemplateColumns.split(" ").length), + }; + }); + const grenze = Math.floor(m2.inhalt / 3) - 8; + ok(m2.anzahl > 5, `${breite} px: ${m2.anzahl} Kacheln angesehen`); + ok(m2.schmalste >= grenze, + `${breite} px: schmalste Kachel ${m2.schmalste} px (mindestens ${grenze}) -- ` + + m2.wer.map((w) => `${w.n} ${w.b}px`).join(", ")); + ok(m2.spuren.every((z) => z <= (breite < 380 ? 1 : 2)), + `${breite} px: kein Raster hat mehr Spalten als erlaubt (${m2.spuren.join("/")})`); + await k2.close(); + } + } finally { await browser.close(); } diff --git a/server/pruef-notizen.mjs b/server/pruef-notizen.mjs index a3af8149..804ba578 100644 --- a/server/pruef-notizen.mjs +++ b/server/pruef-notizen.mjs @@ -34,9 +34,10 @@ Derselbe Grund wie bei pruef-handy-teamdogi: `dogfather-universe.com` steht in der fest eingebauten HSTS-Liste von Chromium, und ohne die - Team-Adresse gibt es diese Seite gar nicht — auf einer Prüfadresse - (127.0.0.1) ist `haus` weder „crew" noch „agentur", und der Block - antwortet zu Recht mit 404. + Team-Adresse sieht man die Trennung nicht: Auf einer Prüfadresse + (127.0.0.1) ist `haus` weder „crew" noch „agentur" — dort ist der + Block offen (so wie jedes andere Modul auch), und nur auf + `workspace.` wird er abgewiesen. Beides wird unten gemessen. Aufrufen mit: node server/pruef-notizen.mjs ===================================================================== */ @@ -188,6 +189,29 @@ for (const rolle of ["gast", "creator"]) { const s2 = await roh(CREW, "/workspace/notizen.html", { keks: kekse.admin }); ok(s2.code === 200, `auf der Team-Adresse schon (${s2.code})`); } + +/* ---- Und die Pruefadresse ist das ANDERE Ende derselben Regel -------- + (27.09.2026, nachgetragen, nachdem pruef-handy einen 404 gemeldet + hatte.) + + Auf 127.0.0.1 hat niemand ein Haus. Die erste Fassung der Schranke + fragte „ist das Haus crew?" und wies damit auch die Pruefadresse ab + -- die Seite lud, blieb leer und schrieb einen Fehler in die + Konsole. Gefragt wird jetzt „ist es das ANDERE Haus?", so wie es + hausWo() im ganzen Haus tut. + + Beide Enden gehoeren in dieselbe Pruefung: Wer nur das eine misst, + kann die Schranke jederzeit wieder zu scharf stellen, ohne dass + etwas rot wird. */ +{ + const keksProbe = await anmelden("admin", "127.0.0.1"); + ok(!!keksProbe, "auf der Pruefadresse meldet sich DogFather an"); + const a = await roh("127.0.0.1", "/workspace/api/notizblock", { keks: keksProbe }); + ok(a.code === 200 && Array.isArray(a.j?.notizen), + `und bekommt dort seinen Block (${a.code}) -- ohne Haus wird nicht gesperrt`); + const s3 = await roh("127.0.0.1", "/workspace/notizen.html", { keks: keksProbe }); + ok(s3.code === 200, `auch die Seite selbst (${s3.code})`); +} { const ohne = await roh(CREW, "/workspace/api/notizblock"); ok(ohne.code === 401, `ohne Anmeldung: 401 (${ohne.code})`); diff --git a/server/tiktok-videos.mjs b/server/tiktok-videos.mjs new file mode 100644 index 00000000..d4e81c59 --- /dev/null +++ b/server/tiktok-videos.mjs @@ -0,0 +1,690 @@ +/* ===================================================================== + VIER VIDEOS ÜBER DIE APP (27.09.2026) + + Diese Datei liegt in server/ und nicht in tools/, und das ist kein + Geschmack: `express` und die Module des Hauses werden von Node + RELATIV zur importierenden Datei aufgeloest. In tools/ gibt es kein + node_modules -- der erste Anlauf ist mit „Cannot find package + express" abgebrochen, bevor eine Zeile lief. + ===================================================================== + + Filipe: „ich brauch 4 richtig geile videos über die ganze app. die + ich und die modis posten können auf tiktok um leute auf die app zu + pushen. ohne link bitte. es darf kein link zu sehen sein aber alles + andere ja was nicht geheim an dogfather nur ist." + + --------------------------------------------------------------------- + WAS DIESES WERKZEUG TUT + + Es startet die echte App auf einer Wegwerf-Datenbank, füllt sie mit + erfundenen Leuten und Beiträgen, meldet sich als Community-Mitglied + an und FILMT die Bedienung — 1080 × 1920, scharf, ohne Browserrahmen + und ohne Adresszeile. Danach schneidet ffmpeg daraus fertige + MP4-Dateien. + + Aufnehmen: + node server/tiktok-videos.mjs alle vier + node server/tiktok-videos.mjs 2 nur das zweite + Für Video 2 (die Nachtruhe) muss der Chat schlafen: + TREFF_NACHT_AB=0 TREFF_NACHT_BIS=24 node server/tiktok-videos.mjs 2 + + Das Ergebnis liegt in videos/ und ist git-ignoriert. + + --------------------------------------------------------------------- + DREI REGELN, DIE HIER NICHT VERHANDELBAR SIND + + 1. KEIN LINK, NIRGENDS. Weder eine Adresszeile (die gibt es in einer + Playwright-Aufnahme ohnehin nicht) noch eine Adresse im Bild. + Deshalb wird `unsere-seiten.html` NICHT gefilmt — dort stehen die + Kanäle und der Shop mit ihren Adressen. Und deshalb enthält kein + erfundener Beitrag eine Adresse. `pruef-tiktok-videos` liest jedes + gefilmte Bild durch eine Texterkennung nach, statt es zu glauben. + + 2. NICHTS, WAS NUR DOGFATHER GEHÖRT. Gefilmt wird ausschließlich die + Sicht eines Community-Mitglieds (`gast`) — also genau das, was + jeder sieht, der einen Zugang hat. Der vertrauliche Meldeweg + taucht als KACHEL auf (dass es ihn gibt, ist die Botschaft), aber + nie von innen. + + 3. KEINE TESTDATEN IM LIVE-SYSTEM. Der Server läuft auf einer + Datenbank in einem mktemp-Ordner, auf einer eigenen Portnummer, + hinter einem eigenen Zertifikat. Die echte Seite wird nicht + angefasst — nicht einmal gelesen. + + --------------------------------------------------------------------- + WARUM ÜBER https UND EINEN VORBAU + + `dogfather-universe.com` steht in der fest eingebauten HSTS-Liste + von Chromium: Der Browser schaltet dort von sich aus auf https um. + Ohne die Team-Adresse gäbe es den Treff gar nicht (auf 127.0.0.1 ist + `haus` weder crew noch agentur). Also: eigenes Zertifikat, winziger + Vorbau, der alles unverändert weiterreicht. Dieselbe Bauart wie in + pruef-handy-teamdogi. + + --------------------------------------------------------------------- + WARUM DIE AUFNAHME WIRKLICH 1080 × 1920 IST + + Playwright skaliert ein Bild für das Video nur HERUNTER, nie herauf. + Eine Aufnahme mit 360 px Fensterbreite liegt deshalb als 360er Bild + in einer 1080er Leinwand — oben links, der Rest grau. Gemessen, beim + ersten Versuch. + + Der Ausweg ist `--force-device-scale-factor=3`: Das Fenster ist dann + 360 × 640 CSS-Pixel (die App nimmt ihr Handy-Layout) und gleichzeitig + 1080 × 1920 echte Bildpunkte. Genau die filmt Playwright. + ===================================================================== */ +import { mkdtempSync, rmSync, readFileSync, mkdirSync, existsSync, renameSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { request as httpAnfrage } from "node:http"; +import { execFileSync, spawnSync } from "node:child_process"; +import { createServer as httpsServer } from "node:https"; +import { createServer as netServer } from "node:net"; + +const HIER = dirname(fileURLToPath(import.meta.url)); +const WURZEL = join(HIER, ".."); +const ZIEL = join(WURZEL, "videos"); +const CREW = "crew.dogfather-universe.com"; + +const ordner = mkdtempSync(join(tmpdir(), "tk-video-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); + +/* Eine eigene Nummer. Frei sein muss sie trotzdem -- sonst filmt + dieses Werkzeug einen fremden Server, und man sieht es dem Bild + nicht an. */ +const PORT = 5471; +{ + const frei = await new Promise((fertig) => { + const s = netServer(); + s.once("error", () => fertig(false)); + s.once("listening", () => s.close(() => fertig(true))); + s.listen(PORT, "127.0.0.1"); + }); + if (!frei) { console.error(`Port ${PORT} ist belegt. Abgebrochen.`); process.exit(3); } +} + +process.env.PORT = `${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)); + +/* ---------- Sprechen mit Hausadresse ---------------------------------- */ +function roh(pfad, { method = "GET", keks = "", rumpf = null } = {}) { + return new Promise((fertig, schief) => { + const daten = rumpf === null ? null : Buffer.from(JSON.stringify(rumpf)); + const a = httpAnfrage({ + host: "127.0.0.1", port: PORT, path: pfad, method, + headers: { + Host: CREW, Origin: `https://${CREW}`, + ...(keks ? { Cookie: keks } : {}), + ...(daten ? { "Content-Type": "application/json", "Content-Length": daten.length } : {}), + }, + }, (antwort) => { + const teile = []; + antwort.on("data", (s) => teile.push(s)); + antwort.on("end", () => { + const text = Buffer.concat(teile).toString("utf8"); + let j = null; try { j = JSON.parse(text); } catch { /* kein JSON */ } + fertig({ code: antwort.statusCode, text, j, kopf: antwort.headers }); + }); + }); + a.on("error", schief); + if (daten) a.write(daten); + a.end(); + }); +} + +/* ---------- Leute anlegen --------------------------------------------- + Erfundene Namen, erfundene Beiträge. Nichts davon stammt aus dem + echten Haus, und nichts davon geht dorthin zurück. */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const nun = new Date().toISOString(); +const kenn = randomBytes(32).toString("hex"); +d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") + .run("code_kennung_schluessel", kenn, nun); +/* ==== DAS ANLEGEDATUM IST NICHT EGAL ============================ + + Der Treff hat drei Stufen, und sie werden aus dem Alter des + Zugangs gerechnet: „Neu" die erste Woche, „Dabei" ab sieben Tagen, + „Stamm" ab dreissig Tagen UND drei eigenen Beitraegen. Wer neu ist, + darf lesen und im Chat schreiben -- mehr nicht. + + Das ist eine gute Regel (die erste Woche ist eine Sandkiste), und + sie hat den ersten Fuellversuch vollstaendig abgewiesen: acht mal + 403, Bretter leer, und im Video haette eine leere App gestanden. + Die Statisten bekommen deshalb ein Datum von vor 200 Tagen. + ============================================================== */ +const VOR_200_TAGEN = new Date(Date.now() - 200 * 86400000).toISOString(); +function person(name, rolle, code, seit = nun) { + 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," + + " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, + createHmac("sha256", kenn).update(code).digest("hex"), seit); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} + +const LEUTE = [ + ["DogFather", "admin"], ["VanVan", "hand"], ["Funny", "linke"], + ["Miss Kira", "modi"], ["Nachtfalter", "gast"], ["Lenny", "gast"], + ["Sanja", "gast"], ["Tobi", "gast"], ["Mo", "gast"], ["Elif", "gast"], +]; +const CODE = {}; const ID = {}; +LEUTE.forEach(([name, rolle], i) => { + CODE[name] = `CODE-TK${String(i).padStart(2, "0")}-0001`; + ID[name] = person(name, rolle, CODE[name], rolle === "gast" ? VOR_200_TAGEN : nun); +}); +d.close(); + +/* `alter_ok` ist Pflicht, und zwar nur fuer die Community: Wer von + aussen kommt, bestaetigt beim ersten Anmelden sein Alter (18+). + Ohne das Feld antwortet die Anmeldung mit 400 „alter_offen" -- der + erste Lauf hat deshalb die Zugangswand gefilmt statt der App, und + im Bild sah es aus wie ein Fehler an der Aufnahme. */ +const keks = {}; +for (const [name, rolle] of LEUTE) { + const a = await roh("/workspace/api/anmelden", { + method: "POST", rumpf: { rolle, code: CODE[name], alter_ok: true }, + }); + keks[name] = [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; "); + if (!keks[name]) { console.error(`Anmeldung fuer ${name} (${rolle}) ging nicht: ${a.code} ${a.text.slice(0, 120)}`); process.exit(4); } +} + +/* ---------- Die Bretter füllen ---------------------------------------- + Über die echten Wege, nicht über die Datenbank: Was hier entsteht, + hat dieselben Prüfungen durchlaufen wie ein echter Beitrag -- und + sieht deshalb auch genauso aus. */ +let schiefgegangen = 0; +async function eintrag(wer, brett, art, titel, text) { + const a = await roh(`/workspace/api/bereich/${brett}`, { + method: "POST", keks: keks[wer], rumpf: { art, titel, text }, + }); + const id = a.j?.eintrag?.id ?? a.j?.id; + /* LAUT SCHEITERN. Ein stiller Fehlschlag hier heisst: leeres Brett + im Video -- und das sieht aus wie eine leere App. */ + if (!id) { + schiefgegangen++; + console.error(` ${brett}/${art} von ${wer}: ${a.code} ${String(a.text).slice(0, 140)}`); + } + /* Freigeben, damit es die Community sieht -- auf den Treff-Brettern + entscheidet das Team, was steht. */ + if (id) { + await roh(`/workspace/api/treff/eintrag/${id}/freigeben`, + { method: "PUT", keks: keks.DogFather, rumpf: {} }); + } + return id; +} + +console.log("Fuelle die Bretter …"); + +/* Die Reihenfolge ist Absicht: „Stamm" verlangt drei eigene + Beitraege auf Treff-Brettern. Wer spaeter ein Highlight einstellen + soll, muss vorher drei Wuensche geschrieben haben -- sonst gilt + dieselbe Sperre wie beim ersten Versuch. */ +await eintrag("DogFather", "anschlag", "ansage", + "Freitag 20 Uhr — der grosse Stream", "Bringt Zeit mit. Es gibt was zu feiern."); +await eintrag("DogFather", "anschlag", "regel", + "Keine Handynummern im Chat", "Auch nicht privat. Wir passen hier aufeinander auf."); +await eintrag("VanVan", "anschlag", "danke", + "Danke an alle vom Samstag", "Ihr wart vierhundert. Vierhundert!"); + +await eintrag("DogFather", "ansteht", "stream", "Freitag 20:00 — Rudel-Abend", + "Der lange. Mit allem."); +await eintrag("DogFather", "ansteht", "event", "Sonntag 18:00 — Fragerunde", + "Ihr fragt, ich antworte. Ohne Filter."); +await eintrag("Miss Kira", "ansteht", "turnier", "Mittwoch — Turnier, 16 Plaetze", + "Anmelden im Chat. Wer zuerst kommt."); + +await eintrag("Mo", "wunsch", "stream", "Nochmal die Horror-Nacht", + "Die von letztem Jahr war das Beste. Bitte nochmal."); +await eintrag("Mo", "wunsch", "gemeinsam", "Gemeinsames Kochen im Live", + "Jeder kocht mit, am Ende zeigen alle den Teller."); +await eintrag("Mo", "wunsch", "technik", "Bitte eine zweite Kamera", + "Man sieht Casper nie. Casper ist wichtig."); +await eintrag("Nachtfalter", "wunsch", "stream", "Rueckblick auf das erste Jahr", + "Mit den alten Clips. Ich heul dann halt."); +await eintrag("Nachtfalter", "wunsch", "gemeinsam", "Spieleabend mit dem Rudel", + "Zehn Leute, ein Spiel, offene Kamera."); +await eintrag("Nachtfalter", "wunsch", "sonstiges", "Merch in Groesse XXL", + "Ich bin gross. Wir sind mehr, als ihr denkt."); +await eintrag("Sanja", "wunsch", "stream", "Ein Stream nur mit Musik", + "Einfach reden und Platten auflegen."); +await eintrag("Sanja", "wunsch", "technik", "Untertitel fuer die Aufzeichnungen", + "Meine Schwester hoert schlecht und schaut trotzdem jede Folge."); +await eintrag("Sanja", "wunsch", "gemeinsam", "Rudel-Treffen in echt", + "Einmal im Jahr. Irgendwo in der Mitte."); + +/* Jetzt reichen die drei fuer „Stamm" -- und duerfen Highlights. */ +await eintrag("Mo", "highlight", "clip", "Der Moment, als der Ton wegflog", + "Zwanzig Sekunden Stille und dann alle am Lachen."); +await eintrag("Nachtfalter", "highlight", "moment", "1000 Follower — live erlebt", + "Wir haben alle mitgezaehlt."); +await eintrag("Sanja", "highlight", "fanart", "Gezeichnet: Casper", + "Zwei Abende gebraucht. Er guckt genau so."); + +await eintrag("DogFather", "mitmachen", "gesucht", "Wir suchen zwei Modis", + "Du musst nichts koennen. Du musst da sein wollen."); +await eintrag("Mo", "mitmachen", "lust", "Ich haette Lust auf Moderation", + "Bin fast jeden Abend da. Traue mich langsam."); +await eintrag("Sanja", "mitmachen", "frage", "Wie viel Zeit kostet das?", + "Ich wuerde gern, hab aber Schichtdienst."); + +if (schiefgegangen) { + console.error(`${schiefgegangen} Beitraege sind nicht entstanden. Es wird nicht gefilmt.`); + process.exit(6); +} + +/* ---------- Der Chat ---------------------------------------------------- */ +/* DIESER AUFRUF LEGT DEN RAUM ERST AN. Der Treff-Chat entsteht beim + ersten Mal, wenn ein Community-Mitglied nach den Raeumen fragt -- + vorher gibt es ihn nicht. Erkannt wird er an der KATEGORIE und + nicht am Namen: Der Name („Das Rudel") ist schon einmal gewandert. */ +const raeume = await roh("/workspace/api/chat/raeume", { keks: keks.Nachtfalter }); +const treffRaum = (raeume.j?.raeume || []).find((r) => r?.kategorie === "treff")?.id; + +const CHAT = [ + ["Nachtfalter", "Guten Abend zusammen 🐺"], + ["Lenny", "Moin! Ist er heute pünktlich?"], + ["Sanja", "Er ist NIE pünktlich"], + ["Miss Kira", "Ich hab ihn gerade gehört, er kommt gleich"], + ["Mo", "Ich bring heute jemanden mit, sie schaut zum ersten Mal"], + ["Elif", "Willkommen an sie! Hier ist es nett, versprochen"], + ["Tobi", "Kann jemand den Link zur Fragerunde... ach nee, steht ja unter Was ansteht"], + ["Nachtfalter", "Genau deswegen liebe ich das hier"], +]; +if (treffRaum) { + for (const [wer, text] of CHAT) { + await roh(`/workspace/api/chat/raeume/${treffRaum}/nachrichten`, { + method: "POST", keks: keks[wer], rumpf: { text }, + }); + await new Promise((r) => setTimeout(r, 60)); + } +} +console.log(`Chat: ${treffRaum ? "gefuellt" : "Raum nicht gefunden"}`); + +/* ---------- NACHGESEHEN, NICHT GEHOFFT -------------------------------- + + Ein leeres Brett sieht im Film aus wie eine leere App. Der erste + Lauf hat 38 Sekunden lang die Zugangswand gefilmt, weil die + Anmeldung still gescheitert war -- gemerkt habe ich es erst am + fertigen Video. Deshalb wird hier abgebrochen statt gedreht. */ +{ + const probe = await roh("/workspace/api/bereich/wunsch", { keks: keks.Nachtfalter }); + const n = (probe.j?.eintraege || probe.j?.liste || []).length; + const ich = await roh("/workspace/api/ich", { keks: keks.Nachtfalter }); + console.log(`Gegenprobe: angemeldet als ${ich.j?.name || "?"} (${ich.j?.rolle || "?"}), ` + + `${n} Wuensche auf dem Brett, ${treffRaum ? CHAT.length : 0} Nachrichten im Chat`); + if (ich.code !== 200 || n < 3 || !treffRaum) { + console.error("Die Buehne steht nicht. Es wird nicht gefilmt."); + process.exit(5); + } +} + +/* ---------- Der Vorbau mit dem Zertifikat ------------------------------- */ +const key = join(ordner, "v.key"), crt = join(ordner, "v.crt"); +execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", + "-keyout", key, "-out", crt, "-days", "2", + "-subj", `/CN=${CREW}`, "-addext", `subjectAltName=DNS:${CREW}`], { stdio: "ignore" }); +const HTTPS_PORT = PORT + 1; +const vorbau = httpsServer({ key: readFileSync(key), cert: readFileSync(crt) }, (von, nach) => { + const weiter = httpAnfrage({ + host: "127.0.0.1", port: PORT, path: von.url, method: von.method, + headers: { ...von.headers, host: `${CREW}:${HTTPS_PORT}` }, + }, (antwort) => { nach.writeHead(antwort.statusCode || 502, antwort.headers); antwort.pipe(nach); }); + weiter.on("error", () => { try { nach.writeHead(502); nach.end(); } catch { /* egal */ } }); + von.pipe(weiter); +}); +await new Promise((r) => vorbau.listen(HTTPS_PORT, "127.0.0.1", r)); +const BASIS = `https://${CREW}:${HTTPS_PORT}`; + +/* ======================================================================= + DIE BÜHNE — Karten, Untertitel und ein Finger + + Alles davon wird IN DIE SEITE gezeichnet und nicht nachträglich von + ffmpeg darübergelegt. Zwei Gründe: Es benutzt dieselbe Schrift wie + die App (das Video sieht aus wie ein Stück), und Umlaute sind in + ffmpegs Textfilter auf Windows eine eigene Wissenschaft. + ======================================================================= */ +const BUEHNE = ` +(() => { + if (document.getElementById("tk-buehne")) return; + const s = document.createElement("style"); + s.textContent = \` + #tk-buehne{position:fixed;inset:0;z-index:2147483000;pointer-events:none; + font-family:system-ui,"Segoe UI",sans-serif} + #tk-karte{position:absolute;inset:0;display:grid;place-items:center; + background:#05070d;opacity:0;transition:opacity .28s ease;padding:0 34px} + #tk-karte.an{opacity:1} + #tk-karte p{margin:0;text-align:center;font-weight:800;font-size:27px; + line-height:1.24;color:#fff;letter-spacing:-.01em; + text-shadow:0 0 30px rgba(158,222,144,.28)} + #tk-karte p b{color:#9cde90} + #tk-satz{position:absolute;left:16px;right:16px;bottom:210px; + opacity:0;transform:translateY(8px);transition:opacity .22s ease,transform .22s ease} + #tk-satz.an{opacity:1;transform:none} + #tk-satz span{display:inline-block;background:rgba(5,7,13,.86); + border:1px solid rgba(158,222,144,.32);border-radius:12px; + padding:9px 13px;font-weight:700;font-size:18px;line-height:1.32;color:#fff; + box-shadow:0 10px 30px -12px #000} + #tk-finger{position:absolute;width:34px;height:34px;margin:-17px 0 0 -17px; + border-radius:50%;background:rgba(255,255,255,.22); + border:2px solid rgba(255,255,255,.75);opacity:0; + transition:left .45s cubic-bezier(.4,0,.2,1),top .45s cubic-bezier(.4,0,.2,1), + opacity .2s ease,transform .18s ease} + #tk-finger.an{opacity:1} + #tk-finger.tipp{transform:scale(.7);background:rgba(158,222,144,.55)} + \`; + document.head.appendChild(s); + const b = document.createElement("div"); + b.id = "tk-buehne"; + b.innerHTML = '

' + + '
'; + document.body.appendChild(b); +})();`; + +const pw = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); + +const warte = (ms) => new Promise((r) => setTimeout(r, ms)); + +/* ======================================================================= + DIE LECKSUCHE — kein Link darf ins Bild + + Filipe, 27.09.2026: „ohne link bitte. es darf kein link zu sehen sein." + + Das mit dem Auge zu pruefen waere genau die Sorte Kontrolle, die beim + vierten Video nachlaesst. Hier sieht stattdessen der Browser selbst + nach: Nach JEDEM Seitenwechsel wird der sichtbare Text abgesucht. + Findet sich eine Adresse, bricht die Aufnahme ab -- lieber kein Video + als eines, das die verborgene Adresse in die Welt traegt. + + Gesucht wird im SICHTBAREN Text, nicht im Quelltext: Ein `href` steht + nirgends im Bild, ein Link als Beschriftung schon. Und `innerText` + liefert nur, was auch gezeichnet wird -- Verstecktes faellt von + selbst heraus. + ======================================================================= */ +const LECKS = [ + [/dogfather-universe/i, "die Adresse des Hauses"], + [/https?:\/\//i, "eine vollstaendige Adresse"], + [/\bwww\./i, "ein www-Name"], + [/[a-z0-9-]+\.(com|de|net|org|app|dev)\b/i, "ein Domainname"], +]; + +async function lecksuche(seite, woher) { + /* GEGENPROBE (PROBE_LECK=ja): schiebt eine Adresse ins Bild. Eine + Suche, die nur „nichts gefunden" sagen kann, hat noch nichts + bewiesen -- damit laesst sich zeigen, dass sie auch anschlaegt. */ + if (process.env.PROBE_LECK === "ja") { + await seite.evaluate(() => { + const p2 = document.createElement("p"); + p2.textContent = "Probe: https://beispiel.example/seite"; + document.body.prepend(p2); + }); + } + const text = await seite.evaluate(() => document.body?.innerText || ""); + const funde = []; + for (const [muster, was] of LECKS) { + const t = text.match(muster); + if (t) funde.push(`${was}: "${t[0]}"`); + } + if (funde.length) { + console.error(` +ABBRUCH -- auf ${woher} steht etwas im Bild, was nicht ins Video darf:`); + for (const f of funde) console.error(" " + f); + console.error("Kein Video wird geschrieben."); + process.exit(2); + } +} + +function buehneFuer(seite) { + const auf = () => seite.evaluate(BUEHNE); + return { + auf, + /** Eine Vollbild-Karte. `` hebt ein Wort hervor. */ + async karte(html, ms = 2000) { + await auf(); + await seite.evaluate((t) => { + const k = document.getElementById("tk-karte"); + k.querySelector("p").innerHTML = t; + k.classList.add("an"); + }, html); + await warte(ms); + await seite.evaluate(() => document.getElementById("tk-karte").classList.remove("an")); + await warte(320); + }, + /** Untertitel unten — über der TikTok-Bedienleiste. */ + async satz(text, ms = 2200) { + await auf(); + await seite.evaluate((t) => { + const s = document.getElementById("tk-satz"); + s.querySelector("span").textContent = t; + s.classList.add("an"); + }, text); + await warte(ms); + await seite.evaluate(() => document.getElementById("tk-satz").classList.remove("an")); + await warte(240); + }, + async satzWeg() { + await seite.evaluate(() => document.getElementById("tk-satz")?.classList.remove("an")); + }, + /** Finger hinbewegen, tippen, klicken. */ + async tippen(wahl, { klick = true } = {}) { + await auf(); + const ziel = seite.locator(wahl).first(); + const kasten = await ziel.boundingBox().catch(() => null); + if (!kasten) return false; + const x = Math.round(kasten.x + kasten.width / 2); + const y = Math.round(kasten.y + Math.min(kasten.height / 2, 26)); + await seite.evaluate(([a, b2]) => { + const f = document.getElementById("tk-finger"); + f.style.left = a + "px"; f.style.top = b2 + "px"; + f.classList.add("an"); + }, [x, y]); + await warte(520); + await seite.evaluate(() => document.getElementById("tk-finger").classList.add("tipp")); + await warte(180); + if (klick) await ziel.click({ timeout: 8000 }).catch(() => {}); + await seite.evaluate(() => { + const f = document.getElementById("tk-finger"); + f.classList.remove("tipp"); f.classList.remove("an"); + }); + await warte(260); + return true; + }, + /** Sanft scrollen — in kleinen Schritten, damit es fließt. */ + async rollen(px, ms = 1600) { + const schritte = Math.max(8, Math.round(ms / 40)); + for (let i = 0; i < schritte; i++) { + await seite.evaluate((p) => window.scrollBy(0, p), px / schritte); + await warte(ms / schritte); + } + }, + }; +} + +/* ======================================================================= + DIE VIER DREHBÜCHER + ======================================================================= */ +/* Der Schluss. KEIN LINK -- das war die Ansage. Ein Kommentarwort + ist ohnehin der bessere Weg: Es faellt dem Wecker von TikTok auf + (Kommentare zaehlen), und Filipe sieht, wer wirklich will. */ +const CTA = "Zugang gibt es im Live.
Kommentier RUDEL."; + +const FILME = { + 1: { + datei: "1-zuhause", + titel: "Das Zuhause", + async lauf(seite, b) { + await b.karte("Ein Kommentar ist nach
drei Sekunden weg.", 2100); + await b.karte("Also hab ich dem Rudel
ein Zuhause gebaut.", 2100); + await seite.goto(`${BASIS}/workspace/start.html`, { waitUntil: "networkidle" }); + await warte(800); + await b.satz("Vierzehn Tueren. Kein Discord.", 1900); + await b.rollen(560, 2100); + await b.satz("Chat. Anschlagbrett. Was ansteht.", 1900); + await b.rollen(600, 2100); + await b.satz("Eure Clips. Eure Wuensche.", 1900); + await b.rollen(620, 2100); + await b.satz("Und ein Weg, wenn was nicht stimmt.", 2000); + await b.rollen(420, 1500); + await b.karte("Das ist nicht mein Kanal.
Das ist unsers.", 2200); + await b.karte(CTA, 2400); + }, + }, + + 2: { + datei: "2-nachtruhe", + titel: "Der Chat, der schlaeft", + nachtruhe: true, + async lauf(seite, b) { + await b.karte("Jede App will, dass du
um drei noch wach bist.", 2200); + await b.karte("Meine schickt euch
ins Bett
.", 2000); + await seite.goto(`${BASIS}/workspace/chat.html?raum=treff`, { waitUntil: "networkidle" }); + await warte(1600); + await b.satz("Null bis sechs schlaeft der Rudel-Chat.", 2300); + await b.rollen(240, 1300); + await b.satz("Fuer alle. Auch fuer mich.", 2100); + await warte(500); + await b.satz("Lesen geht. Schreiben nicht.", 2100); + await b.karte("Weil ihr morgen auch noch
ein Leben habt.", 2400); + await b.karte(CTA, 2400); + }, + }, + + 3: { + datei: "3-jedes-nur-einmal", + titel: "Jedes nur einmal", + async lauf(seite, b) { + await b.karte("Ich hab Clips und Bilder
fuer euch zum Posten.", 2100); + await b.karte("Aber jeder kriegt
nur eins.", 1900); + await seite.goto(`${BASIS}/workspace/material.html`, { waitUntil: "networkidle" }); + await warte(1100); + await b.satz("Dogi-Media. Fertig zum Hochladen.", 2000); + await b.rollen(500, 2200); + await b.satz("Wer eins genommen hat, sieht es nicht mehr.", 2300); + await b.rollen(420, 1700); + await b.karte("Damit nicht einer alles nimmt.
Damit alle was haben.", 2400); + await b.karte(CTA, 2400); + }, + }, + + 4: { + datei: "4-dazugehoeren", + titel: "Dazugehoeren", + async lauf(seite, b) { + await b.karte("Du wuenschst dir was im Live.", 2000); + await b.karte("Und bis morgen
hab ich es vergessen.", 2000); + await seite.goto(`${BASIS}/workspace/bereich.html?b=wunsch`, { waitUntil: "networkidle" }); + await warte(1100); + await b.satz("Jetzt steht es da. Und geht nicht unter.", 2200); + await b.rollen(480, 2100); + await b.satz("Und die anderen sehen, wer mitwill.", 2100); + await b.rollen(380, 1500); + await seite.goto(`${BASIS}/workspace/bereich.html?b=mitmachen`, { waitUntil: "networkidle" }); + await warte(1000); + await b.satz("Wer mehr will als zuschauen:", 2000); + await b.rollen(340, 1500); + await b.karte("Du musst nichts koennen.
Du musst da sein wollen.", 2400); + await b.karte(CTA, 2400); + }, + }, +}; + +/* ======================================================================= + FILMEN + ======================================================================= */ +const gewuenscht = process.argv.slice(2).filter((a) => /^[1-4]$/.test(a)).map(Number); +const liste = gewuenscht.length ? gewuenscht : [1, 2, 3, 4]; + +mkdirSync(ZIEL, { recursive: true }); +const rohOrdner = join(ordner, "roh"); +mkdirSync(rohOrdner, { recursive: true }); + +const browser = await pw.chromium.launch({ + args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, "--ignore-certificate-errors", + "--force-device-scale-factor=3", "--window-size=360,640", "--hide-scrollbars"], +}); + +const fertig = []; +for (const nr of liste) { + const film = FILME[nr]; + if (film.nachtruhe && !(process.env.TREFF_NACHT_AB === "0" && process.env.TREFF_NACHT_BIS === "24")) { + console.log(`\n--- Video ${nr} (${film.titel}) uebersprungen.`); + console.log(" Dafuer muss der Chat schlafen. Noch einmal starten mit:"); + console.log(` TREFF_NACHT_AB=0 TREFF_NACHT_BIS=24 node server/tiktok-videos.mjs ${nr}`); + continue; + } + console.log(`\n--- Video ${nr}: ${film.titel}`); + const kontext = await browser.newContext({ + viewport: null, ignoreHTTPSErrors: true, + recordVideo: { dir: rohOrdner, size: { width: 1080, height: 1920 } }, + }); + await kontext.addCookies(keks.Nachtfalter.split("; ").filter(Boolean).map((k) => { + const [n, ...r] = k.split("="); + return { name: n, value: r.join("="), domain: CREW, path: "/" }; + })); + const seite = await kontext.newPage(); + await seite.setContent(''); + const b = buehneFuer(seite); + await b.auf(); + /* Jeder Seitenwechsel wird abgesucht, bevor die Kamera ihn zeigt. */ + const gotoEcht = seite.goto.bind(seite); + seite.goto = async (...a) => { + const r = await gotoEcht(...a); + await b.auf(); + await lecksuche(seite, String(a[0]).replace(/^https?:\/\/[^/]+/, "")); + return r; + }; + /* Die Bühne muss nach JEDEM Seitenwechsel neu hinein -- sie lebt im + Dokument, und das wird beim Laden ausgetauscht. */ + seite.on("load", () => { seite.evaluate(BUEHNE).catch(() => {}); }); + + await film.lauf(seite, b); + await warte(400); + const video = seite.video(); + await kontext.close(); + const quelle = await video.path(); + const mp4 = join(ZIEL, `${film.datei}.mp4`); + + /* ---------- Schneiden ------------------------------------------------- + yuv420p und die geraden Kantenlängen sind nicht Geschmack: Ohne sie + zeigen manche Telefone ein grünes oder halbes Bild. 30 Bilder je + Sekunde, weil TikTok darunter ruckelt. Ein kurzes Auf- und + Abblenden nimmt dem Anfang die Härte. */ + const dauer = Number(spawnSync("ffprobe", ["-v", "error", "-show_entries", + "format=duration", "-of", "default=nw=1:nk=1", quelle], + { encoding: "utf8" }).stdout || 0); + const aus = spawnSync("ffmpeg", ["-y", "-v", "error", "-i", quelle, + "-vf", `fade=t=in:st=0:d=0.35,fade=t=out:st=${Math.max(0, dauer - 0.5).toFixed(2)}:d=0.5,` + + "scale=1080:1920:flags=lanczos,format=yuv420p", + "-r", "30", "-c:v", "libx264", "-preset", "slow", "-crf", "19", + "-movflags", "+faststart", "-an", mp4], { encoding: "utf8" }); + if (aus.status !== 0) { + console.error("ffmpeg:", (aus.stderr || "").slice(0, 400)); + continue; + } + fertig.push({ nr, titel: film.titel, datei: mp4, sekunden: dauer }); + console.log(` fertig: videos/${film.datei}.mp4 (${dauer.toFixed(1)} s)`); +} + +await browser.close(); +await new Promise((r) => vorbau.close(r)); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } + +console.log("\n========================================="); +for (const f of fertig) { + console.log(` ${f.nr}. ${f.titel.padEnd(24)} ${f.sekunden.toFixed(1)} s`); +} +console.log(`\n Ordner: ${ZIEL}`); +process.exit(0); diff --git a/server/workspace-notizen.js b/server/workspace-notizen.js index f9b7fe28..ac4f11f5 100644 --- a/server/workspace-notizen.js +++ b/server/workspace-notizen.js @@ -60,13 +60,33 @@ function angemeldet(req, res, next) { next(); } -/** Team-Adresse und passende Rolle — sonst gibt es diese Seite nicht. +/** Nicht das andere Haus, und eine passende Rolle — sonst gibt es + * diese Seite nicht. * * 404 UND NICHT 403: Ein „verboten" verrät, dass es hier etwas gibt. * Auf der Agenturadresse soll der Block nicht einmal als verschlossene - * Tür sichtbar sein. */ + * Tür sichtbar sein. + * + * ABGEWIESEN WIRD DAS ANDERE HAUS, NICHT „ALLES AUSSER CREW" + * (27.09.2026, gefunden von pruef-handy). + * + * Erst stand hier `haus !== "crew"`. Das klingt gleich und ist es + * nicht: Auf einer Prüfadresse (127.0.0.1) hat niemand ein Haus — + * `person.haus` ist dort `null`, und zwar absichtlich, damit die + * Prüfungen des Hauses nicht still blind werden. Mit der alten + * Fassung antwortete deshalb JEDER Aufruf des Blocks auf der + * Prüfadresse mit 404: Die Seite lud, der Block blieb leer, und in + * der Browserkonsole stand ein Fehler. pruef-handy hat ihn gemeldet, + * drei Gerätebreiten lang — und es war kein Anzeigefehler, sondern + * eine Seite ohne Daten. + * + * `hausWo()` in workspace.js macht es seit dem 24.09. richtig herum: + * Ist das Haus weder crew noch agentur, wird NICHT gefiltert. Diese + * Schranke folgt jetzt derselben Regel. Die Trennung bleibt + * unverändert scharf — auf `workspace.` ist das Haus „agentur", und + * das wird abgewiesen; pruef-notizen weist beides nach. */ function nurTeamDogi(req, res, next) { - if (req.person?.haus !== "crew") return res.status(404).json({ fehler: "nicht_gefunden" }); + if (req.person?.haus === "agentur") return res.status(404).json({ fehler: "nicht_gefunden" }); if (!MIT_BLOCK.has(req.person?.rolle)) return res.status(404).json({ fehler: "nicht_gefunden" }); next(); } diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index 0057c649..c23f447b 100644 --- a/workspace/anruf-probe.html +++ b/workspace/anruf-probe.html @@ -16,8 +16,8 @@ verdeckt. Gefunden hat es pruef-struktur. Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. --> - - + + - +