SCHRITT 4 AUS DEM VIDEO-PLAN Filipe postet ein Video auf dem Handy. Bisher: Link kopieren, App oeffnen, Bereich suchen, Feld finden, einfuegen. Fuenf Schritte, von denen jeder einzelne der Grund sein kann, es "spaeter" zu machen -- und spaeter heisst nie. Jetzt: in TikTok auf "Teilen", DogFather waehlen, uebernehmen. Die neue Seite workspace/teilen.html ist das Ziel dieses Tipps, eingetragen als share_target in beiden Manifesten. DER STOLPERSTEIN, DEN JEDE ANLEITUNG VERSCHWEIGT Das Teilen-Ziel kennt drei Felder: title, text, url. Man baut es, testet mit "?url=https://…" und ist fertig. IM ECHTEN LEBEN KOMMT DER LINK IN `text`. Android-Apps -- TikTok eingeschlossen -- teilen als reinen Text: "Sieh dir das an! https://vm.tiktok.com/xyz/" steht dann in `text`, und `url` ist leer. Wer nur `url` liest, hat eine Seite gebaut, die im Test funktioniert und auf dem Handy eine leere Maske zeigt. Gesucht wird deshalb in allen drei Feldern nach einem MUSTER, nicht nach einem Feldnamen -- und ein Satzzeichen am Ende gehoert nicht zur Adresse. SIE TRAEGT NICHT VON SELBST EIN. Die Seite kann nicht wissen, ob der Griff in die Tasche gemeint war. Ein Video, das ungefragt in den Highlights landet, muss jemand wieder loeschen -- das ist mehr Arbeit als ein Tipp auf "Uebernehmen". Und wohin ist eine Frage, keine Annahme: Highlights sind vorausgewaehlt, Treff und Anschlagbrett stehen daneben. GET UND NICHT POST. POST braeuchte einen Service Worker, der die Anfrage abfaengt -- eine Stelle mehr, die kaputtgehen kann, und zwar genau die unsichtbare: Man tippt auf Teilen und landet auf einer leeren Seite. Hier wird nur ein Link geteilt, keine Datei. GESEHEN, NICHT NUR GEZAEHLT Das Bildschirmfoto auf 390 px zeigte einen Mangel, den keine Zahl gemeldet haette: Das gewaehlte Ziel hob sich nur ueber Helligkeit ab. Bei einer echten Wahl ist das zu wenig, und wer Farben schlecht unterscheidet, sieht gar nichts. Jetzt: farbiger Streifen links, deutlicherer Grund -- und ein HAKEN, der ohne jede Farbwahrnehmung funktioniert. EIN SICHERHEITSFUND NEBENBEI -- UND ER IST DER WICHTIGERE pruef-alle-wege meldete: Von 126 schreibenden Wegen hatte genau EINER keine Herkunftspruefung. POST /workspace/api/aufbewahrung/jetzt -> 200 Eine beliebige fremde Seite kann im Browser eines angemeldeten DogFather ein Formular dorthin abschicken; der Browser gibt das Sitzungsplaetzchen mit, der Server fuehrt aus. Und ausgerechnet dieser Weg: aufbewahrungLauf() LOESCHT abgelaufene Daten. Ein Aufruf zur falschen Zeit loescht wirklich etwas, und im Protokoll sieht es aus, als haette DogFather es selbst angestossen. Die anderen 125 Wege hatten den Riegel -- dieser eine wurde beim Hinzufuegen uebersehen. Genau dafuer geht die Pruefung ALLE Wege durch, statt einer Liste zu glauben. Behoben, danach: "kein schreibender Weg nimmt eine fremde Herkunft an". DIE PRUEFUNG (server/pruef-teilen.mjs, 27 Pruefungen) Auf 390 px gemessen -- diese Seite wird fast nur dort geoeffnet, eine Messung auf 1400 px misst etwas, das niemand sieht. Geprueft wird der Satz mit Link darin, das Satzzeichen am Ende, der Fall ohne Link (Feld zum Einfuegen statt Fehlermeldung), das Eintragen, die Dublette, das zweite Brett -- und dass share_target in BEIDEN Manifesten steht. Ohne den Eintrag erscheint der Workspace gar nicht erst im Teilen-Menue, und die ganze Seite waere unerreichbar, ohne dass eine der anderen 26 Pruefungen es merkt. GEGENPROBE: nur `url` lesen statt aller drei Felder -- also genau die naheliegende Fassung, die im Test funktioniert und auf dem Handy versagt -> 8 Pruefungen rot. Stempel 202609171416. Co-Authored-By: Claude Opus 5 <[email protected]>
284 lines
13 KiB
JavaScript
284 lines
13 KiB
JavaScript
/* =====================================================================
|
||
AUS „TEILEN" WIRD EIN TIPP (17.09.2026) — Schritt 4 des Video-Plans
|
||
|
||
Filipe postet ein Video auf dem Handy. Bisher: Link kopieren, App
|
||
öffnen, Bereich suchen, Feld finden, einfügen. Fünf Schritte, von
|
||
denen jeder der Grund sein kann, es „später" zu machen.
|
||
|
||
---------------------------------------------------------------------
|
||
DER FALL, DER DIESE PRÜFUNG RECHTFERTIGT
|
||
|
||
Das Teilen-Ziel kennt drei Felder: `title`, `text`, `url`. Man baut
|
||
es und probiert es mit `?url=https://…` aus — das geht, und man ist
|
||
fertig.
|
||
|
||
IM ECHTEN LEBEN KOMMT DER LINK ABER IN `text`. Android-Apps,
|
||
TikTok eingeschlossen, teilen als reinen Text: „Sieh dir das an!
|
||
https://vm.tiktok.com/xyz/" steht dann in `text`, und `url` ist
|
||
leer. Wer nur `url` liest, hat eine Seite gebaut, die im Test
|
||
funktioniert und auf dem Handy eine leere Maske zeigt.
|
||
|
||
Abschnitt 2 prüft genau diesen Fall — und zwar mit einem Satz
|
||
drumherum, nicht mit einer nackten Adresse.
|
||
|
||
Aufrufen mit: node server/pruef-teilen.mjs
|
||
===================================================================== */
|
||
|
||
import { mkdtempSync, rmSync, readFileSync } 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(4494, "pruef-teilen");
|
||
const TIKTOK = await portMussFreiSein(4503, "nachgebautes TikTok");
|
||
const ordner = mkdtempSync(join(tmpdir(), "ws-teilen-"));
|
||
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`;
|
||
|
||
const PNG = Buffer.from(
|
||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
||
"base64");
|
||
const tiktok = createServer((q, a) => {
|
||
const u = new URL(q.url, `http://127.0.0.1:${TIKTOK}`);
|
||
if (u.pathname === "/oembed") {
|
||
const wer = ((u.searchParams.get("url") || "").match(/@([^/?]+)/) || [])[1] || "";
|
||
if (!wer) { a.writeHead(404); a.end("{}"); return; }
|
||
a.writeHead(200, { "content-type": "application/json" });
|
||
a.end(JSON.stringify({
|
||
version: "1.0", type: "video",
|
||
title: "Casper erwischt den Postboten",
|
||
author_url: `https://www.tiktok.com/@${wer}`,
|
||
thumbnail_url: `http://127.0.0.1:${TIKTOK}/cover.jpg`,
|
||
}));
|
||
return;
|
||
}
|
||
if (u.pathname === "/cover.jpg") {
|
||
a.writeHead(200, { "content-type": "image/jpeg" }); a.end(PNG); 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(240_000, "pruef-teilen");
|
||
await new Promise((r) => setTimeout(r, 900));
|
||
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 } = await import("node:crypto");
|
||
const d = new DatabaseSync(process.env.WORKSPACE_DB);
|
||
const j = new Date().toISOString();
|
||
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,aktiv,erstellt) VALUES (?,?,?,?,?,1,?)")
|
||
.run("Filipe", "admin", hash, salt, 32768, j);
|
||
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 pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
|
||
const browser = await pw.chromium.launch();
|
||
/* AUF HANDYGRÖSSE. Diese Seite wird fast ausschliesslich dort
|
||
geoeffnet -- eine Messung auf 1400 px misst etwas, das niemand
|
||
sieht. */
|
||
const ctx = await browser.newContext({
|
||
viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true,
|
||
});
|
||
await ctx.addCookies(keks.split("; ").map((k) => {
|
||
const [name, ...rest] = k.split("=");
|
||
return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" };
|
||
}));
|
||
const seite = await ctx.newPage();
|
||
const konsolenfehler = [];
|
||
seite.on("console", (m) => { if (m.type() === "error") konsolenfehler.push(m.text()); });
|
||
seite.on("pageerror", (f) => konsolenfehler.push(String(f)));
|
||
|
||
const VIDEO = `http://127.0.0.1:${TIKTOK}/@dogfather0804/video/4711`;
|
||
const oeffne = async (frage) => {
|
||
await seite.goto(`${BASIS}/workspace/teilen.html?${frage}`, { waitUntil: "networkidle" });
|
||
await seite.waitForTimeout(900);
|
||
};
|
||
const gezeigterLink = () => seite.$eval("#url-text", (n) => n.textContent.trim()).catch(() => "");
|
||
const zahlIm = async (bereich) => {
|
||
const a = await fetch(`${BASIS}/workspace/api/bereich/${bereich}`, { headers: { cookie: keks } });
|
||
const x = await a.json().catch(() => null);
|
||
return (x?.eintraege || []).length;
|
||
};
|
||
|
||
/* =======================================================================
|
||
1. DAS SAUBERE FELD
|
||
======================================================================= */
|
||
melde("");
|
||
melde("=== 1. Wenn der Link in „url“ steht ===");
|
||
{
|
||
await oeffne(`url=${encodeURIComponent(VIDEO)}`);
|
||
ok(await gezeigterLink() === VIDEO, "der Link wird erkannt und angezeigt");
|
||
ok(!(await seite.$("#haendisch:not([hidden])")),
|
||
"und das Feld zum Einfügen bleibt weg – es gibt ja nichts einzufügen");
|
||
const bretter = await seite.$$eval(".teilen__brett", (ns) => ns.map((n) => ({
|
||
name: n.querySelector(".teilen__brett-name")?.textContent,
|
||
gewaehlt: n.getAttribute("aria-pressed"),
|
||
})));
|
||
ok(bretter.length === 3, `drei Ziele zur Auswahl (${bretter.length})`);
|
||
ok(bretter[0]?.gewaehlt === "true" && /Highlights/.test(bretter[0]?.name || ""),
|
||
"Highlights ist vorausgewählt – dort gehören Videos hin");
|
||
await seite.screenshot({ path: "server/bild-teilen.png", fullPage: true });
|
||
}
|
||
|
||
/* =======================================================================
|
||
2. DER ECHTE FALL: ein Satz mit Link darin ← der eigentliche Punkt
|
||
======================================================================= */
|
||
melde("");
|
||
melde("=== 2. Wenn TikTok einen Satz teilt ===");
|
||
{
|
||
/* GENAU SO kommt es von einem Android-Handy an: alles in `text`,
|
||
`url` leer, und die Adresse steckt mitten im Satz. */
|
||
const satz = `Sieh dir das an! ${VIDEO} #fyp`;
|
||
await oeffne(`text=${encodeURIComponent(satz)}`);
|
||
ok(await gezeigterLink() === VIDEO,
|
||
`die Adresse wird aus dem Satz herausgeholt (${await gezeigterLink()})`);
|
||
|
||
/* UND MIT EINEM PUNKT DAHINTER. „…/4711." ist keine Adresse mit
|
||
Punkt, sondern ein Satzende. */
|
||
await oeffne(`text=${encodeURIComponent(`Schau mal: ${VIDEO}.`)}`);
|
||
ok(await gezeigterLink() === VIDEO,
|
||
`ein Satzzeichen am Ende gehört nicht zur Adresse (${await gezeigterLink()})`);
|
||
|
||
/* Und im Titel, falls eine App es dort ablegt. */
|
||
await oeffne(`title=${encodeURIComponent(VIDEO)}&text=`);
|
||
ok(await gezeigterLink() === VIDEO, "auch aus dem Titel");
|
||
}
|
||
|
||
/* =======================================================================
|
||
3. GAR KEIN LINK
|
||
======================================================================= */
|
||
melde("");
|
||
melde("=== 3. Wenn nichts dabei ist ===");
|
||
{
|
||
await oeffne("text=nur%20ein%20Text%20ohne%20Adresse");
|
||
ok(!!(await seite.$("#haendisch:not([hidden])")),
|
||
"dann erscheint ein Feld zum Einfügen – kein Fehler, ein Angebot");
|
||
ok(!(await seite.$("#fund:not([hidden])")),
|
||
"und es wird keine Adresse behauptet, die es nicht gibt");
|
||
}
|
||
|
||
/* =======================================================================
|
||
4. UND ES TRÄGT WIRKLICH EIN
|
||
======================================================================= */
|
||
melde("");
|
||
melde("=== 4. Der Tipp auf „Übernehmen“ ===");
|
||
{
|
||
const vorher = await zahlIm("highlight");
|
||
const satz = `Neues Video! ${VIDEO}`;
|
||
await oeffne(`text=${encodeURIComponent(satz)}`);
|
||
|
||
/* NICHT VON SELBST. Eine Seite, die beim Öffnen einträgt, kann nicht
|
||
wissen, ob der Griff in die Tasche gemeint war. */
|
||
ok(await zahlIm("highlight") === vorher,
|
||
`allein vom Öffnen passiert nichts (${vorher})`);
|
||
|
||
await seite.click("#uebernehmen");
|
||
await seite.waitForTimeout(2500);
|
||
const nachher = await zahlIm("highlight");
|
||
ok(nachher === vorher + 1, `nach dem Tipp liegt es da (${vorher} → ${nachher})`);
|
||
|
||
const fertig = await seite.$eval("#fertig-satz", (n) => n.textContent.trim()).catch(() => "");
|
||
ok(/Angekommen/.test(fertig), `und die Seite sagt es (\u201e${fertig.slice(0, 55)}\u201c)`);
|
||
ok(/Casper erwischt den Postboten/.test(fertig),
|
||
"mitsamt dem Titel, den TikTok geliefert hat");
|
||
|
||
/* DER WEG DORTHIN steht als Knopf da -- wer teilt, will meistens
|
||
gleich sehen, wie es aussieht. */
|
||
const hin = await seite.$eval("#hin", (n) => n.getAttribute("href")).catch(() => "");
|
||
ok(/b=highlight/.test(hin), `der Knopf führt zum richtigen Brett (${hin})`);
|
||
|
||
/* ---- Und derselbe Link ein zweites Mal ---------------------------- */
|
||
await oeffne(`text=${encodeURIComponent(satz)}`);
|
||
await seite.click("#uebernehmen");
|
||
await seite.waitForTimeout(2500);
|
||
ok(await zahlIm("highlight") === nachher,
|
||
`zweimal geteilt ergibt kein zweites Video (${await zahlIm("highlight")})`);
|
||
const zweiterSatz = await seite.$eval("#fertig-satz", (n) => n.textContent.trim()).catch(() => "");
|
||
ok(/lag schon/.test(zweiterSatz), `und es steht auch da (\u201e${zweiterSatz.slice(0, 45)}\u201c)`);
|
||
}
|
||
|
||
/* =======================================================================
|
||
5. AUF EIN ANDERES BRETT
|
||
======================================================================= */
|
||
melde("");
|
||
melde("=== 5. Wohin ist eine Frage ===");
|
||
{
|
||
const vorher = await zahlIm("treff");
|
||
await oeffne(`url=${encodeURIComponent(`http://127.0.0.1:${TIKTOK}/@hasidog00804/video/99`)}`);
|
||
await seite.click('.teilen__brett[data-brett="treff"]');
|
||
await seite.waitForTimeout(300);
|
||
const gewaehlt = await seite.$$eval(".teilen__brett",
|
||
(ns) => ns.filter((n) => n.getAttribute("aria-pressed") === "true").map((n) => n.dataset.brett));
|
||
ok(gewaehlt.length === 1 && gewaehlt[0] === "treff",
|
||
`genau ein Ziel ist gewählt (${gewaehlt.join(", ")})`);
|
||
await seite.click("#uebernehmen");
|
||
await seite.waitForTimeout(2500);
|
||
ok(await zahlIm("treff") === vorher + 1,
|
||
`und es landet dort und nicht bei den Highlights (${vorher} → ${await zahlIm("treff")})`);
|
||
}
|
||
|
||
/* =======================================================================
|
||
6. DAS TEILEN-ZIEL STEHT IM MANIFEST
|
||
======================================================================= */
|
||
melde("");
|
||
melde("=== 6. Damit das Handy es überhaupt anbietet ===");
|
||
{
|
||
/* OHNE DIESEN EINTRAG erscheint der Workspace gar nicht erst im
|
||
Teilen-Menü -- die ganze Seite wäre dann unerreichbar, und die
|
||
fünf Abschnitte darüber hätten es nicht gemerkt. */
|
||
for (const datei of ["crew.webmanifest", "app.webmanifest"]) {
|
||
const m = JSON.parse(readFileSync(new URL(`../workspace/${datei}`, import.meta.url), "utf8"));
|
||
const z = m.share_target;
|
||
ok(!!z && z.action === "/workspace/teilen.html",
|
||
`${datei}: Teilen-Ziel zeigt auf die Seite (${z?.action})`);
|
||
ok(z?.method === "GET",
|
||
`${datei}: per GET – POST bräuchte einen Service Worker dazwischen (${z?.method})`);
|
||
/* ALLE DREI FELDER. Fehlte `text`, wäre genau der Fall aus
|
||
Abschnitt 2 nicht abgedeckt -- und das ist der häufigste. */
|
||
ok(z?.params?.text === "text" && z?.params?.url === "url" && z?.params?.title === "title",
|
||
`${datei}: title, text UND url werden entgegengenommen`);
|
||
}
|
||
|
||
/* UND DIE SEITE IST IN DER RECHTETAFEL. Ohne Eintrag ist sie seit
|
||
dem 11.09. für jede Rolle zu -- ein fehlender Eintrag heißt nein. */
|
||
const { SEITEN } = await import("./rechte.js");
|
||
const rollen = SEITEN["/workspace/teilen.html"] || [];
|
||
ok(rollen.length > 0, `die Seite steht in der Rechtetafel (${rollen.join(", ")})`);
|
||
ok(!rollen.includes("gast"),
|
||
"und die Community kommt nicht hin – sie darf auch keine Videos eintragen");
|
||
}
|
||
|
||
melde("");
|
||
ok(konsolenfehler.length === 0,
|
||
konsolenfehler.length ? konsolenfehler.join(" | ") : "keine Fehler in der Browserkonsole");
|
||
|
||
melde("");
|
||
melde(`${geprueft} Prüfungen, ${fehler} Fehler`);
|
||
melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG");
|
||
await browser.close();
|
||
await new Promise((r) => tiktok.close(r));
|
||
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ }
|
||
process.exit(fehler ? 1 : 0);
|