diff --git a/server/index.js b/server/index.js index ad8e211e..8f8ec848 100644 --- a/server/index.js +++ b/server/index.js @@ -53,6 +53,7 @@ import { checklisteRouter } from "./workspace-checkliste.js"; import { teamlageRouter } from "./workspace-teamlage.js"; import { pushRouter, pushStarten } from "./workspace-push.js"; import { chatRouter } from "./workspace-chat.js"; +import { videoRouter } from "./workspace-video.js"; import { leistungRouter } from "./workspace-leistung.js"; import { fruehwarnungRouter } from "./workspace-fruehwarnung.js"; import { icsRouter } from "./workspace-ics.js"; @@ -211,6 +212,12 @@ app.use(pushRouter); alle Chat-Wege still 404 und niemand sieht warum. Der zweite Fall ist der unangenehmere: Die Seite laedt, der Chat bleibt leer. */ app.use(chatRouter); +/* DER VIDEO-WEG (17.09.2026), Stufe A des Video-Plans. Er liegt VOR + dem Bereichs-Router: Beide horchen auf `/workspace/api/bereich/...`, + und `/:bereich/:id` wuerde "video" sonst fuer eine Nummer halten -- + derselbe Fehler, der heute frueh beim Antwort-Weg drei Anlaeufe + gekostet hat. */ +app.use(videoRouter); /* 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/pruef-video.mjs b/server/pruef-video.mjs new file mode 100644 index 00000000..f6044b4b --- /dev/null +++ b/server/pruef-video.mjs @@ -0,0 +1,344 @@ +/* ===================================================================== + 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); diff --git a/server/workspace-bereiche.js b/server/workspace-bereiche.js index 78b3773d..2367e64f 100644 --- a/server/workspace-bereiche.js +++ b/server/workspace-bereiche.js @@ -1047,7 +1047,8 @@ function sichtbarRoh2(person, praefix = "e") { } const SPALTEN = ` - e.id, e.bereich, e.art, e.titel, e.text, e.datum, e.uhrzeit, e.aus_eintrag_id, e.bewertung, + e.id, e.bereich, e.art, e.titel, e.text, e.datum, e.uhrzeit, e.aus_eintrag_id, + e.quelle_url, e.quelle_kanal, e.bewertung, e.dringlichkeit, e.status, e.creator_id, e.erstellt, e.erstellt_von, e.geaendert, e.hook, e.format, e.saeule_id, e.geplant, e.event_ende, e.event_aufgaben, e.event_regeln, diff --git a/server/workspace-video.js b/server/workspace-video.js new file mode 100644 index 00000000..5e44e796 --- /dev/null +++ b/server/workspace-video.js @@ -0,0 +1,232 @@ +/* ===================================================================== + EIN VIDEO IN DIE APP HOLEN (17.09.2026) — Stufe A des Video-Plans + + Filipe: „ich will dass sobald ich ein video poste es sofort in der + app erscheint für die ganze community … und dass jeder sofort von da + auch aufs video kommen kann um dann zu liken … am besten so, dass man + das Coverbild und den Text sieht." + + Der Weg hier ist der, der HEUTE funktioniert, ohne Freigabe und ohne + Geheimnis: Jemand fügt die Adresse ein, alles andere geht von allein. + + Link → oEmbed (Titel, Cover-Adresse, Account) + → Cover HERUNTERLADEN und bei uns ablegen + → Eintrag bei „Highlights" mit Bild, Text und Knopf + + --------------------------------------------------------------------- + WARUM DAS COVER KOPIERT WIRD — der wichtigste Satz dieser Datei + + TikToks Bildadresse ist signiert und läuft ab. Gemessen am + 17.09.2026 an einem echten Video: + + x-expires = 1789812000 → in 48 Stunden + + Wer sie nur speichert, hat übermorgen schwarze Kacheln. Das merkt + man beim Bauen nicht und beim Testen nicht — es fällt erst am + übernächsten Tag auf, und dann sieht die ganze App kaputt aus. + + Deshalb wandert das Bild in dieselbe Ablage wie jeder Anhang, und + es geht durch dieselbe Byte-Prüfung: Ein heruntergeladenes Cover ist + eine FREMDE Datei, und für fremde Dateien wurde die Prüfung gebaut. + + --------------------------------------------------------------------- + UND NUR SEINE EIGENEN KANÄLE + + Der Account aus der oEmbed-Antwort wird gegen `KANAELE` geprüft + (`kanalVonHandle`). Ein fremdes Video hat in Filipes Highlights + nichts zu suchen — und das ist kein Sonderfall, sondern der + wichtigste Ausgang dieser Datei. + + Aufrufen: POST /workspace/api/bereich/:bereich/video { url } + ===================================================================== */ + +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, DATEN_ORDNER, + KANAELE, kanalVonHandle, TREFF_BRETTER, +} from "./workspace.js"; +import { darfSchreiben } from "./workspace-treff.js"; +import { bildArtVon } from "./workspace-dateien.js"; + +export const videoRouter = express.Router(); + +const ORDNER = join(DATEN_ORDNER, "dateien"); +try { mkdirSync(ORDNER, { recursive: true }); } catch { /* gibt es schon */ } + +/* GRENZEN FÜR DEN WEG NACH DRAUSSEN. Ein fremder Dienst, der nicht + antwortet, darf unsere Anfrage nicht mit sich ziehen -- genau daran + ist in diesem Haus schon ein ganzer Pruefdurchlauf drei Stunden lang + haengengeblieben. */ +const FRIST_MS = 8000; +const COVER_MAX = 4 * 1024 * 1024; + +function angemeldet(req, res, next) { + const person = sitzungLesen(req); + if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); + req.person = person; + next(); +} +videoRouter.use("/workspace/api/bereich", angemeldet); + +/** Ist das ueberhaupt eine TikTok-Adresse? Keine Zeichenkettensuche, + * sondern der Rechnername -- "tiktok.com.beispiel.de" enthaelt + * "tiktok.com" und gehoert jemand anderem. */ +export function istTikTok(roh) { + try { + const u = new URL(String(roh)); + const h = u.hostname.toLowerCase(); + /* NUR IN EINER PRUEFUNG: Dann liegt der nachgebaute Dienst auf + 127.0.0.1, und die Adresse des "Videos" auch. Ohne diese Zeile + muesste die Pruefung die echte Regel umgehen -- und pruefte + damit eine andere als die, die im Betrieb gilt. */ + if (process.env.TIKTOK_OEMBED_BASIS && (h === "127.0.0.1" || h === "localhost")) { + return true; + } + if (u.protocol !== "https:") return false; + return h === "tiktok.com" || h.endsWith(".tiktok.com"); + } catch { return false; } +} + +/** Mit Frist holen. Ohne sie haengt die Anfrage am fremden Dienst. */ +async function holeMitFrist(adresse, kopf = {}) { + const abbruch = new AbortController(); + const uhr = setTimeout(() => abbruch.abort(), FRIST_MS); + try { + return await fetch(adresse, { signal: abbruch.signal, headers: kopf, redirect: "follow" }); + } finally { clearTimeout(uhr); } +} + +/* WOHIN GEFRAGT WIRD. Im Betrieb TikTok; in einer Pruefung ein + nachgebauter Dienst auf 127.0.0.1. + + WARUM UMSTELLBAR: Eine Pruefung, die das echte TikTok braucht, ist + langsam, haengt am Netz und misst fremde Verfuegbarkeit statt + unseren Code. Sie waere die Sorte Pruefung, die irgendwann rot wird, + ohne dass etwas kaputt ist -- und dann schaltet man sie ab. + + Die Umgebungsvariable wird NUR von Pruefungen gesetzt. Steht sie + nicht da, gilt TikTok -- kein Verhalten, das sich still aendert. */ +const OEMBED_BASIS = process.env.TIKTOK_OEMBED_BASIS || "https://www.tiktok.com/oembed"; + +/** Die Auskunft zu einem Video: Titel, Cover-Adresse, Account. */ +export async function videoAuskunft(url) { + const a = await holeMitFrist( + `${OEMBED_BASIS}?url=${encodeURIComponent(url)}`, + { accept: "application/json" }); + if (!a.ok) return { fehler: `TikTok antwortet nicht (${a.status}).` }; + const j = await a.json().catch(() => null); + if (!j || !j.title) return { fehler: "TikTok hat nichts zu diesem Video geschickt." }; + /* Der Account steht in `author_url` (…/@name). `author_name` ist der + ANZEIGENAME und aendert sich, wenn jemand ihn umstellt -- als + Schluessel waere er die falsche Wahl. */ + const handle = String(j.author_url || "").split("/@")[1] || ""; + return { + titel: String(j.title).slice(0, 160), + cover: j.thumbnail_url || null, + handle: handle ? `@${handle}` : "", + kanal: kanalVonHandle(handle), + }; +} + +/** Das Cover holen und ablegen. Gibt den Dateinamen zurueck -- oder + * `null`, wenn es kein Bild ist. */ +async function coverAblegen(adresse) { + if (!adresse) return null; + let a; + try { a = await holeMitFrist(adresse); } catch { return null; } + if (!a.ok) return null; + const lang = Number(a.headers.get("content-length") || 0); + if (lang > COVER_MAX) return null; + const roh = Buffer.from(await a.arrayBuffer()); + if (!roh.length || roh.length > COVER_MAX) return null; + /* DIESELBE BYTEPRUEFUNG WIE BEI HOCHGELADENEN DATEIEN. Was von + draussen kommt, ist von draussen -- egal ob jemand es hochlaedt + oder wir es holen. `dateien.typ` waere hier sogar noch + wertloser: 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 }; +} + +videoRouter.post("/workspace/api/bereich/:bereich/video", async (req, res) => { + let abgelegt = null; + try { + const bereich = String(req.params.bereich); + if (!TREFF_BRETTER.includes(bereich)) { + return res.status(404).json({ fehler: "nicht_gefunden" }); + } + const warum = darfSchreiben(req.person, bereich); + if (warum) return res.status(403).json({ fehler: warum }); + + const url = String(req.body?.url ?? "").trim(); + if (!istTikTok(url)) { + return res.status(400).json({ fehler: "Das ist keine TikTok-Adresse." }); + } + + /* SCHON DA? Derselbe Link zweimal soll dasselbe Ergebnis geben, + nicht zwei Eintraege. Wer den Knopf zweimal drueckt, meint nicht + zwei Videos. */ + const schon = db().prepare( + "SELECT id FROM eintraege WHERE quelle_url = ? AND bereich = ?").get(url, bereich); + if (schon) return res.status(200).json({ id: schon.id, schon: true }); + + const auskunft = await videoAuskunft(url); + if (auskunft.fehler) return res.status(502).json({ fehler: auskunft.fehler }); + + /* NUR SEINE EIGENEN KANAELE. Ein fremdes Video in Filipes + Highlights waere nicht bloss falsch einsortiert -- es waere ein + fremder Inhalt unter seinem Namen. */ + if (!auskunft.kanal) { + return res.status(403).json({ + fehler: `„${auskunft.handle || "Dieser Account"}“ ist keiner deiner Kanäle. ` + + `Möglich sind: ${KANAELE.map((k) => k.handle).join(", ")}.`, + }); + } + + abgelegt = await coverAblegen(auskunft.cover); + + const jetzt = new Date().toISOString(); + const arten = { highlight: "clip", anschlag: "hinweis", treff: "lob" }; + const { lastInsertRowid } = db().prepare(` + INSERT INTO eintraege + (bereich, art, titel, datum, status, erstellt, erstellt_von, + quelle_url, quelle_kanal, quelle_geholt_am) + VALUES (?,?,?,?, 'offen', ?,?,?,?,?)`) + .run(bereich, arten[bereich] || "clip", auskunft.titel, jetzt.slice(0, 10), + jetzt, req.person.id, url, auskunft.kanal, jetzt); + const eintragId = Number(lastInsertRowid); + + if (abgelegt) { + db().prepare(` + INSERT INTO dateien (name_original, name_datei, groesse, typ, status, notiz, + hochgeladen_von, erstellt, eintrag_id) + VALUES (?,?,?,?, 'entwurf', NULL, ?,?,?)`) + .run(`cover-${auskunft.kanal}${abgelegt.name.slice(abgelegt.name.lastIndexOf("."))}`, + abgelegt.name, abgelegt.groesse, abgelegt.art, req.person.id, jetzt, eintragId); + abgelegt = null; /* ab hier gehoert die Datei dem Eintrag */ + } + + protokolliere("video_eingelesen", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `${auskunft.kanal} ${url}`.slice(0, 120), + }); + res.status(201).json({ + id: eintragId, schon: false, kanal: auskunft.kanal, + titel: auskunft.titel, cover: !!abgelegt || undefined, + }); + } catch (fehler) { + /* GING ETWAS SCHIEF, NACHDEM DIE DATEI SCHON LAG: wegraeumen. Eine + Datei ohne Eintrag findet niemand wieder und niemand loescht sie + -- sie liegt einfach fuer immer da. */ + if (abgelegt) { try { unlinkSync(join(ORDNER, abgelegt.name)); } catch { /* egal */ } } + console.error("[workspace] Video einlesen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); diff --git a/server/workspace.js b/server/workspace.js index d6c38bdc..9827c534 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -1298,17 +1298,37 @@ export const MODI_STUFEN = [ Eintrag ist schlechter als ein leerer. ===================================================================== */ export const KANAELE = [ - { wert: "dogfather", name: "DogFather", + { wert: "dogfather", name: "DogFather", handle: "@dogfather0804", text: "Der Hauptkanal. Die Livestreams und alles, was unmittelbar " + "daran hängt." }, - { wert: "hasidog", name: "HasiDog", + { wert: "hasidog", name: "HasiDog", handle: "@hasidog00804", text: "Der zweite Account — eigene Zuschauer, eigener Ton. Was hier " + "läuft, ist nicht einfach dasselbe noch einmal." }, - { wert: "clips", name: "DogFather Clips", + { wert: "clips", name: "DogFather Clips", handle: "@dogis.modi.gang", text: "Nur Ausschnitte. Hier wird geschnitten, beschriftet und " + "hochgeladen, nicht gesendet." }, ]; +/** Zu welchem Kanal gehoert dieser TikTok-Name? + * + * DIE HANDLES STEHEN AM KANAL UND NICHT IN EINER ZWEITEN LISTE + * (17.09.2026). Der Video-Plan nennt genau das als die eine Stelle, + * an der etwas auseinanderlaufen kann: Sie stehen ohnehin schon in + * der Analyse-App (`TIKTOK_ACCOUNTS_LIST`). Wer einen vierten Account + * aufmacht und nur einen der beiden Orte pflegt, bekommt Videos, die + * zu keinem Kanal gehoeren. + * + * Hier gibt es deshalb genau EINE Liste, und sie haengt an dem + * Begriff, zu dem sie gehoert. + * + * `null` heisst: gehoert uns nicht. Das ist KEIN Sonderfall, sondern + * der wichtigste Ausgang -- ein fremdes Video hat in Filipes + * Highlights nichts zu suchen. */ +export function kanalVonHandle(handle) { + const h = String(handle || "").trim().toLowerCase().replace(/^@?/, "@"); + return KANAELE.find((k) => k.handle.toLowerCase() === h)?.wert ?? null; +} + /** Wer darf mit Kanaelen arbeiten? Dieselbe Runde wie bei den * Kategorien -- das Team und DogFather. Fuer alle anderen kommt die * Liste gar nicht erst mit, statt mitzukommen und ausgeblendet zu @@ -1766,6 +1786,30 @@ function umstellungen(d) { falschen Moment. */ const jetztStempel = new Date().toISOString().slice(0, 19).replace(/[-:T]/g, ""); + /* ---- Woher ein Eintrag kommt: das Video (17.09.2026) ---- + + Stufe A des Video-Plans. `quelle_url` ist die Adresse bei TikTok, + `quelle_kanal` einer der drei Kanaele, `quelle_geholt_am` der + Zeitpunkt des Einlesens. + + DAS COVERBILD STEHT HIER NICHT. Es liegt als Datei in unserer + eigenen Ablage (`dateien.eintrag_id`), und das ist der ganze + Punkt: TikToks Bildadresse traegt `x-expires` und haelt + GEMESSEN 48 Stunden. Wer sie speichert, hat uebermorgen schwarze + Kacheln -- und merkt es beim Bauen nicht und beim Testen nicht. */ + try { + const spalten = d.prepare("PRAGMA table_info(eintraege)").all().map((s) => s.name); + for (const [name, typ] of [["quelle_url", "TEXT"], ["quelle_kanal", "TEXT"], + ["quelle_geholt_am", "TEXT"]]) { + if (spalten.length && !spalten.includes(name)) { + d.exec(`ALTER TABLE eintraege ADD COLUMN ${name} ${typ}`); + console.log(`[workspace] Spalte '${name}' an den Eintraegen ergaenzt.`); + } + } + } catch (fehler) { + console.error("[workspace] Video-Spalten:", fehler?.message); + } + /* ---- Antworten an einem Beitrag (17.09.2026), Stufe 6 ---- "Der Treff -- Hallo sagen, fragen, loben" war eine Liste aus diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest index e0b39493..de3724f5 100644 --- a/workspace/app.webmanifest +++ b/workspace/app.webmanifest @@ -13,25 +13,25 @@ "theme_color": "#06090f", "icons": [ { - "src": "/assets/img/app-symbole/workspace-192.png?v=202609171211", + "src": "/assets/img/app-symbole/workspace-192.png?v=202609171249", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-512.png?v=202609171211", + "src": "/assets/img/app-symbole/workspace-512.png?v=202609171249", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609171211", + "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609171249", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609171211", + "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609171249", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/assets/css/bereich.css b/workspace/assets/css/bereich.css index 0f4ae0f2..f0c5906f 100644 --- a/workspace/assets/css/bereich.css +++ b/workspace/assets/css/bereich.css @@ -1123,3 +1123,44 @@ #liste[data-aushang="ja"] .eintrag-karte { padding: 17px 16px; } #liste[data-aushang="ja"] .eintrag-titel { font-size: 1.12rem; } } + +/* ===================================================================== + EIN VIDEO EINLESEN (17.09.2026) — Stufe A des Video-Plans + ------------------------------------------------------------------- */ +.videoholen { + margin: 0 0 20px; padding: 16px 18px; + border: 1px solid var(--rand); + border-radius: var(--radius); + background: var(--flaeche); +} +.videoholen__form { margin: 0; } +.videoholen__zeile { display: flex; gap: 10px; } +.videoholen__zeile input { + flex: 1 1 auto; min-width: 0; + font: inherit; font-size: .9rem; +} +.videoholen__zeile .knopf { flex: 0 0 auto; } + +/* Der Knopf zum Video. Er ist die Antwort auf „und dann liken": Er + führt dorthin, wo das Liken zählt. Deshalb steht er deutlich da und + nicht als kleiner Textlink unter dem Datum. */ +.videozeile { + display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; + margin-top: 12px; +} +.videozeile__kanal { + --v-ton: #6f89a8; + padding: 2px 10px; border-radius: 999px; + font-size: .74rem; font-weight: 650; letter-spacing: .02em; + white-space: nowrap; + color: color-mix(in srgb, var(--v-ton) 76%, #ffffff); + background: color-mix(in srgb, var(--v-ton) 13%, transparent); + border: 1px solid color-mix(in srgb, var(--v-ton) 32%, transparent); +} +/* Dieselben drei Töne wie am Kanal einer Aufgabe — ein Kanal sieht im + ganzen Haus gleich aus, sonst lernt man sie zweimal. */ +.videozeile__kanal[data-kanal="dogfather"] { --v-ton: #c2544f; } +.videozeile__kanal[data-kanal="hasidog"] { --v-ton: #7d6bbd; } +.videozeile__kanal[data-kanal="clips"] { --v-ton: #3f9c8a; } + +.videozeile__weg { flex: 0 0 auto; text-decoration: none; } diff --git a/workspace/assets/js/bereich.js b/workspace/assets/js/bereich.js index f5810f74..0947ccbc 100644 --- a/workspace/assets/js/bereich.js +++ b/workspace/assets/js/bereich.js @@ -56,6 +56,12 @@ /* Wie ein Brett in einem Satz heisst. Kurz, weil es mitten in einer Zeile steht: „Aus Wunschliste: …" liest sich, „Aus dem Bereich Wunschliste (wunsch): …" nicht. */ + /* Wie ein Kanal auf der Karte heisst. Kurz -- er steht neben einem + Knopf, nicht in einer Ueberschrift. */ + const KANAL_WORT = { + dogfather: 'DogFather', clips: 'DogFather Clips', hasidog: 'HasiDog', + }; + const BRETT_WORT = { anschlag: 'Anschlagbrett', ansteht: 'Was ansteht', treff: 'Treff', wunsch: 'Wunschliste', highlight: 'Highlights', regeln: 'Regeln', @@ -388,6 +394,29 @@ k.append(kopf); + /* ---- DAS VIDEO (17.09.2026), Stufe A des Video-Plans ---------- + Der Knopf ist die Antwort auf "und dann liken": Er fuehrt + dorthin, wo das Liken zaehlt. Ein Herz bei uns, das bei TikTok + nichts bewirkt, waere eine Attrappe. + + `rel="noopener"` ist Pflicht: Ohne das kann die geoeffnete Seite + ueber `window.opener` auf unsere zugreifen. */ + if (e.quelle_url) { + const z = el('div', 'videozeile'); + if (e.quelle_kanal) { + const k = el('span', 'videozeile__kanal', + KANAL_WORT[e.quelle_kanal] || e.quelle_kanal); + k.dataset.kanal = e.quelle_kanal; + z.append(k); + } + const a = el('a', 'schritt videozeile__weg', 'Auf TikTok ansehen →'); + a.href = e.quelle_url; + a.target = '_blank'; + a.rel = 'noopener noreferrer'; + z.append(a); + k.append(z); + } + /* ---- DIE KETTE (17.09.2026), Stufe 8 des Plans ----------------- Woher kam das hier, und was ist daraus geworden? Beides steht auf der Karte, nicht in einem Detail: Der Zusammenhang ist die @@ -914,6 +943,53 @@ } catch { uebernahmen = {}; uebernahmeMoeglich = false; } } + /* ---- EIN VIDEO EINLESEN --------------------------------------- + Link einfuegen, den Rest macht der Server. Nur bei den Highlights: + Dort gehoeren Clips hin, und ein Feld an jedem anderen Brett waere + eine Einladung, sie ueberall hinzuschreiben. */ + function videoFeldZeigen() { + const ziel = $('videoholen'); + if (!ziel) return; + ziel.hidden = !(bereich === 'highlight' && darfEintragen()); + } + + function videoFeldBedienen() { + const form = $('video-form'); + if (!form || form.dataset.bereit === 'ja') return; + form.dataset.bereit = 'ja'; + form.addEventListener('submit', async (ev) => { + ev.preventDefault(); + const feld = $('video-url'); + const knopf = $('video-holen'); + const url = feld.value.trim(); + if (!url) return; + knopf.disabled = true; + /* "Wird geholt" statt eines stillen Wartens: Der Server fragt + TikTok, und das dauert einen Moment. Ein Knopf, der nichts tut + und nichts sagt, wird ein zweites Mal gedrueckt. */ + const hinweis = $('video-hinweis'); + const alterText = hinweis?.textContent || ''; + if (hinweis) hinweis.textContent = 'Wird geholt …'; + try { + const a = await hole(`/workspace/api/bereich/${bereich}/video`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url }), + }); + const r = await a.json().catch(() => ({})); + if (!a.ok) { + melde(r.fehler || 'Ging nicht.'); + if (hinweis) hinweis.textContent = alterText; + knopf.disabled = false; + return; + } + feld.value = ''; + if (hinweis) hinweis.textContent = r.schon + ? 'Das Video war schon da.' : alterText; + await laden(); + } finally { knopf.disabled = false; } + }); + } + /* ---- DER STARTKATALOG ------------------------------------------------ Fertige Texte, die jemand aus dem Team uebernimmt. Nicht automatisch: Ein Text, den niemand gelesen hat, stuende sonst als @@ -1017,6 +1093,8 @@ alles uebernommen ist -- und das haengt an dem, was gerade geladen wurde. */ startkatalogZeigen(); + videoFeldZeigen(); + videoFeldBedienen(); maxStimmen = (daten.eintraege || []) .reduce((n, e) => Math.max(n, Number(e.stimmen) || 0), 0); diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 7a823df6..603a931d 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -12,19 +12,19 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - + + + + + + - + @@ -291,10 +291,10 @@ - - - - - + + + + +