Filipe: "damit meine videos auch da auf der app rein kommen ... sobald
ich ein video poste erscheint es sofort in der app fuer die ganze
community ... dass man das Coverbild und den Text sieht ... fuer meinen
Account, dann DogFather Clips und HasiDog."
Der Weg, der HEUTE funktioniert -- ohne Freigabe, ohne Geheimnis, ohne
Wartezeit: Adresse einfuegen, alles andere geht von allein.
Link -> oEmbed (Titel, Cover-Adresse, Account)
-> Cover HERUNTERLADEN und bei uns ablegen
-> Eintrag mit Bild, Text und Knopf zum Video
DAS COVER WIRD KOPIERT, NICHT VERLINKT -- der wichtigste Satz.
TikToks Bildadresse ist signiert und laeuft ab. Heute an einem echten
Video nachgemessen: x-expires = 1789812000, also in 48 Stunden. Wer sie
nur speichert, hat uebermorgen schwarze Kacheln. Beim Bauen merkt man
das nicht und beim Testen auch nicht -- es faellt erst am uebernaechsten
Tag auf, und dann sieht die ganze App kaputt aus.
Deshalb wandert das Bild in dieselbe Ablage wie jeder Anhang und geht
durch dieselbe Bytepruefung. Ein heruntergeladenes Cover ist eine
FREMDE Datei; dass der Typ hier von einem fremden Server behauptet wird
statt vom eigenen Browser, macht ihn nicht glaubwuerdiger.
NUR SEINE EIGENEN KANAELE. Der Account aus der Antwort wird gegen
KANAELE geprueft (kanalVonHandle). Genommen wird author_url (.../@name),
nicht author_name -- der Anzeigename aendert sich, wenn jemand ihn
umstellt. Ein fremdes Video in Filipes Highlights waere nicht bloss
falsch einsortiert, es waere fremder Inhalt unter seinem Namen: 403.
DIE PRUEFUNG SCHALTET TIKTOK AB (server/pruef-video.mjs, 37 Pruefungen)
Ein nachgebauter Dienst auf 127.0.0.1 antwortet wie TikTok, mitsamt
Ablaufstempel. Im letzten Abschnitt wird er ABGESCHALTET. Danach muss
die alte Cover-Adresse ins Leere laufen und unsere weiterhin ein Bild
liefern -- beides wird gemessen, nebeneinander. Waere das Bild nur
verlinkt, waeren beide tot, genau wie uebermorgen im Betrieb.
Die Adresse des Dienstes kommt aus TIKTOK_OEMBED_BASIS. Steht sie nicht
da, gilt TikTok -- kein Verhalten, das sich still aendert. Eine
Pruefung, die das echte TikTok braucht, misst fremde Verfuegbarkeit
statt unseren Code und wird irgendwann rot, ohne dass etwas kaputt ist.
VIER GEGENPROBEN, weil 37 von 37 im ersten Lauf kein Beweis ist:
a) Cover nicht mehr kopieren -> 9 Pruefungen rot
b) author_name statt author_url -> 22 rot
c) Bytepruefung entfernen -> 2 rot (genau Abschnitt 5)
d) Dublettenpruefung entfernen -> 4 rot (genau Abschnitt 4)
Jede schlaegt dort an, wo sie soll. Und eine fuenfte Gegenprobe ist ins
Leere gelaufen: Der Patch lief ueber python3, das es hier nicht gibt --
die Datei blieb unveraendert und der Lauf war gruen. Haette ich die
Ausgabe nicht gelesen, haette ich behauptet, die Pruefung koenne rot
werden, ohne sie je rot gesehen zu haben. Der dritte Ausgang, an mir
selbst.
DAZU:
- eintraege.quelle_url / quelle_kanal / quelle_geholt_am (Umstellung)
- KANAELE bekommt handle, dazu kanalVonHandle()
- videoRouter steht VOR bereicheRouter, sonst schluckt /:bereich ihn
- Eingabefeld und Kanal-Knopf in bereich.html/.js, Farben wie bei den
Aufgaben-Kanaelen
- Stempel 202609171249
Nicht darin: die Kanal-Filterzeile in der Galerie, die Handy-Verknuepfung
(Teilen -> Workspace) und der Zustand "nicht mehr da" fuer geloeschte
Videos. Stufe B (Display API) braucht Filipes Entwickler-Freigabe.
Co-Authored-By: Claude Opus 5 <[email protected]>
233 lines
10 KiB
JavaScript
233 lines
10 KiB
JavaScript
/* =====================================================================
|
|
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" });
|
|
}
|
|
});
|