diff --git a/server/bild-kampagne.mjs b/server/bild-kampagne.mjs index 2401a7d7..5dc438a7 100644 --- a/server/bild-kampagne.mjs +++ b/server/bild-kampagne.mjs @@ -380,6 +380,70 @@ async function messen(breite, hoehe, name) { const kasten = await seite.$("#kampagneholen"); await kasten.screenshot({ path: `server/bild-kampagne-${breite}.png` }); + /* ---- DAS KANALFELD (08.10.2026) ------------------------------- + Es steht unter dem Einlesekasten und ist genauso zugeklappt. + GEMESSEN WIRD, was man sonst erst merkt, wenn jemand es mit dem + Finger nicht trifft: die Höhe des Eingabefelds. 44 px ist die + Hausgröße -- am 07.10.2026 standen in anleitung.css 40 px mit dem + Kommentar „die Hausgröße" daneben, und das war falsch. */ + const kanal = await seite.evaluate(() => { + const k = document.getElementById("kanalfeld"); + if (!k) return { fehlt: true }; + const schild = document.getElementById("kanal-schild"); + const form = document.getElementById("kanal-form"); + const feld = document.getElementById("kanal-adresse"); + const g = (e) => (e ? e.getBoundingClientRect() : { width: 0, height: 0 }); + const st = feld ? getComputedStyle(feld) : null; + return { + fehlt: false, + sichtbar: !k.hidden, + schildHoehe: Math.round(g(schild).height), + formHoehe: Math.round(g(form).height), + feldHoehe: Math.round(g(feld).height), + feldBreite: Math.round(g(feld).width), + kastenBreite: Math.round(g(k).width), + schrift: st ? st.fontSize : null, + lage: document.getElementById("kanal-lage")?.textContent.trim() || "", + /* Steht die Adresse irgendwo auf dem Bildschirm? Sie darf nicht. + Das Feld ist leer, und die Lage sagt nur, OB einer da ist. */ + textGanz: k.textContent, + }; + }); + console.log(" -- Kanalfeld: " + JSON.stringify(kanal)); + ok(kanal.fehlt === false && kanal.sichtbar === true, + `das Kanalfeld steht da (sichtbar: ${kanal.sichtbar})`); + ok(kanal.schildHoehe >= 44, + ` sein Schild ist ${kanal.schildHoehe} px hoch (mindestens 44)`); + /* ZUGEKLAPPT ALS VORGABE -- der Kanal wird einmal eingetragen. */ + ok(kanal.formHoehe === 0, + ` und zugeklappt (Formularhoehe ${kanal.formHoehe} px)`); + ok(/eingerichtet|nachgesehen/i.test(kanal.lage), + ` das Schild sagt die Lage: ${JSON.stringify(kanal.lage)}`); + ok(!/api\/webhooks/.test(kanal.textGanz), + " und nirgends steht eine Webhook-Adresse"); + + /* AUFGEKLAPPT: jetzt muss das Eingabefeld zu treffen sein. */ + await seite.evaluate(() => document.getElementById("kanal-schild")?.click()); + await seite.waitForTimeout(400); + const auf = await seite.evaluate(() => { + const feld = document.getElementById("kanal-adresse"); + const r = feld?.getBoundingClientRect(); + const k = document.getElementById("kanalfeld").getBoundingClientRect(); + return { + feldHoehe: Math.round(r?.height || 0), + feldBreite: Math.round(r?.width || 0), + kastenBreite: Math.round(k.width), + raus: Math.round(Math.max(0, (r?.right || 0) - k.right)), + }; + }); + console.log(" -- aufgeklappt: " + JSON.stringify(auf)); + ok(auf.feldHoehe >= 44, + `das Eingabefeld ist ${auf.feldHoehe} px hoch (Hausgroesse 44)`); + ok(auf.raus === 0, + ` und laeuft nicht aus dem Kasten heraus (${auf.raus} px)`); + const kanalKasten = await seite.$("#kanalfeld"); + await kanalKasten.screenshot({ path: `server/bild-kampagne-kanal-${breite}.png` }); + /* UND WIE ES WAEHREND DES HOLENS AUSSIEHT. */ await seite.evaluate(() => { document.getElementById("kampagne-form").dataset.laeuft = "ja"; diff --git a/server/pruef-kampagne-lesen.mjs b/server/pruef-kampagne-lesen.mjs index 0b7ea3ce..808ac749 100644 --- a/server/pruef-kampagne-lesen.mjs +++ b/server/pruef-kampagne-lesen.mjs @@ -139,9 +139,34 @@ for (const [name, s] of Object.entries(SEITEN)) { /* Und: es liegt nichts Unerklärtes dort. Eine Prüfdatei, die niemand benutzt, ist entweder ein Rest oder eine vergessene Absicht. */ { - const erwartet = new Set([...Object.values(SEITEN).map((s) => s.datei), "LIESMICH.md"]); + /* WAS SONST NOCH DORT LIEGEN DARF -- benannt, mit Grund. + + Diese drei gehören nicht zu `SEITEN`: Das sind die TikTok-Seiten, + die `leseKampagne` liest. Hier geht es um den Bildleser, der eine + ganz andere Eingabe hat -- den erkannten Text eines + Bildschirmfotos. Gelesen werden sie von `pruef-kampagne-bild.mjs`. + + SIE WURDEN AM 08.10.2026 MIT 698c23e2 ANGELEGT, und von da an war + diese Prüfung rot: Ich hatte sie vor dem Commit gemessen und + danach nicht noch einmal. Ein Prüfstand von vorher ist keiner. */ + const AUCH_ERLAUBT = new Map([ + ["bild-agentur-nachbau.png", "das nachgebaute Backstage-Bild -- Eingabe der Texterkennung"], + ["bild-agentur-nachbau.html", "woraus es entsteht, damit es sich nachbauen laesst"], + ["bild-agentur-erkannt.txt", "was Tesseract daraus liest -- die Eingabe von leseBild"], + ]); + + const erwartet = new Set([...Object.values(SEITEN).map((s) => s.datei), "LIESMICH.md", + ...AUCH_ERLAUBT.keys()]); const gefunden = readdirSync(DATEN); const fremd = gefunden.filter((n) => !erwartet.has(n)); + + /* GEGENWACHE: Jede benannte Datei muss es noch geben. Sonst bleibt + ein Freibrief stehen, nachdem die Stelle aufgeräumt ist -- und + deckt beim nächsten Mal etwas ab, das niemand entschieden hat. */ + const toteNamen = [...AUCH_ERLAUBT.keys()].filter((n) => !gefunden.includes(n)); + ok(AUCH_ERLAUBT.size > 0 && toteNamen.length === 0, + `jede benannte Zusatzdatei gibt es noch (${AUCH_ERLAUBT.size})` + + (toteNamen.length ? ` -- fehlen: ${toteNamen.join(", ")}` : "")); ok(gefunden.length === erwartet.size && fremd.length === 0, `${gefunden.length} Dateien im Ordner, alle erklärt` + (fremd.length ? ` — unerklärt: ${fremd.join(", ")}` : "")); diff --git a/server/pruef-kampagne.mjs b/server/pruef-kampagne.mjs index 46550f41..d0506549 100644 --- a/server/pruef-kampagne.mjs +++ b/server/pruef-kampagne.mjs @@ -35,7 +35,7 @@ Aufrufen mit: node server/pruef-kampagne.mjs ===================================================================== */ -import { mkdtempSync, rmSync, readFileSync, existsSync } from "node:fs"; +import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync, statSync } from "node:fs"; import { brotliDecompressSync } from "node:zlib"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -57,6 +57,13 @@ const TIKPORT = await eigenerPort(import.meta, "der nachgebaute TikTok-Dienst", und `bildAdresseErlaubt` ebenfalls. Beides nur hier — im Betrieb steht die Variable nicht, und dann gilt die echte Regel. */ process.env.TIKTOK_KAMPAGNE_BASIS = "http://127.0.0.1:" + TIKPORT; +/* DERSELBE SCHALTER FÜR DEN KANAL (08.10.2026). Er nennt die Basis, + mit der eine Webhook-Adresse anfangen muss, damit 127.0.0.1 + erlaubt ist. Im Betrieb steht die Variable nicht — dann gilt die + echte Regel (https und ein Discord-Rechner). Ohne diesen Weg wäre + das Stellen in den Kanal nur am ECHTEN Discord prüfbar, und das + heißt: in Filipes Kanal. */ +process.env.DISCORD_WEBHOOK_BASIS = "http://127.0.0.1:" + TIKPORT; 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"; @@ -76,6 +83,20 @@ let geprueft = 0; const melde = (z) => console.log(z); const ok = (b, t) => { geprueft++; melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; +/* DER DRITTE AUSGANG JE FRAGE -- nicht in Ordnung, aber auch nicht + bestanden. `nichtNachsehbar` weiter unten bricht den GANZEN Lauf + ab; das ist für eine fehlende Voraussetzung richtig, nicht für + eine einzelne Frage, die auf diesem Betriebssystem keine Antwort + hat. Hier „ok" zu melden wäre eine Zusicherung über etwas, das + nicht gemessen wurde -- genau die Sorte grüner Haken, die nichts + bestätigt (01.09.2026). */ +let offen = 0; +const unklar = (was, wie) => { + offen++; + melde(` -- NICHT NACHSEHBAR: ${was}`); + melde(` ${wie}`); +}; + function nichtNachsehbar(grund, rat) { melde(""); melde("KONNTE NICHT NACHSEHEN"); @@ -208,12 +229,35 @@ const BANNERBILD = pngBauen(750, 500, (x, y, b, h) => { let abrufe = 0; const gesehen = []; + +/* ---- DER NACHGEBAUTE DISCORD-KANAL (08.10.2026) ---------------- + Was er bekommt, landet in `kanalPost`. `kanalAntwort` lässt sich + umstellen, damit auch die beiden Absagen geprüft werden können: + 4xx heißt „der Zugang ist hin", 5xx heißt „später nochmal" -- + zwei verschiedene Sätze, also zwei Messungen. */ +const kanalPost = []; +let kanalAntwort = 204; + const tikDienst = createServer((anfrage, antwort) => { abrufe++; const u = new URL(anfrage.url, "http://127.0.0.1"); gesehen.push({ pfad: u.pathname, kennung: anfrage.headers["user-agent"] || "" }); const leite = (nach) => { antwort.writeHead(302, { location: nach }); antwort.end(); }; + if (u.pathname.startsWith("/api/webhooks/")) { + const stuecke = []; + anfrage.on("data", (s) => stuecke.push(s)); + anfrage.on("end", () => { + let rumpf = null; + try { rumpf = JSON.parse(Buffer.concat(stuecke).toString("utf8")); } + catch { rumpf = null; } + kanalPost.push({ pfad: u.pathname, methode: anfrage.method, rumpf }); + antwort.writeHead(kanalAntwort, { "content-type": "application/json" }); + antwort.end(kanalAntwort < 300 ? "" : JSON.stringify({ message: "nein" })); + }); + return undefined; + } + if (u.pathname === "/t/gipfel") return leite(`/falcon/home?activityId=${ID_GIPFEL}&campaign_traffic_id=1`); if (u.pathname === "/t/gipfel-zweiter-versand") return leite(`/falcon/home?activityId=${ID_GIPFEL}&campaign_traffic_id=2`); if (u.pathname === "/t/beendet") return leite(`/falcon/home?activityId=${ID_BEENDET}`); @@ -395,6 +439,17 @@ const dbLesen = (sql, ...w) => { try { return x.prepare(sql).all(...w); } finally { x.close(); } }; +/* ZUM SCHREIBEN DASSELBE MUSTER. `d` ist ab Zeile 375 geschlossen; + wer es spaeter benutzt, bekommt „database is not open" -- genau + das ist beim ersten Lauf von Abschnitt 12c passiert. Eine eigene + Verbindung je Aufruf ist hier richtig: Der Server haelt seine + eigene, und zwei offene auf derselben Datei sind in SQLite + genau dann ein Problem, wenn eine davon vergessen wird. */ +const dbSchreiben = (sql, ...w) => { + const x = new DatabaseSync(process.env.WORKSPACE_DB); + try { return x.prepare(sql).run(...w); } finally { x.close(); } +}; + /* ===================================================================== 1. DEN LINK AUS DEM GANZEN TEXT HOLEN ===================================================================== */ @@ -1435,6 +1490,302 @@ melde("=== 12b. Ein Event nach draußen teilen ==="); 13. DAS PROTOKOLL ===================================================================== */ melde(""); +melde("=== 12c. Der Beitrag stellt sich selbst in den Kanal ==="); + +{ + /* Filipe: „ich will dass wenn ich es teile im discord der agentur, + dass ein banner und ein kompletter fertiger text für die creator + entsteht und nicht einfach der link kopiert wird.“ + + DAS GEHEIMNIS LIEGT IN EINER DATEI, nicht im Quelltext und nicht + in der Datenbank. Hier wird sie in den Wegwerf-Datenordner + geschrieben — dieselbe Stelle, an der im Betrieb die echte + liegt (`DATEN_ORDNER` ist der Ordner der Datenbank). */ + const WEBHOOK = join(ordner, "discord-webhook.txt"); + const legeAn = (inhalt) => writeFileSync(WEBHOOK, inhalt, "utf8"); + const nimmWeg = () => { try { rmSync(WEBHOOK); } catch { /* war nicht da */ } }; + const stellen = (keks, id = kachelId) => + roh(`/workspace/api/eintrag/${id}/discord`, { methode: "POST", keks }); + + /* DIESER ABSCHNITT STELLT SEINEN ZUSTAND SELBST HER. Abschnitt 12b + zieht den Teilen-Link am Ende zurück; wer sich darauf verlässt, + dass er noch da ist, misst etwas anderes als gedacht — beim + ersten Lauf kam `nicht_geteilt`, wo `discord_nicht_eingerichtet` + stehen sollte. Ein Prüfabschnitt, der auf dem Zustand des + vorigen aufbaut, ist von dessen Reihenfolge abhängig. */ + const wiederGeteilt = await roh(`/workspace/api/eintrag/${kachelId}/teilen`, + { methode: "POST", keks: keksDogi, rumpf: {} }); + ok(wiederGeteilt.code === 200 + && /\/e\/[A-Za-z0-9_-]{20,}$/.test(JSON.parse(wiederGeteilt.text || "{}").weg || ""), + `das Event ist für diesen Abschnitt geteilt (${wiederGeteilt.code})`); + + /* ---- 1. Ohne Datei gibt es keinen Knopf und keine Absage ohne Satz */ + nimmWeg(); + const ohne = await stellen(keksDogi); + const jOhne = JSON.parse(ohne.text || "{}"); + ok(ohne.code === 409 && jOhne.fehler === "discord_nicht_eingerichtet", + `ohne eingerichteten Kanal: ${ohne.code} ${jOhne.fehler}`); + + /* Und die Oberfläche erfährt das, BEVOR sie einen Knopf zeigt. */ + const tOhne = await roh(`/workspace/api/eintrag/${kachelId}/teilen`, + { methode: "POST", keks: keksDogi, rumpf: {} }); + ok(JSON.parse(tOhne.text || "{}").discord === false, + " und die Teilen-Antwort sagt `discord: false`"); + + /* ---- 2. Eine FREMDE Adresse gilt nicht -------------------------- + Ohne diese Messung wäre das Feld ein Weg, unseren Server zu + beliebigen Zielen sprechen zu lassen. Drei Formen werden + abgelehnt, und zwar jede aus einem eigenen Grund. */ + const abgelehnt = [ + ["https://beispiel.test/api/webhooks/1/x", "fremder Rechner"], + ["http://discord.com/api/webhooks/1/x", "ohne Verschlüsselung"], + ["https://discord.com/irgendwas/1/x", "kein Webhook-Weg"], + [`http://127.0.0.1:${TIKPORT + 1}/api/webhooks/1/x`, "andere Basis als der Schalter"], + ]; + let fremdeAbgelehnt = 0; + for (const [adresse, warum] of abgelehnt) { + legeAn(adresse); + const r = await stellen(keksDogi); + const j = JSON.parse(r.text || "{}"); + const gut = r.code === 409 && j.fehler === "discord_nicht_eingerichtet"; + if (gut) fremdeAbgelehnt++; + else melde(` -- DURCHGELASSEN: ${warum} -> ${r.code} ${j.fehler}`); + } + ok(fremdeAbgelehnt === abgelehnt.length, + `alle ${abgelehnt.length} fremden Adressen werden abgelehnt (${fremdeAbgelehnt})`); + + /* ---- 3. Mit dem nachgebauten Kanal ------------------------------ */ + legeAn([ + "# Der Kanal der Agentur. Diese Zeile ist ein Kommentar.", + `http://127.0.0.1:${TIKPORT}/api/webhooks/42/geheim`, + ].join("\n")); + + const tMit = await roh(`/workspace/api/eintrag/${kachelId}/teilen`, + { methode: "POST", keks: keksDogi, rumpf: {} }); + const jMit = JSON.parse(tMit.text || "{}"); + ok(jMit.discord === true, "mit Datei sagt die Teilen-Antwort `discord: true`"); + /* UND DIE ADRESSE SELBST STEHT NICHT DRIN. Eine Auskunft über das + Vorhandensein ist etwas anderes als der Schlüssel. */ + ok(!/webhooks|geheim|127\.0\.0\.1/.test(tMit.text || ""), + " und die Adresse steht nicht in der Antwort"); + + const vorZahl = kanalPost.length; + const r1 = await stellen(keksDogi); + const j1d = JSON.parse(r1.text || "{}"); + ok(r1.code === 200 && j1d.ok === true, + `DogFather stellt den Beitrag: ${r1.code} (${j1d.zeichen} Zeichen)`); + ok(kanalPost.length === vorZahl + 1, + ` genau ein Beitrag kommt im Kanal an (${kanalPost.length - vorZahl})`); + + const post = kanalPost[kanalPost.length - 1]; + const inhalt = String(post?.rumpf?.content || ""); + ok(post?.methode === "POST" && post?.pfad === "/api/webhooks/42/geheim", + ` an den richtigen Weg (${post?.methode} ${post?.pfad})`); + ok(inhalt.length > 200 && inhalt.length <= 2000, + ` mit dem ganzen Text (${inhalt.length} Zeichen, Grenze 2000)`); + ok(/^#\s/m.test(inhalt) && inhalt.includes("Gipfel"), + " Titel als Überschrift"); + /* DER LINK STEHT GANZ AM ENDE UND NACKT. Discord setzt die Vorschau + UNTER die Nachricht — steht der Link oben, schiebt das Banner den + Text aus dem Blick. Und in spitzen Klammern wäre die Vorschau + unterdrückt, also genau das Banner weg. */ + const letzte = inhalt.trimEnd().split("\n").pop().trim(); + ok(/^https?:\/\/\S+$/.test(letzte) && letzte.includes("/e/"), + ` und der Link nackt in der letzten Zeile (${letzte.replace(/^https?:\/\/[^/]+/, "…")})`); + + /* ---- 4. Ein fremder Text darf niemanden anpingen --------------- + Im Beitrag steht, was TikTok geschrieben hat. Stünde darin + „@everyone", würde unser Beitrag den ganzen Server anpingen. */ + ok(Array.isArray(post?.rumpf?.allowed_mentions?.parse) + && post.rumpf.allowed_mentions.parse.length === 0, + " Erwähnungen sind abgeschaltet (allowed_mentions leer)"); + ok(post?.rumpf?.flags === undefined, + " und die Vorschau ist NICHT unterdrückt (keine flags)"); + + /* ---- 5. Wer darf das? ------------------------------------------ */ + const alsCreator = await stellen(keksLuna); + ok(alsCreator.code === 403 || alsCreator.code === 404, + `ein Creator darf nicht in den Kanal stellen (${alsCreator.code})`); + + /* ---- 6. Ein ungeteiltes Event hat keinen Link ------------------- */ + const frisch = dbSchreiben( + `INSERT INTO eintraege (bereich, haus, art, titel, text, datum, erstellt_von, erstellt) + VALUES ('agentur','agentur','kampagne','Noch nicht geteilt','x',?,?,?)`, + heuteLokal(), 1, new Date().toISOString()); + const nurFrisch = await stellen(keksDogi, Number(frisch.lastInsertRowid)); + ok(nurFrisch.code === 409 + && JSON.parse(nurFrisch.text || "{}").fehler === "nicht_geteilt", + `ohne Teilen-Link keine Nachricht (${nurFrisch.code})`); + + /* ---- 7. Die beiden Absagen von Discord ------------------------- + 4xx und 5xx bekommen verschiedene Sätze, weil sie verschiedene + Dinge bedeuten: „der Zugang ist hin" gegen „später nochmal". + Ohne diese Messung wäre nicht belegt, dass die Unterscheidung + überhaupt ankommt. */ + kanalAntwort = 401; + const vier = await stellen(keksDogi); + ok(vier.code === 502 && JSON.parse(vier.text || "{}").fehler === "discord_abgelehnt", + `Discord sagt 401 -> discord_abgelehnt (${vier.code})`); + kanalAntwort = 500; + const fuenf = await stellen(keksDogi); + ok(fuenf.code === 502 + && JSON.parse(fuenf.text || "{}").fehler === "discord_nicht_erreichbar", + `Discord sagt 500 -> discord_nicht_erreichbar (${fuenf.code})`); + kanalAntwort = 204; + + /* ---- 8. Jeder Versuch steht im Protokoll ------------------------ */ + const prot = dbLesen( + `SELECT aktion, COUNT(*) AS n FROM protokoll + WHERE aktion LIKE 'event_%gesendet' GROUP BY aktion`); + const karte = new Map(prot.map((z) => [z.aktion, z.n])); + ok((karte.get("event_gesendet") || 0) >= 1 + && (karte.get("event_nicht_gesendet") || 0) >= 2, + `Erfolg und Fehlschlag stehen beide im Protokoll ` + + `(gesendet ${karte.get("event_gesendet") || 0}, ` + + `nicht gesendet ${karte.get("event_nicht_gesendet") || 0})`); + + /* ---- 9. Die Bremse --------------------------------------------- */ + let gebremst = null; + for (let i = 0; i < 16 && !gebremst; i++) { + const r = await stellen(keksDogi); + if (r.code === 429) gebremst = i + 1; + } + ok(gebremst !== null && gebremst <= 16, + `nach ${gebremst} weiteren Versuchen bremst sie (Grenze 12 je Stunde)`); + const letzteAbsage = await stellen(keksDogi); + ok(JSON.parse(letzteAbsage.text || "{}").fehler === "senden_gebremst", + " und sagt es mit einer Kennung, zu der es einen Satz gibt"); + + nimmWeg(); +} + +melde("=== 12d. Den Kanal eintragen -- ohne Kommandozeile ==="); + +{ + /* WARUM ES DIESEN WEG GIBT. Die Webhook-Adresse ist der Schlüssel + zum Kanal. Über einen Befehl eingetragen landet sie im + Chatverlauf, im Bildschirmfoto und in `~/.bash_history` -- am + 03.09.2026 ist genau das mit einem Token passiert, und er musste + zurückgezogen werden. Gemessen am 08.10.2026 kann `claudian` die + Datei auch gar nicht anlegen (der Ordner gehört `dogiweb`, die + Gruppe hat kein Schreibrecht, und `sudo` deckt `dogiweb` nicht + ab) -- der Dienst aber schon. + + GEPRÜFT WIRD DESHALB NICHT NUR „es geht", sondern vor allem: + Wer darf es, und kommt die Adresse irgendwo wieder heraus. */ + const WEG = "/workspace/api/teilen/kanal"; + const ADRESSE = `http://127.0.0.1:${TIKPORT}/api/webhooks/77/einGeheimnis`; + const kanalDatei = join(ordner, "discord-webhook.txt"); + try { rmSync(kanalDatei); } catch { /* war nicht da */ } + + /* ---- 1. Wer darf? ---------------------------------------------- */ + const lunaSieht = await roh(WEG, { keks: keksLuna }); + ok(lunaSieht.code === 403, + `ein Creator sieht den Kanal nicht (${lunaSieht.code})`); + const lunaSetzt = await roh(WEG, + { methode: "PUT", keks: keksLuna, rumpf: { adresse: ADRESSE } }); + ok(lunaSetzt.code === 403, + ` und darf ihn nicht eintragen (${lunaSetzt.code})`); + + /* ZWEI GETRENNTE HÄUSER (24.09.2026). Auf der Crew-Adresse gibt es + den Agentur-Kanal nicht -- auch nicht für DogFather. 404, nicht + 403: Was es hier nicht gibt, soll man nicht erahnen können. */ + const crewSieht = await roh(WEG, { keks: keksDogiCrew, wirt: CREW_HOST }); + ok(crewSieht.code === 404 + && JSON.parse(crewSieht.text || "{}").fehler === "nicht_hier", + `auf der Crew-Adresse gibt es ihn nicht (${crewSieht.code})`); + const crewSetzt = await roh(WEG, { methode: "PUT", keks: keksDogiCrew, + wirt: CREW_HOST, rumpf: { adresse: ADRESSE } }); + ok(crewSetzt.code === 404 && !existsSync(kanalDatei), + ` und von dort wird keine Datei angelegt (${crewSetzt.code})`); + + /* ---- 2. Untaugliche Adressen kommen nicht hinein ---------------- */ + const schlecht = [ + ["", "leer"], + ["kein-link", "gar keine Adresse"], + ["https://beispiel.test/api/webhooks/1/x", "fremder Rechner"], + ["https://discord.com/kanal/1/x", "kein Webhook-Weg"], + ["javascript:alert(1)", "anderes Verfahren"], + ]; + let abgewiesen = 0; + for (const [adresse, warum] of schlecht) { + const r = await roh(WEG, { methode: "PUT", keks: keksDogi, rumpf: { adresse } }); + const j = JSON.parse(r.text || "{}"); + if (r.code === 400 && j.fehler === "adresse_untauglich") abgewiesen++; + else melde(` -- ANGENOMMEN: ${warum} -> ${r.code} ${j.fehler}`); + } + ok(abgewiesen === schlecht.length && !existsSync(kanalDatei), + `alle ${schlecht.length} untauglichen Adressen werden abgewiesen, ` + + `ohne eine Datei anzulegen (${abgewiesen})`); + + /* ---- 3. DogFather trägt ihn ein -------------------------------- */ + const setzt = await roh(WEG, + { methode: "PUT", keks: keksDogi, rumpf: { adresse: ADRESSE } }); + ok(setzt.code === 200 && JSON.parse(setzt.text || "{}").bereit === true, + `DogFather trägt den Kanal ein (${setzt.code})`); + ok(existsSync(kanalDatei), " die Datei liegt im Datenordner"); + /* ERST NACHSEHEN, OB SIE DA IST. Beim ersten Lauf schlug der + Eintrag fehl, und dieses `readFileSync` riss mit ENOENT den + ganzen Lauf ab -- aus einem roten Haken wurde ein Absturz, und + die zehn Fragen danach liefen nie. Eine Prüfung darf am + Fehlschlag einer anderen nicht sterben. */ + ok(existsSync(kanalDatei) + && readFileSync(kanalDatei, "utf8").includes(ADRESSE), + " und enthält die Adresse"); + + /* ---- 4. UND SIE KOMMT NIE WIEDER HERAUS ------------------------ + Das ist die Messung, auf die es ankommt. Ein Feld, das sein + Geheimnis zurückliest, ist eine Kopie mehr -- und jede Kopie ist + eine Stelle, an der es wegkommen kann. */ + const liest = await roh(WEG, { keks: keksDogi }); + const jLiest = JSON.parse(liest.text || "{}"); + ok(jLiest.bereit === true && jLiest.steht === true && !!jLiest.seit, + `die Auskunft sagt bereit/seit (${jLiest.seit})`); + ok(!/webhooks|einGeheimnis|127\.0\.0\.1/.test(liest.text || ""), + " aber die Adresse steht NICHT in der Antwort"); + + /* ---- 5. Die Rechte der Datei ----------------------------------- */ + if (process.platform === "win32") { + /* DER DRITTE AUSGANG. Windows kennt keine Unix-Rechte; `chmod` + schaltet dort nur das Schreibschutz-Bit. Hier „in Ordnung" zu + melden wäre eine Zusicherung über etwas, das nicht gemessen + wurde -- und genau diese Prüfung soll auf dem Server laufen, + wo sie etwas bedeutet. */ + unklar("die Rechte der Kanaldatei", + "Windows kennt keine Unix-Rechte. Auf dem Server nachsehen: " + + "ls -l /home/dogiweb/workspace-daten/discord-webhook.txt " + + "muss -rw------- und dogiweb zeigen."); + } else { + const m = statSync(kanalDatei).mode & 0o777; + ok(m === 0o600, `nur der Dienst darf sie lesen (${m.toString(8)})`); + } + + /* ---- 6. Und jetzt geht der Beitrag --------------------------- */ + const tNun = await roh(`/workspace/api/eintrag/${kachelId}/teilen`, + { methode: "POST", keks: keksDogi, rumpf: {} }); + ok(JSON.parse(tNun.text || "{}").discord === true, + "nach dem Eintragen sagt die Teilen-Antwort `discord: true`"); + + /* ---- 7. Entfernen wirkt sofort ---------------------------------- */ + const entfernt = await roh(WEG, { methode: "DELETE", keks: keksDogi }); + ok(entfernt.code === 200 + && JSON.parse(entfernt.text || "{}").bereit === false + && !existsSync(kanalDatei), + `Entfernen wirkt sofort und räumt die Datei weg (${entfernt.code})`); + const tWeg = await roh(`/workspace/api/eintrag/${kachelId}/teilen`, + { methode: "POST", keks: keksDogi, rumpf: {} }); + ok(JSON.parse(tWeg.text || "{}").discord === false, + " und der Knopf verschwindet wieder"); + + /* ---- 8. Im Protokoll steht die Adresse nicht ------------------- */ + const prot = dbLesen( + "SELECT aktion, detail FROM protokoll WHERE aktion LIKE 'kanal_%'"); + ok(prot.length >= 2, + `Eintragen und Entfernen stehen im Protokoll (${prot.length} Zeilen)`); + ok(!prot.some((z) => /webhooks|einGeheimnis|127\.0\.0\.1/.test(z.detail || "")), + " und die Adresse steht in keinem Protokolleintrag"); +} + melde("=== 13. Jeder Versuch steht im Protokoll, nicht nur der Erfolg ==="); { @@ -1502,8 +1853,14 @@ melde("=== 14. Zwanzig in einer Stunde, dann ist Schluss ==="); ===================================================================== */ melde(""); melde(`${abrufe} Abrufe beim nachgebauten Dienst, keiner im echten Netz.`); -melde(`${geprueft} Pruefungen, ${fehler} Fehler`); -melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG"); +melde(`${geprueft} Pruefungen, ${fehler} Fehler` + + (offen ? `, ${offen} nicht nachsehbar` : "")); +/* „ALLES IN ORDNUNG" DARF NICHT DASTEHEN, WENN EINE FRAGE OFFEN IST. + Sonst verschwindet die ehrliche Lücke hinter der Überschrift, und + niemand sieht mehr, dass eine Zusicherung auf diesem Rechner gar + nicht geprüft werden konnte (dieselbe Form wie in + pruef-kampagne-bild.mjs). */ +melde(fehler ? "NICHT IN ORDNUNG" : (offen ? "IN ORDNUNG, mit Luecke" : "ALLES IN ORDNUNG")); tikDienst.close(); try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Rest */ } diff --git a/server/workspace-teilen.js b/server/workspace-teilen.js index 55413579..4d0f1bbc 100644 --- a/server/workspace-teilen.js +++ b/server/workspace-teilen.js @@ -61,7 +61,7 @@ import express from "express"; import { randomBytes } from "node:crypto"; import { join } from "node:path"; -import { existsSync } from "node:fs"; +import { existsSync, readFileSync, writeFileSync, chmodSync, rmSync, statSync } from "node:fs"; import { db, protokolliere, echteIp, sitzungLesen, istLeitung, DATEN_ORDNER } from "./workspace.js"; @@ -73,6 +73,11 @@ import { liefereDatei } from "./helfer-ausliefern.mjs"; geteilte Seite zeigt irgendwann etwas anderes als die Kachel, und niemand koennte sagen, welche von beiden recht hat. */ import { gliederung } from "./kampagne-lesen.mjs"; +/* ORTSZEIT, NICHT UTC. `toISOString()` schreibt zwischen 00:00 und + 02:00 deutscher Zeit den Vortag -- in eine Zeile, die später + jemand liest, um zu wissen, seit wann der Kanal gilt. Gefunden + hat das `pruef-struktur`, nicht das Nachdenken. */ +import { heuteLokal } from "./helfer-tag.mjs"; import { punkteAusText } from "./workspace-bereiche.js"; export const teilenRouter = express.Router(); @@ -102,6 +107,13 @@ function angemeldet(req, res, next) { /* ---------- Der Knopf im Workspace ------------------------------------ */ teilenRouter.use("/workspace/api/eintrag", angemeldet); +/* UND FÜR DIE KANAL-WEGE (08.10.2026). Ein `use` mit Pfad gilt nur + für diesen Pfad -- wer daneben eine Route anlegt, hat dort kein + `req.person`. Beim ersten Prüflauf antworteten die drei Kanal-Wege + deshalb jedem mit 403 „dafür fehlt dir die Berechtigung", auch + DogFather. Es ist ZU gescheitert, nicht auf -- aber die Auskunft + schickte in die falsche Richtung. */ +teilenRouter.use("/workspace/api/teilen", angemeldet); /** Das Event holen und prüfen, ob es überhaupt geteilt werden darf. */ function eventFuer(req, res) { @@ -183,7 +195,13 @@ teilenRouter.post("/workspace/api/eintrag/:id(\\d+)/teilen", (req, res) => { console.error("[teilen] Beitrag:", f?.message); beitrag = null; } - return res.json({ weg: basis, text: beitrag, schon: !!e.teilen_token }); + /* `discord` sagt NUR, ob ein Kanal eingerichtet ist -- nie, welcher. + Ohne diese Auskunft stünde im Browser ein Knopf, der jedes Mal + absagt, und niemand wüsste warum. */ + return res.json({ + weg: basis, text: beitrag, schon: !!e.teilen_token, + discord: webhookBereit(), + }); } catch (fehler) { console.error("[teilen] anlegen:", fehler?.message); return res.status(503).json({ fehler: "Das Teilen hat gerade nicht geklappt." }); @@ -208,6 +226,292 @@ teilenRouter.delete("/workspace/api/eintrag/:id(\\d+)/teilen", (req, res) => { } }); +/* ===================================================================== + DER BEITRAG STELLT SICH SELBST IN DEN KANAL (08.10.2026) + ===================================================================== + Filipe: „ich will dass wenn ich es teile im discord der agentur, dass + ein banner und ein kompletter fertiger text für die creator entsteht + und nicht einfach der link kopiert wird." + + WO DAS GEHEIMNIS LIEGT -- und warum nicht woanders: + + * NICHT im Quelltext und nicht in Gitea. Eine Webhook-Adresse IST + der Schlüssel: Wer sie hat, schreibt in den Kanal, ohne + angemeldet zu sein. + * NICHT in der Datenbank. Sie wird gesichert und liegt in Kopien. + * NICHT in der Dienstdatei. Die liegt in /etc, dort darf ich nichts + ändern (gemessen: `sudo -l` erlaubt systemctl, nicht das + Bearbeiten von Dateien) -- und jede Änderung bräuchte einen + Neustart. + * SONDERN in einer Datei neben der Datenbank, die nur `dogiweb` + lesen darf. Sie wird bei JEDEM Aufruf gelesen, nicht beim Start: + Dann wirkt eine neue Adresse sofort, und ein Austausch braucht + keinen Neustart. + + SIE WIRD NIE AUSGEGEBEN. Nicht im Protokoll, nicht in einer Antwort + an den Browser, nicht in einer Fehlermeldung. Nach draußen geht + ausschließlich die Antwort auf die Frage „ist eingerichtet: ja/nein". + ===================================================================== */ + +const WEBHOOK_DATEI = join(DATEN_ORDNER, "discord-webhook.txt"); +const WEBHOOK_FRIST_MS = 10_000; +/* Zwölf je Stunde und Person. Mehr ist kein Teilen mehr, sondern eine + Schleife -- und am anderen Ende hängt ein fremder Dienst, der uns + dafür sperren kann. */ +const DISCORD_JE_STUNDE = 12; + +/* Nur echte Discord-Adressen. Ohne diese Liste wäre das Feld ein Weg, + unseren Server zu beliebigen Zielen sprechen zu lassen -- dieselbe + Falle wie bei der Banneradresse am 07.10.2026, nur umgekehrt. */ +const WEBHOOK_HOSTS = new Set([ + "discord.com", "discordapp.com", "ptb.discord.com", "canary.discord.com", +]); + +/** Die Webhook-Adresse — oder `null`, wenn keine brauchbare dasteht. + * + * Die Datei darf Kommentarzeilen mit `#` enthalten; genommen wird die + * erste Zeile, die keine ist. So kann daneben stehen, wofür sie gilt, + * ohne dass eine zweite Datei nötig wird. + */ +/** Taugt diese Zeile als Webhook-Adresse? Gibt die saubere Adresse + * zurück — oder `null`. + * + * EINE STELLE FÜR BEIDE WEGE. Sie wird beim Lesen der Datei + * gebraucht UND beim Eintragen über die Oberfläche. Zwei Fassungen + * derselben Sicherheitsregel laufen auseinander, und beim zweiten + * Mal merkt es niemand — genau das war heute an den Meldungen zu + * sehen, wo eine zweite Satztabelle neun Sätze allein führte. + */ +function adresseTaugt(zeile) { + if (!zeile) return null; + let u; + try { u = new URL(String(zeile).trim()); } catch { return null; } + /* NUR IN EINER PRÜFUNG: Dann liegt der nachgebaute Kanal auf + 127.0.0.1. Derselbe Schalter wie bei `istTikTok` — und er ist zu, + solange die Umgebungsvariable nicht gesetzt ist, also im Betrieb + immer. Ohne ihn wäre dieser Weg nur am echten Discord prüfbar, + und das heißt: in Filipes Kanal. */ + const basisTest = process.env.DISCORD_WEBHOOK_BASIS; + const nachgebaut = !!basisTest && zeile.startsWith(basisTest) + && (u.hostname === "127.0.0.1" || u.hostname === "localhost"); + if (!nachgebaut) { + if (u.protocol !== "https:") return null; + if (!WEBHOOK_HOSTS.has(u.hostname)) return null; + } + if (!u.pathname.startsWith("/api/webhooks/")) return null; + return u.toString(); +} + +/** Die eingetragene Adresse — oder `null`. + * + * Kommentarzeilen mit `#` sind erlaubt; genommen wird die erste + * Zeile, die keine ist. So kann daneben stehen, wofür sie gilt. + */ +function webhookAdresse() { + let inhalt; + try { inhalt = readFileSync(WEBHOOK_DATEI, "utf8"); } catch { return null; } + const zeile = String(inhalt).split("\n") + .map((z) => z.trim()).find((z) => z && !z.startsWith("#")); + return adresseTaugt(zeile); +} + +/** Ist ein Kanal eingerichtet? Das darf die Oberfläche wissen — die + * Adresse nicht. Ohne diese Auskunft stünde ein Knopf da, der + * jedes Mal absagt, und niemand wüsste, warum. */ +export function webhookBereit() { return !!webhookAdresse(); } + +/** Wie viele Beiträge hat diese Person in der letzten Stunde gestellt? + * + * GEZÄHLT WIRD IM PROTOKOLL, nicht an Erfolgen — sonst bremst die + * Bremse genau den nicht, dessen Versuche scheitern und der deshalb + * in einer Schleife hängt (gelernt am 07.10.2026 an der + * Kampagnen-Bremse, die 21 Versuche durchließ). + * + * `senden_gebremst` steht bewusst NICHT in der Liste: Sonst zählte + * die Bremse ihre eigenen Absagen mit und würde immer strenger. */ +function beitraegeLetzteStunde(personId) { + const seit = new Date(Date.now() - 3600_000).toISOString(); + const z = db().prepare( + `SELECT COUNT(*) AS anzahl, MIN(zeitpunkt) AS aeltester + FROM protokoll + WHERE person_id = ? AND zeitpunkt >= ? + AND aktion IN ('event_gesendet','event_nicht_gesendet')`) + .get(personId, seit); + return { anzahl: z?.anzahl || 0, aeltester: z?.aeltester || null }; +} + +/** Den Beitrag in den Kanal stellen. Gibt `{ ok: true }` oder + * `{ fehler }` mit einer Kennung, zu der es in `meldung.js` einen + * Satz gibt — `pruef-meldungen` erzwingt das. + */ +async function inDenKanal(adresse, text) { + let antwort; + try { + antwort = await fetch(adresse, { + method: "POST", + headers: { "content-type": "application/json" }, + /* `allowed_mentions: { parse: [] }` IST KEIN SCHMUCK. + Im Beitrag steht der Text der KAMPAGNE, und den schreibt + TikTok, nicht wir. Stünde darin „@everyone", würde unser + Beitrag den ganzen Server anpingen — ausgelöst von einem + fremden Text. Mit dieser Zeile bleibt eine Erwähnung sichtbar + und wirkungslos. */ + body: JSON.stringify({ + content: text, + allowed_mentions: { parse: [] }, + }), + /* KEIN `flags: 4`. Das wäre „Vorschauen unterdrücken" — und + genau die Vorschau ist das Banner, das Filipe will. Der Link + steht deshalb nackt am Ende des Textes; Discord holt sich + Bild, Titel und Beschreibung aus den og-Marken der offenen + Seite (sie stehen weiter unten in dieser Datei). */ + signal: AbortSignal.timeout(WEBHOOK_FRIST_MS), + }); + } catch (f) { + /* ABSICHTLICH OHNE DIE ADRESSE im Protokolltext. Eine + Fehlermeldung von `fetch` enthält sie sonst mitsamt dem + Schlüssel — und Protokolle liest man später in Ruhe. */ + console.error("[teilen] Discord nicht erreichbar:", f?.name || "Fehler"); + return { fehler: "discord_nicht_erreichbar" }; + } + if (antwort.ok) return { ok: true }; + /* 4xx heißt: an uns liegt es — falscher oder gelöschter Haken. + 5xx und 429 heißen: dort liegt es, später nochmal. */ + if (antwort.status >= 400 && antwort.status < 500 && antwort.status !== 429) { + console.error("[teilen] Discord lehnt ab:", antwort.status); + return { fehler: "discord_abgelehnt" }; + } + console.error("[teilen] Discord antwortet:", antwort.status); + return { fehler: "discord_nicht_erreichbar" }; +} + +/* ---------- Den Kanal eintragen, ohne Kommandozeile ------------------ + + Das Geheimnis geht von Filipes Zwischenablage über TLS direkt an + den Dienst, der es in eine Datei schreibt, die nur `dogiweb` lesen + darf. Es passiert NICHT den Chat, keine Befehlszeile und keine + `~/.bash_history` — am 03.09.2026 hat genau dieser Weg einen Token + in ein Bildschirmfoto und einen Chatverlauf getragen, und er + musste zurückgezogen werden. + + HERAUS GEHT SIE NIE WIEDER. `GET` beantwortet nur „ist eingerichtet" + und „seit wann". Wer sie ändern will, trägt eine neue ein; wer sie + nachlesen will, kann das nicht — auch DogFather nicht. Das ist + Absicht: Ein Feld, das ein Geheimnis zurückgibt, ist eine Kopie + mehr, und jede Kopie ist eine Stelle, an der sie wegkommen kann. + ------------------------------------------------------------------- */ + +/** Darf diese Person den Kanal der Agentur sehen und setzen? */ +function nurLeitungAgentur(req, res) { + /* ZWEI GETRENNTE HÄUSER (24.09.2026). Auf der Adresse von Team Dogi + gibt es den Agentur-Kanal nicht — auch nicht für DogFather, der in + beiden Häusern arbeitet. 404 und nicht 403: Was es hier nicht + gibt, soll man auch nicht erahnen können. */ + if (req.person?.haus === "crew") { + res.status(404).json({ fehler: "nicht_hier" }); + return false; + } + if (!istLeitung(req.person)) { + res.status(403).json({ fehler: "nicht_erlaubt" }); + return false; + } + return true; +} + +teilenRouter.get("/workspace/api/teilen/kanal", (req, res) => { + if (!nurLeitungAgentur(req, res)) return undefined; + const bereit = !!webhookAdresse(); + let seit = null; + try { seit = statSync(WEBHOOK_DATEI).mtime.toISOString(); } catch { seit = null; } + /* `steht` sagt, ob überhaupt eine Datei da ist. Sie kann da sein und + trotzdem untauglich — dann hat jemand etwas eingetragen, was keine + Discord-Adresse ist, und genau das soll man sehen statt zu rätseln, + warum der Knopf fehlt. */ + return res.json({ bereit, seit, steht: existsSync(WEBHOOK_DATEI) }); +}); + +teilenRouter.put("/workspace/api/teilen/kanal", (req, res) => { + if (!nurLeitungAgentur(req, res)) return undefined; + const adresse = adresseTaugt(req.body?.adresse); + if (!adresse) return res.status(400).json({ fehler: "adresse_untauglich" }); + try { + const kopf = [ + "# Discord-Kanal der Agentur.", + `# Eingetragen am ${heuteLokal()} über den Workspace.`, + "# Wer diese Adresse hat, schreibt in den Kanal. Sie gehört nirgends", + "# sonst hin -- nicht in eine Notiz, nicht in einen Chat, nicht in Gitea.", + adresse, + "", + ].join("\n"); + /* `mode` BEIM SCHREIBEN GILT NUR FÜR EINE NEUE DATEI. Liegt schon + eine da, behält sie ihre Rechte — deshalb danach noch einmal + ausdrücklich. Ohne das wäre eine einmal falsch angelegte Datei + für immer lesbar, und niemand würde es sehen. */ + writeFileSync(WEBHOOK_DATEI, kopf, { encoding: "utf8", mode: 0o600 }); + try { chmodSync(WEBHOOK_DATEI, 0o600); } catch { /* Windows kennt das nicht */ } + /* IM PROTOKOLL STEHT KEINE ADRESSE. Ein Protokoll liest man später + in Ruhe, und es wird gesichert. */ + protokolliere("kanal_eingetragen", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: "Discord-Kanal der Agentur", + }); + return res.json({ bereit: true }); + } catch (f) { + console.error("[teilen] Kanal eintragen:", f?.code || f?.message); + return res.status(503).json({ fehler: "nicht_gespeichert" }); + } +}); + +teilenRouter.delete("/workspace/api/teilen/kanal", (req, res) => { + if (!nurLeitungAgentur(req, res)) return undefined; + try { rmSync(WEBHOOK_DATEI); } catch { /* war nicht da -- dann ist er weg */ } + protokolliere("kanal_entfernt", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: "Discord-Kanal der Agentur", + }); + return res.json({ bereit: !!webhookAdresse() }); +}); + +teilenRouter.post("/workspace/api/eintrag/:id(\\d+)/discord", async (req, res) => { + const e = eventFuer(req, res); + if (!e) return undefined; + /* OHNE SCHLÜSSEL KEIN BEITRAG. Der Link ist der Kern der Nachricht; + ihn ohne Ziel zu verschicken wäre eine Einladung ins Leere. */ + if (!e.teilen_token) return res.status(409).json({ fehler: "nicht_geteilt" }); + + const adresse = webhookAdresse(); + if (!adresse) return res.status(409).json({ fehler: "discord_nicht_eingerichtet" }); + + const bremse = beitraegeLetzteStunde(req.person.id); + if (bremse.anzahl >= DISCORD_JE_STUNDE) { + protokolliere("senden_gebremst", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `${bremse.anzahl} Beiträge seit ${bremse.aeltester}`, + }); + return res.status(429).json({ fehler: "senden_gebremst" }); + } + + let text = null; + try { + const voll = eventMitNummer(e.id); + const basis = `${TEILEN_BASIS}/e/${e.teilen_token}`; + if (voll) text = discordText(teilenDaten(voll, basis)); + } catch (f) { + console.error("[teilen] Beitrag fuer Discord:", f?.message); + text = null; + } + if (!text) return res.status(503).json({ fehler: "kein_beitrag" }); + + const r = await inDenKanal(adresse, text); + protokolliere(r.ok ? "event_gesendet" : "event_nicht_gesendet", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `Eintrag ${e.id} "${String(e.titel).slice(0, 60)}"` + + (r.ok ? ` (${text.length} Zeichen)` : ` -- ${r.fehler}`), + }); + if (!r.ok) return res.status(502).json({ fehler: r.fehler }); + return res.json({ ok: true, zeichen: text.length }); +}); + /* ---------- Das Plakat, offen für alle -------------------------------- */ /** Was in HTML nicht roh stehen darf. Vier Zeichen, keine Bibliothek: diff --git a/workspace/anleitung.html b/workspace/anleitung.html index a96bfeed..67833422 100644 --- a/workspace/anleitung.html +++ b/workspace/anleitung.html @@ -7,17 +7,17 @@ - - - - - + + + + + - - + + @@ -289,16 +289,16 @@ Und anleitung.js braucht es doppelt: Symbol, Farbe, Name und Adresse jeder Karte kommen von dort, nicht vom Server. --> - + - - - - - - + + + + + + diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index e3c14662..46e57730 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`. --> - - + + - +