From 964590ecbbae6435420b4445d006cf7c50fc367f Mon Sep 17 00:00:00 2001 From: Dogfather Date: Thu, 8 Oct 2026 22:00:04 +0200 Subject: [PATCH] Teilen: der Beitrag stellt sich selbst in den Agentur-Kanal Filipe: "ich will dass wenn ich es teile im discord der agentur, dass ein banner und ein kompletter fertiger text fuer die creator entsteht und nicht einfach der link kopiert wird." Den fertigen Text gibt es seit fdadd5d5; was fehlte, war der letzte Schritt -- bisher musste er ihn selbst einfuegen. NEU AN DER KACHEL: ein Knopf "In den Agentur-Kanal stellen", sobald ein Kanal eingerichtet ist. Der Browser schickt dabei KEINEN Text mit -- der Server stellt ihn aus denselben Daten noch einmal zusammen. Sonst koennte jemand mit einem veraenderten Text unter unserem Namen posten. EINGERICHTET WIRD IM WORKSPACE, NICHT AUF DER KOMMANDOZEILE. Die Webhook-Adresse IST der Schluessel zum Kanal; ueber einen Befehl eingetragen landet sie im Chatverlauf, im Bildschirmfoto und in ~/.bash_history -- am 03.09.2026 ist genau das mit einem Token passiert. Gemessen am 08.10.2026 kann `claudian` die Datei ohnehin nicht anlegen: /home/dogiweb/workspace-daten ist drwxr-xr-x dogiweb:dogiweb, und `sudo -l` deckt `dogiweb` nicht ab. Der Dienst kann es. Damit braucht der Weg keine Rechteaenderung, kein root und keinen Neustart. DAS GEHEIMNIS liegt in /home/dogiweb/workspace-daten/discord-webhook.txt mit 600, wird bei JEDEM Aufruf gelesen (ein Austausch wirkt sofort) und kommt nie wieder heraus: GET beantwortet nur "ist eingerichtet" und "seit wann", das Protokoll nennt sie nicht, und die Teilen-Antwort auch nicht. DIE SCHRANKEN, jede einzeln gemessen: - nur discord.com/discordapp.com/ptb./canary., nur https, nur Wege unter /api/webhooks/ -- sonst waere das Feld ein Weg, den Server zu beliebigen Zielen sprechen zu lassen - nur die Leitung, nur die Agentur-Adresse; von crew.* aus 404 statt 403, damit man nicht erahnen kann, was es woanders gibt - allowed_mentions leer: im Beitrag steht TikToks Text, und ein "@everyone" darin wuerde sonst den ganzen Server anpingen - keine flags: die Vorschau bleibt an, denn genau sie ist das Banner - ohne Teilen-Link keine Nachricht - zwoelf je Stunde und Person, gezaehlt im Protokoll (Erfolge UND Fehlschlaege -- sonst bremst sie den nicht, der in einer Schleife haengt; derselbe Fehler wie am 07.10. an der Kampagnen-Bremse) SIEBEN NEUE KENNUNGEN, sieben Saetze in meldung.js -- verlangt hat sie `pruef-meldungen`, nicht mein Gedaechtnis. Jeder Satz sagt auch, was stattdessen geht: Der Beitrag liegt in jedem Fall zum Kopieren da. ZWEI FEHLER FANDEN NUR DIE PRUEFUNGEN: - teilenRouter.use("/workspace/api/eintrag", angemeldet) deckte die neuen Kanal-Wege nicht ab. Ohne req.person ist istLeitung falsch, also antworteten sie JEDEM mit 403 "nicht_erlaubt", auch DogFather. Es ist ZU gescheitert, nicht auf -- aber die Auskunft schickte in die falsche Richtung. - Das Datum im Dateikopf kam aus UTC (pruef-struktur): zwischen 00:00 und 02:00 stuende dort der Vortag, in einer Zeile, die spaeter jemand liest, um zu wissen, seit wann der Kanal gilt. Geprueft: pruef-kampagne 190 -> 226 (Abschnitte 12c und 12d), mit einer ehrlichen Luecke -- die Dateirechte lassen sich auf Windows nicht messen, auf dem Server schon; die Schlusszeile sagt jetzt "IN ORDNUNG, mit Luecke" statt "ALLES IN ORDNUNG". bild-kampagne 53 -> 83 (Kanalfeld vermessen und abgebildet, Eingabefeld 45/46 px auf beiden Breiten). Dazu gruen: meldungen 10, struktur, css-klassen, zeichen, deutsche-texte, alle-wege, schranke, ports, portnummern, tippziele, lesbarkeit, agentur 67, eventkarte 190, workspace-seiten 37, haus-trennung 107, formulare 23, fingermass. SECHS GEGENPROBEN am Weg in den Kanal, alle sechs schlagen an: Rechnerliste uebergangen, Erwaehnungen wieder erlaubt, Vorschau unterdrueckt, Bremse aus, ohne Teilen-Link gesendet, Adresse in die Antwort gelegt. NEBENBEI, UND ES WAR MEIN EIGENER REST: pruef-kampagne-lesen war seit 698c23e2 rot, also seit heute Vormittag. Mit dem Bildleser kamen drei Pruefdaten in den Ordner, und die Wache dort verlangt zu Recht, dass nichts Unerklaertes liegenbleibt. Ich hatte sie VOR dem Commit gemessen und danach nicht noch einmal -- ein Pruefstand von vorher ist keiner. Die drei sind jetzt benannt, mit Grund, und eine Gegenwache prueft, dass es jede noch gibt. 186 -> 187, zwei Gegenproben: eine unbekannte Datei faellt auf, ein toter Eintrag in der Liste auch. --- server/bild-kampagne.mjs | 64 ++++++ server/pruef-kampagne-lesen.mjs | 27 ++- server/pruef-kampagne.mjs | 363 ++++++++++++++++++++++++++++++- server/workspace-teilen.js | 308 +++++++++++++++++++++++++- workspace/anleitung.html | 28 +-- workspace/anruf-probe.html | 10 +- workspace/app.webmanifest | 8 +- workspace/assets/css/bereich.css | 30 +++ workspace/assets/js/bereich.js | 183 +++++++++++++++- workspace/assets/js/meldung.js | 36 ++- workspace/aufgaben.html | 38 ++-- workspace/automation.html | 32 +-- workspace/befinden.html | 34 +-- workspace/bereich.html | 91 ++++++-- workspace/bewerben.html | 38 ++-- workspace/bewerbungen.html | 36 +-- workspace/buehne.html | 6 +- workspace/calls.html | 34 +-- workspace/chat.html | 42 ++-- workspace/content.html | 34 +-- workspace/crew-index.html | 12 +- workspace/crew.webmanifest | 8 +- workspace/dateien.html | 34 +-- workspace/entwicklung.html | 36 +-- workspace/hilfe.html | 30 +-- workspace/index.html | 10 +- workspace/kalender.html | 34 +-- workspace/leistung.html | 32 +-- workspace/manager-ziele.html | 30 +-- workspace/material.html | 32 +-- workspace/notizen.html | 28 +-- workspace/personen.html | 32 +-- workspace/profil.html | 36 +-- workspace/reaktion-foyer.html | 30 +-- workspace/reaktion.html | 40 ++-- workspace/rechte.html | 34 +-- workspace/report.html | 36 +-- workspace/scouting.html | 32 +-- workspace/start.html | 38 ++-- workspace/startcheck.html | 34 +-- workspace/steckbrief.html | 36 +-- workspace/support.html | 28 +-- workspace/tafel.html | 6 +- workspace/talente.html | 36 +-- workspace/team.html | 32 +-- workspace/teamlage.html | 34 +-- workspace/teilen.html | 30 +-- workspace/treff-moderation.html | 32 +-- workspace/treff-regeln.html | 32 +-- workspace/uebersicht.html | 32 +-- workspace/unsere-seiten.html | 30 +-- workspace/unterstuetzen.html | 32 +-- workspace/werdegang.html | 32 +-- workspace/willkommen.html | 2 +- workspace/wissen.html | 34 +-- 55 files changed, 1753 insertions(+), 715 deletions(-) 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`. --> - - + + - +