Files
dogfather-universe/server/workspace-teilen.js
T
DogFatherGit 964590ecbb 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.
2026-10-08 22:00:04 +02:00

1164 lines
48 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
EIN EVENT NACH DRAUSSEN TEILEN (07.10.2026)
Filipe: „ich will auch eine option wo ich das teilen kann so dass ich
es z.b. in discord poste und es erscheint dan im discord mit einem
geilen banner und perfekten text. … ich will einfach dass es perfekt
läuft und klappt … und so dass die seite geschützt bleibt."
Beides zusammen ist die ganze Aufgabe dieser Datei.
---------------------------------------------------------------------
WARUM ES EINE SEITE OHNE ANMELDUNG BRAUCHT
Discord baut seine Vorschau selbst: Es ruft die Adresse ab, liest die
Open-Graph-Angaben im Kopf und zeigt Bild, Titel und Text. Es meldet
sich dabei NIRGENDS an und schickt keinen Keks mit. Eine Seite hinter
der Wand ergibt deshalb im Chat nur einen nackten Link — oder, noch
schlechter, die Vorschau der Anmeldeseite.
Dasselbe gilt für WhatsApp, Telegram, Signal und Slack. Wer teilen
will, braucht eine öffentliche Seite. Die Frage ist nicht OB, sondern
WIE WENIG.
---------------------------------------------------------------------
WIE WENIG — die fünf Regeln, an denen der Schutz hängt
1. NICHTS IST OFFEN, BIS JEMAND ES OEFFNET. Ohne `teilen_token` gibt
es keine Seite. Die Vorgabe ist zu, nicht offen.
2. DER SCHLUESSEL WIRD GEWUERFELT, NICHT GEZAEHLT. 16 Byte Zufall.
Eine laufende Nummer liesse sich durchprobieren -- und dann waere
nicht ein Event offen, sondern alle.
3. ES GEHT NUR HINAUS, WAS AUF EIN PLAKAT GEHOERT. Titel, Zeitraum,
Einleitung, Banner. KEINE Aufgaben, KEINE Regeln, KEINE Namen,
keine Rollen, keine Nummern, kein Haus. Wer den Link hat, sieht
ein Plakat -- nicht die Arbeitsfläche dahinter.
4. ZURUECKZIEHEN GEHT SOFORT. Ein Druck, und die Seite ist weg --
nicht „nach einer Weile", sondern beim nächsten Abruf.
5. SUCHMASCHINEN BLEIBEN DRAUSSEN. `noindex, nofollow` im Kopf UND
als `X-Robots-Tag` im Kopf der Antwort. Ein geteilter Link soll
im Chat landen, nicht bei Google.
---------------------------------------------------------------------
WARUM AUF DER HAUPTADRESSE UND NICHT IM WORKSPACE
`workspace.dogfather-universe.com` leitet in Caddy alles ausserhalb
von /workspace/ und /assets/ auf die Startseite um -- eine
öffentliche Seite dort hiesse, an dieser Umleitung zu drehen. Die
Hauptadresse ist ohnehin öffentlich; dort kostet es nichts.
Aufrufen:
POST /workspace/api/eintrag/:id/teilen -> { weg }
DELETE /workspace/api/eintrag/:id/teilen -> zurueckgezogen
GET /e/:token -> das Plakat (offen)
GET /e/:token/bild -> das Banner (offen)
===================================================================== */
import express from "express";
import { randomBytes } from "node:crypto";
import { join } from "node:path";
import { existsSync, readFileSync, writeFileSync, chmodSync, rmSync, statSync } from "node:fs";
import {
db, protokolliere, echteIp, sitzungLesen, istLeitung,
DATEN_ORDNER } from "./workspace.js";
import { liefereDatei } from "./helfer-ausliefern.mjs";
/* DIESELBE ZERLEGUNG WIE DIE KACHEL, nicht eine zweite.
`gliederung` macht aus dem Regeltext Abschnitte, `punkteAusText`
aus den Aufgaben eine Liste. Beides hier nachzubauen hiesse: Die
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();
const ORDNER = join(DATEN_ORDNER, "dateien");
/* Die Adresse, unter der ein geteiltes Event steht. Sie steht hier
EINMAL -- die Oberfläche bekommt den fertigen Weg vom Server und
baut ihn nicht selbst zusammen. Zwei Fassungen derselben Adresse
laufen auseinander, und eine davon landet dann in einem Chat. */
export const TEILEN_BASIS =
process.env.TEILEN_BASIS || "https://dogfather-universe.com";
/** Ein neuer Schlüssel. 16 Byte, als base64url -- 22 Zeichen ohne
* Sonderzeichen, die in einer Adresse Ärger machen. */
const neuerSchluessel = () => randomBytes(16).toString("base64url");
const jetzt = () => new Date().toISOString();
function angemeldet(req, res, next) {
const person = sitzungLesen(req);
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
req.person = person;
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) {
const id = Number(req.params.id);
if (!Number.isFinite(id)) { res.status(400).json({ fehler: "keine_nummer" }); return null; }
const e = db().prepare(
"SELECT id, bereich, art, titel, teilen_token, haus FROM eintraege WHERE id = ?").get(id);
if (!e) { res.status(404).json({ fehler: "nicht_gefunden" }); return null; }
/* NUR AGENTUR-EVENTS. Ein Aushang aus dem Rudel, eine Aufgabe oder
eine vertrauliche Aufzeichnung hat auf einem Plakat nichts
verloren -- und „teilen" waere dort nicht einmal gemeint. Die
Frage wird POSITIV gestellt: Was hier nicht ausdrücklich erlaubt
ist, ist es nicht. */
if (e.bereich !== "agentur" || e.art !== "kampagne") {
res.status(403).json({
fehler: "Teilen gibt es nur für Kampagnen und Events der Agentur.",
});
return null;
}
/* Und nur, wenn es zur Agentur gehört. Ein Eintrag aus dem Haus
Team Dogi wird nicht öffentlich, auch nicht aus Versehen. */
if (e.haus === "crew") {
res.status(403).json({
fehler: "Dieses Event gehört zu Team Dogi und wird nicht geteilt.",
});
return null;
}
if (!istLeitung(req.person)) {
res.status(403).json({
fehler: "Teilen dürfen DogFather und die Manager.",
});
return null;
}
return e;
}
teilenRouter.post("/workspace/api/eintrag/:id(\\d+)/teilen", (req, res) => {
const e = eventFuer(req, res);
if (!e) return undefined;
try {
let schluessel = e.teilen_token;
if (!schluessel) {
/* Beim zweiten Druck KEIN neuer Schlüssel: Der alte Link ist
vielleicht schon in einem Chat. Einen neuen zu würfeln hiesse,
ihn stillschweigend ins Leere laufen zu lassen. */
schluessel = neuerSchluessel();
db().prepare(
"UPDATE eintraege SET teilen_token = ?, teilen_seit = ? WHERE id = ?")
.run(schluessel, jetzt(), e.id);
protokolliere("event_geteilt", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `Eintrag ${e.id} "${String(e.titel).slice(0, 60)}"`,
});
}
/* ---- DER FERTIGE BEITRAG KOMMT MIT (08.10.2026) ----------
Filipe: „beim teilen wird auch nur der link geteilt ... das
ist kake.“ Er hat recht: Ein Link ist eine Aufforderung,
und wer ihn nicht anklickt, weiß nicht, dass es die
Kampagne gibt.
GEBAUT WIRD HIER, NICHT IM BROWSER. Der Beitrag enthält
Aufgaben, Geschenke und Regeln -- dieselben Daten, die die
offene Seite zeigt, aus derselben Funktion
(`teilenDaten`). Im Browser gebaut wäre es die zweite
Fassung derselben Sache, und die läuft auseinander.
FÄLLT ER AUS, BLEIBT DER LINK. Ein Teilen, das wegen eines
Textbaufehlers gar nicht geht, wäre schlimmer als eines
ohne Text -- deshalb steht der Bau in einem eigenen
`try`. */
const basis = `${TEILEN_BASIS}/e/${schluessel}`;
let beitrag = null;
try {
const voll = eventMitNummer(e.id);
if (voll) beitrag = discordText(teilenDaten(voll, basis));
} catch (f) {
console.error("[teilen] Beitrag:", f?.message);
beitrag = null;
}
/* `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." });
}
});
teilenRouter.delete("/workspace/api/eintrag/:id(\\d+)/teilen", (req, res) => {
const e = eventFuer(req, res);
if (!e) return undefined;
try {
db().prepare(
"UPDATE eintraege SET teilen_token = NULL, teilen_seit = NULL WHERE id = ?")
.run(e.id);
protokolliere("event_zurueckgezogen", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `Eintrag ${e.id}`,
});
return res.json({ weg: null });
} catch (fehler) {
console.error("[teilen] zurueckziehen:", fehler?.message);
return res.status(503).json({ fehler: "Das Zurückziehen hat gerade nicht geklappt." });
}
});
/* =====================================================================
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:
* Alles hier geht in Text- oder Attributstellen, nicht in Skript. */
const sicher = (s) => String(s ?? "")
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
/** Ein Datum für Menschen: 05.10.2026 */
function tagText(iso) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(iso || ""));
return m ? `${m[3]}.${m[2]}.${m[1]}` : null;
}
/* DIE SPALTEN STEHEN EINMAL DA (08.10.2026).
Seit es zwei Abfragen gibt -- eine ueber den Schluessel (die
offene Seite) und eine ueber die Nummer (der Discord-Text beim
Teilen) -- waere eine zweite Liste die Sorte Abschrift, die in
diesem Haus schon einmal drei Spalten gekostet hat. Wer hier
etwas ergaenzt, ergaenzt es fuer beide.
`erstellt_von` und `haus` stehen ABSICHTLICH nicht dabei: Was
die Kampagne sagt, geht hinaus -- wer wir sind, nicht. */
const TEILEN_SPALTEN = `id, titel, text, datum, event_ende, kampagne_id,
teilen_seit, teilen_token, event_aufgaben, event_regeln,
kampagne_daten`;
/** Das Event zu einer Nummer -- fuer den Weg ueber den Teilen-Knopf. */
function eventMitNummer(id) {
try {
return db().prepare(
`SELECT ${TEILEN_SPALTEN}
FROM eintraege
WHERE id = ? AND bereich = 'agentur' AND art = 'kampagne'`)
.get(Number(id)) || null;
} catch { return null; }
}
/** Das Event zu einem Schlüssel -- oder null. */
function eventZu(token) {
if (!/^[A-Za-z0-9_-]{10,64}$/.test(String(token || ""))) return null;
try {
return db().prepare(
/* NEU SEIT DEM 07.10.2026: event_aufgaben, event_regeln,
kampagne_daten. Siehe die Begruendung bei `seitePlakat`,
wo die Grenze gezogen wird -- was die KAMPAGNE sagt, geht
hinaus; was ueber UNS etwas sagt, nicht. Deshalb steht
`erstellt_von` hier weiterhin nicht, und `haus` auch
nicht. */
`SELECT ${TEILEN_SPALTEN}
FROM eintraege
WHERE teilen_token = ? AND bereich = 'agentur' AND art = 'kampagne'`)
.get(String(token)) || null;
} catch { return null; }
}
/* NICHT IN SUCHMASCHINEN. Steht als Kopfzeile UND im HTML: Die
Kopfzeile gilt auch für das Banner, das gar kein HTML hat. */
function nichtIndizieren(res) {
res.setHeader("X-Robots-Tag", "noindex, nofollow, noarchive");
}
teilenRouter.get("/e/:token/bild", (req, res) => {
nichtIndizieren(res);
const e = eventZu(req.params.token);
if (!e) return res.status(404).type("text/plain").send("nicht gefunden");
let datei;
try {
datei = db().prepare(
"SELECT name_datei, typ FROM dateien WHERE eintrag_id = ? ORDER BY id LIMIT 1")
.get(e.id);
} catch { datei = null; }
if (!datei) return res.status(404).type("text/plain").send("kein Bild");
const pfad = join(ORDNER, datei.name_datei);
if (!existsSync(pfad)) return res.status(404).type("text/plain").send("kein Bild");
if (datei.typ) res.setHeader("Content-Type", datei.typ);
/* Eine Stunde im Zwischenspeicher: Discord holt das Bild einmal je
Nachricht, und ein Banner ändert sich nicht. Länger wäre falsch --
wer zurückzieht, will nicht, dass es noch tagelang irgendwo hängt. */
res.setHeader("Cache-Control", "public, max-age=3600");
return liefereDatei(req, res, pfad);
});
/* =====================================================================
WAS VON EINEM EVENT NACH DRAUSSEN GEHT -- AN EINER STELLE
(08.10.2026)
Gerufen von ZWEI Wegen: der offenen Seite (`GET /e/:token`) und
dem Discord-Text (`POST .../teilen`). Beide zeigen dasselbe
Event; haetten sie je einen eigenen Aufbau, wuerde die Seite
irgendwann etwas anderes sagen als der Beitrag daneben -- und
niemand koennte sagen, welcher von beiden recht hat.
Die Grenze ist dieselbe wie vorher: Was die KAMPAGNE sagt, geht
hinaus; keine Hinweiszeile an DogFather, kein Steckbrief, kein
Name, keine Rolle, kein Haus.
===================================================================== */
export function teilenDaten(e, basis) {
const von = tagText(e.datum);
const bis = tagText(e.event_ende);
const zeitraum = von && bis && bis !== von ? `${von} – ${bis}` : (von || "");
const text = String(e.text || "").replace(/\s+/g, " ").trim();
/* DISCORD ZEIGT RUND 300 ZEICHEN -- UND DER ZEITRAUM STEHT MIT
DAVOR (berichtigt 07.10.2026).
Hier stand 280. Gemessen kam `og:description` auf 304
Zeichen heraus: Die Beschreibung ist `zeitraum · kurz`, und
der Zeitraum („04.10.2026 – 07.10.2026 · ") braucht allein
26 Zeichen. Wer nur den Text begrenzt, begrenzt nicht die
Angabe, die wirklich hinausgeht.
250 statt 280: 250 + 26 = 276, mit Luft fuer einen laengeren
Zeitraum. Eine Wortgrenze bleibt es -- ein Satz, der mitten
im Wort endet, sieht nach einem Fehler aus. */
const kurz = text.length > 250
? text.slice(0, text.lastIndexOf(" ", 247)) + " …"
: text;
/* ---- WAS DIE KAMPAGNE SAGT (07.10.2026) ------------------
Filipe: „auch wenn man teilt soll es einfach perfekt sein.
man soll alle infos sehen die man braucht."
Bis heute standen hier Titel, Zeitraum, Einleitung und Banner
-- also gerade so viel, dass Discord eine Vorschau bauen kann.
Wer dem Link folgte, erfuhr nicht, was zu tun ist.
DIE GRENZE LIEGT NICHT BEI DER MENGE, SONDERN BEIM GEGENSTAND:
Was die KAMPAGNE sagt, darf hinaus -- Aufgaben, Geschenke,
Regeln. Was ueber UNS etwas sagt, nicht: kein Name, keine
Rolle, kein Haken, kein Haus, keine Kampagnennummer, kein
Steckbrief. Der Grund ist nicht Geheimhaltung um ihrer selbst
willen: Wer den Link weitergibt, gibt eine EINLADUNG weiter,
und eine Einladung nennt die Sache, nicht die Einladenden.
DIE HINWEISE BLEIBEN DRINNEN, und das ist die wichtigste
Zeile hier. „Die Preise konnten nicht gelesen werden -- bitte
nachtragen" ist eine Notiz AN DOGFATHER. Auf einer Seite, die
er in Discord postet, stuende damit vor seinen Creatorn, dass
seine Kachel unfertig ist. Dasselbe gilt fuer die Fussnote
ueber einen gekuerzten Regeltext. Beides sind Hinweiszeilen,
beide fallen hier weg -- an einer Stelle, mit einer Regel.
DER STECKBRIEF BLEIBT EBENFALLS DRINNEN. Kurznummer, Markt
und Zeitzone sind Buchhaltung, keine Einladung; sie stehen
auf der Kachel, wo sie gebraucht werden. */
let auszug = null;
if (e.kampagne_daten) {
try { auszug = JSON.parse(e.kampagne_daten); } catch { auszug = null; }
}
const aufgaben = punkteAusText(e.event_aufgaben).map((p) => p.text);
const abschnitte = gliederung(e.event_regeln,
{ geschenke: auszug?.geschenke || [] })
/* Kein Hinweisabschnitt, und auch keine Hinweiszeile in einem
anderen Abschnitt. */
.filter((a) => a.art !== "hinweise")
.map((a) => ({ ...a, zeilen: a.zeilen.filter((z) => z.art !== "hinweis") }))
.filter((a, i, alle) => a.zeilen.length
|| (alle[i + 1] && alle[i + 1].stufe > a.stufe));
/* Die Geschenke werden aus den Abschnitten herausgezogen -- wie
auf der Kachel: Sie stehen oben, nicht mitten im Regeltext. */
const gabenAb = abschnitte.find((a) => a.art === "geschenke") || null;
const rest = abschnitte.filter((a) => a !== gabenAb);
const gaben = (gabenAb?.zeilen || []).filter((z) => z.art === "geschenk");
/* DIE VORSCHAU IN DISCORD SOLL AUCH OHNE EINLEITUNG ETWAS SAGEN.
Gemessen an „LIVE Glow Up": Die Kampagne hat gar keinen
Einleitungstext (der Inhalt steht in den Klapptexten) -- in der
Vorschau stand damit nur ein Zeitraum. Die ersten Aufgaben
sagen in derselben Zeile mehr als jede Umschreibung. */
const aufgabenKurz = aufgaben.slice(0, 3).join(" · ");
const beschreibung = [zeitraum, kurz || aufgabenKurz]
.filter(Boolean).join(" · ");
const hatBild = (() => {
try {
return !!db().prepare("SELECT 1 FROM dateien WHERE eintrag_id = ? LIMIT 1").get(e.id);
} catch { return false; }
})();
return {
titel: String(e.titel || "Event"),
zeitraum,
text: kurz,
beschreibung,
basis,
bild: hatBild ? `${basis}/bild` : null,
vonTikTok: !!e.kampagne_id,
aufgaben,
gaben,
gabenTitel: gabenAb?.titel || null,
gabenUnter: gabenAb?.unter || null,
abschnitte: rest,
};
}
/* =====================================================================
EIN FERTIGER BEITRAG FÜR DISCORD (08.10.2026)
Filipe: „beim teilen wird auch nur der link geteilt, und 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 damit die leute drauf gehen. das ist
kake."
Er hat recht, und zwar aus einem Grund, den man im Workspace nicht
sieht: Ein Link in einem Discord-Kanal ist eine AUFFORDERUNG. Wer
ihn nicht anklickt — und das sind die meisten — weiß nicht, dass es
die Kampagne gibt. Was in Discord steht, muss die Kampagne SEIN.
---------------------------------------------------------------------
DER BANNER KOMMT VON SELBST, UND ZWAR ÜBER DEN LINK
Discord baut aus den Open-Graph-Angaben der offenen Seite eine
Vorschau mit großem Bild. Die steht UNTER der Nachricht. Deshalb:
- Der Link steht GANZ AM ENDE. Steht er oben, schiebt die Vorschau
den Text nach unten aus dem Blick.
- Der Link steht NACKT da, nicht in spitzen Klammern. `<…>`
unterdrückt die Vorschau — und damit genau das Banner, um das es
geht.
---------------------------------------------------------------------
2000 ZEICHEN, UND NICHT EINS MEHR
Discord nimmt pro Nachricht 2000 Zeichen. Eine Nachricht, die beim
Einfügen abgeschnitten wird, endet mitten im Wort — und niemand
merkt es, bis ein Creator nachfragt. Gekürzt wird deshalb HIER, in
fester Reihenfolge und an Abschnittsgrenzen:
1. „Gut zu wissen" fällt weg (steht vollständig auf der Seite)
2. die Einleitung wird kürzer (steht vollständig auf der Seite)
3. die Geschenke fallen weg (stehen vollständig auf der Seite)
Titel, Zeitraum, Aufgaben und der Link bleiben IMMER. Das sind die
vier Dinge, wegen denen man den Beitrag überhaupt schreibt.
---------------------------------------------------------------------
KEINE NAMEN, KEINE ROLLEN
Derselbe Satz wie bei der offenen Seite: Was die Kampagne sagt, geht
hinaus. Wer sie eingelesen hat, steht nirgends — der Beitrag kommt
vom Team, nicht von einer Person.
===================================================================== */
const DISCORD_MAX = 2000;
/** Discord-Auszeichnung in fremdem Text entwerten.
*
* EIN TITEL MIT EINEM STERN DARIN würde sonst die halbe Nachricht
* kursiv setzen. Entwertet wird nur dort, wo FREMDER Text steht
* (Titel, Aufgaben, Geschenke) -- nicht in den Überschriften, die wir
* selbst schreiben.
*
* ZWEI SORTEN ZEICHEN, UND SIE BRAUCHEN ZWEI REGELN (berichtigt
* 08.10.2026, beim ersten Blick auf den fertigen Beitrag):
*
* ÜBERALL \ * _ ~ ` | die wirken mitten im Wort
* NUR AM ANFANG # - > die wirken nur am Zeilenanfang
*
* Zuerst stand der Bindestrich in der ersten Gruppe. Heraus kam
* „4\-wöchige Wachstum Challenge" und „Ahorn\-Welt" -- die
* Rückstriche standen sichtbar im Beitrag. Ein Bindestrich mitten im
* Wort ist in Discord keine Aufzählung; ihn zu entwerten macht den
* Text kaputt, statt ihn zu retten. Dasselbe gilt für `#` und `>`.
*
* Der Rückstrich ist in Discord das Entwertungszeichen, genau wie in
* Markdown -- und steht deshalb selbst mit in der ersten Gruppe. */
function discordRoh(s) {
return String(s ?? "")
.replace(/([\\*_~`|])/g, "\\$1")
.replace(/^([#\->])/, "\\$1");
}
/** Ein fertiger Beitrag für den Agentur-Kanal.
*
* @param {object} d was `teilenDaten()` geliefert hat
* @returns {string} höchstens 2000 Zeichen, Link am Ende
*/
export function discordText(d) {
const kopf = [];
kopf.push(`# ${discordRoh(d.titel)}`);
const zeile2 = [];
if (d.zeitraum) zeile2.push(`**${discordRoh(d.zeitraum)}**`);
zeile2.push(d.vonTikTok ? "TikTok-Kampagne" : "Event der Agentur");
kopf.push(zeile2.join(" · "));
/* Die Einleitung. Sie darf lang sein -- gekürzt wird sie erst, wenn
es wirklich eng wird (siehe unten). */
const einleitung = d.text ? [``, discordRoh(d.text)] : [];
const aufgaben = (d.aufgaben || []).length
? [``, `## 🎯 ${d.aufgaben.length === 1 ? "Deine Aufgabe" : "Deine Aufgaben"}`,
...d.aufgaben.map((a) => `- ${discordRoh(a)}`)]
: [];
const gaben = (d.gaben || []).length
? [``, "## 🎁 Diese Geschenke zählen",
...d.gaben.map((g) => {
const c = coinText(g.coins);
const zusatz = c || g.zusatz;
return `- **${discordRoh(g.text)}**${zusatz ? ` · ${discordRoh(zusatz)}` : ""}`;
})]
: [];
/* „Gut zu wissen": die Punkte aus Rangliste, Zeitplan und Teilnahme
-- aber nur die kurzen. Ein ganzer Teilnahmebedingungs-Absatz in
einem Discord-Beitrag liest niemand; dafür gibt es die Seite. */
const merken = [];
for (const a of d.abschnitte || []) {
if (!/rangliste|zeitplan|teilnahme/i.test(a.art || "")) continue;
for (const z of a.zeilen || []) {
if (z.art !== "punkt" && z.art !== "text") continue;
if (z.text.length > 120) continue;
merken.push(`- ${discordRoh(z.text)}`);
if (merken.length >= 5) break;
}
if (merken.length >= 5) break;
}
const wissen = merken.length ? [``, "## ℹ️ Gut zu wissen", ...merken] : [];
/* DER LINK ZULETZT UND NACKT -- Begründung oben. */
const fuss = [``, "-# Alle Regeln, Preise und Ligen im Detail:", d.basis];
/* ZUSAMMENSETZEN UND NOTFALLS KÜRZEN. Gebaut wird von der
vollständigen Fassung abwärts, nicht von der kurzen aufwärts: So
steht immer die beste Fassung da, die noch passt. */
const bauen = (teile) => teile.flat().join("\n").replace(/\n{3,}/g, "\n\n").trim();
let aus = bauen([kopf, einleitung, aufgaben, gaben, wissen, fuss]);
if (aus.length <= DISCORD_MAX) return aus;
aus = bauen([kopf, einleitung, aufgaben, gaben, fuss]);
if (aus.length <= DISCORD_MAX) return aus;
/* Die Einleitung auf zwei Sätze. An einer Satzgrenze, nicht mitten
im Wort -- ein abgeschnittenes Wort sieht nach einem Fehler aus. */
if (d.text) {
const saetze = String(d.text).split(/(?<=[.!?])\s+/);
const kurz = saetze.slice(0, 2).join(" ");
aus = bauen([kopf, [``, discordRoh(kurz)], aufgaben, gaben, fuss]);
if (aus.length <= DISCORD_MAX) return aus;
}
aus = bauen([kopf, aufgaben, gaben, fuss]);
if (aus.length <= DISCORD_MAX) return aus;
aus = bauen([kopf, aufgaben, fuss]);
if (aus.length <= DISCORD_MAX) return aus;
/* Selbst Titel, Aufgaben und Link passen nicht -- dann werden die
Aufgaben beschnitten. Der Link bleibt in jedem Fall: Ohne ihn ist
der Beitrag eine Sackgasse. */
const ohneAufgaben = bauen([kopf, fuss]);
const platz = DISCORD_MAX - ohneAufgaben.length - 40;
const gekuerzt = [];
let laenge = 0;
for (const a of aufgaben.slice(2)) {
if (laenge + a.length + 1 > platz) break;
gekuerzt.push(a);
laenge += a.length + 1;
}
return bauen([kopf,
gekuerzt.length ? [``, aufgaben[1], ...gekuerzt, "- …"] : [],
fuss]);
}
teilenRouter.get("/e/:token", (req, res) => {
nichtIndizieren(res);
const e = eventZu(req.params.token);
if (!e) {
/* ZURUECKGEZOGEN SIEHT AUS WIE NIE DAGEWESEN. Ein eigener Satz
(„war mal da") würde bestätigen, dass es diesen Schlüssel gab --
und damit Durchprobieren belohnen. */
return res.status(404).type("text/html; charset=utf-8").send(seiteLeer());
}
const basis = `${TEILEN_BASIS}/e/${encodeURIComponent(req.params.token)}`;
res.type("text/html; charset=utf-8");
return res.send(seitePlakat(teilenDaten(e, basis)));
});
/* ---------- Die beiden Seiten ----------------------------------------- */
const STIL = `
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh;
display: flex; align-items: center; justify-content: center;
padding: 24px;
background: #070a10;
color: #e8f1fa;
font: 16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
.k {
width: min(560px, 100%);
background: #0d131d;
border: 1px solid rgba(255,255,255,.1);
border-radius: 18px;
overflow: hidden;
}
.k__bild { display: block; width: 100%; height: auto; }
.k__inhalt { padding: 22px 24px 26px; }
.k__schild {
display: inline-flex; align-items: center; gap: 7px;
padding: 3px 12px; border-radius: 999px;
font-size: .72rem; font-weight: 700; letter-spacing: .14em;
text-transform: uppercase;
}
.k__schild::before {
content: ""; width: 7px; height: 7px; border-radius: 50%;
background: currentColor;
}
.k__schild[data-quelle="tiktok"] {
color: #3fbdf5; background: rgba(63,189,245,.15);
}
.k__schild[data-quelle="agentur"] {
color: #cace02; background: rgba(202,206,2,.15);
}
h1 { margin: 14px 0 6px; font-size: 1.6rem; line-height: 1.25; }
.k__zeit { margin: 0 0 14px; color: #9fb2c6; font-weight: 600; }
.k__text { margin: 0; color: #c5d4e3; }
.k__fuss {
margin: 20px 0 0; padding-top: 16px;
border-top: 1px solid rgba(255,255,255,.08);
color: #7f95ad; font-size: .85rem;
}
a { color: #7fd2fb; }
/* ---- Alles, was seit dem 07.10.2026 dazugekommen ist ----
Dieselbe Gliederung wie auf der Kachel, nur ohne Aufklappen:
Wer einem geteilten Link folgt, will lesen und nicht
bedienen. Augenschonend bleibt es trotzdem -- der
Unterschied laeuft ueber Groesse, Gewicht und Sperrung,
nicht ueber grelle Farben. */
.k__block {
margin: 20px 0 0; padding-top: 16px;
border-top: 1px solid rgba(255,255,255,.08);
}
.k__schild2 {
margin: 0 0 10px;
font-size: .74rem; font-weight: 700;
letter-spacing: .1em; text-transform: uppercase;
color: #9fb2c6;
}
.k__liste { margin: 0 0 8px; padding-left: 20px; color: #c5d4e3; }
.k__liste li { margin: 3px 0; font-size: .93rem; }
.k__liste:last-child { margin-bottom: 0; }
.k__woher { margin: 0 0 10px; color: #c5d4e3; font-size: .9rem; }
.k__unter { display: block; margin-top: 2px; color: #9fb2c6; font-size: .88rem; }
.k__gaben { display: flex; flex-wrap: wrap; gap: 7px; margin: 0; padding: 0; list-style: none; }
.k__gabe {
display: inline-flex; align-items: center; gap: 7px;
padding: 5px 11px; border-radius: 999px;
border: 1px solid rgba(226,193,90,.28);
background: rgba(226,193,90,.08);
font-size: .9rem; max-width: 100%;
}
.k__gname { font-weight: 600; overflow-wrap: anywhere; }
.k__coins {
padding: 1px 8px; border-radius: 999px;
background: rgba(226,193,90,.18); color: #e8d08f;
font-size: .82rem; font-weight: 700; white-space: nowrap;
}
.k__gzusatz { color: #9fb2c6; font-size: .84rem; overflow-wrap: anywhere; }
.k__abschnitt { margin: 0 0 14px; }
.k__abschnitt:last-child { margin-bottom: 0; }
.k__abschnitt[data-stufe="3"] {
margin-left: 2px; padding-left: 12px;
border-left: 2px solid rgba(255,255,255,.1);
}
.k__titel {
margin: 0 0 6px;
font-size: .8rem; font-weight: 700;
letter-spacing: .08em; text-transform: uppercase;
color: #d7e3f0;
}
.k__abschnitt[data-stufe="3"] > .k__titel {
font-size: .92rem; font-weight: 650;
letter-spacing: .01em; text-transform: none;
}
.k__absatz { margin: 0 0 7px; color: #c5d4e3; font-size: .93rem; }
.k__absatz:last-child { margin-bottom: 0; }
.k__frage { margin: 11px 0 5px; color: #e8f1fa; font-weight: 650; font-size: .93rem; }
.k__frage:first-child { margin-top: 0; }
@media (max-width: 480px) {
.k__abschnitt[data-stufe="3"] { padding-left: 9px; }
}
`;
/* ===================================================================
DIE BAUTEILE DER OFFENEN SEITE (07.10.2026)
ALLES GEHT DURCH `sicher()`. Diese Seite ist die einzige im
Haus ohne Anmeldung, und ihr Inhalt kommt aus einer FREMDEN
Quelle (einer TikTok-Seite). Ein `<script>` im Titel einer
Kampagne waere sonst ein Skript auf unserer Domain.
LEERE BLOECKE ERSCHEINEN NICHT. Eine Ueberschrift „Aufgaben"
ueber einer leeren Flaeche sieht aus, als sei das Laden
abgebrochen -- auf einer Seite, die jemand gerade in Discord
gepostet hat, ist das das Letzte, was man will.
=================================================================== */
/** Eine Zahl mit Tausenderpunkt, wie man sie liest. */
function coinText(n) {
/* `Number(null)` IST 0 -- und „0 Coins" waere eine falsche
Preisangabe. Derselbe Fehler ist mir am 07.10.2026 an einem Tag
zweimal passiert (Server und Kachel); hier steht er zum
dritten Mal und deshalb mit derselben Sicherung. */
if (n === null || n === undefined || n === "") return null;
const z = Number(n);
if (!Number.isFinite(z)) return null;
return `${z.toLocaleString("de-DE")} ${z === 1 ? "Coin" : "Coins"}`;
}
function blockAufgaben(liste) {
const a = (liste || []).filter(Boolean);
if (!a.length) return "";
return `<section class="k__block">
<h2 class="k__schild2">${a.length === 1 ? "Aufgabe" : "Aufgaben"}</h2>
<ul class="k__liste">${a.map((x) => `<li>${sicher(x)}</li>`).join("")}</ul>
</section>`;
}
function blockGaben(d) {
const g = (d.gaben || []).filter(Boolean);
if (!g.length) return "";
const kopf = d.gabenTitel
? `<p class="k__woher">${sicher(d.gabenTitel)}${
d.gabenUnter ? ` <span class="k__unter">${sicher(d.gabenUnter)}</span>` : ""}</p>`
: "";
return `<section class="k__block">
<h2 class="k__schild2">Diese Geschenke zählen</h2>
${kopf}
<ul class="k__gaben">${g.map((x) => {
const c = coinText(x.coins);
return `<li class="k__gabe"><span class="k__gname">${sicher(x.text)}</span>${
c ? `<span class="k__coins">${sicher(c)}</span>` : ""}${
x.zusatz ? `<span class="k__gzusatz">${sicher(x.zusatz)}</span>` : ""}</li>`;
}).join("")}</ul>
</section>`;
}
/** Die Zeilen eines Abschnitts in ihrer Reihenfolge.
* Aufeinanderfolgende Punkte kommen in EINE Liste; ein Absatz
* dazwischen beendet sie -- dieselbe Regel wie auf der Kachel, und
* aus demselben Grund: Bei den Teilnahmebedingungen steht zwischen
* zwei Aufzaehlungen ein Satz, der sich auf die ZWEITE bezieht. */
function zeilenHtml(zeilen) {
const teile = [];
let liste = null;
for (const z of zeilen || []) {
if (z.art === "punkt" || z.art === "geschenk") {
if (!liste) { liste = []; teile.push({ liste }); }
liste.push(z);
continue;
}
liste = null;
teile.push({ zeile: z });
}
return teile.map((s) => {
if (s.liste) {
return `<ul class="k__liste">${s.liste.map((z) => {
const c = coinText(z.coins);
return `<li>${sicher(z.text)}${c ? ` <span class="k__coins">${sicher(c)}</span>` : ""}</li>`;
}).join("")}</ul>`;
}
const klasse = s.zeile.art === "frage" ? "k__frage" : "k__absatz";
return `<p class="${klasse}">${sicher(s.zeile.text)}</p>`;
}).join("");
}
function blockRegeln(abschnitte) {
const a = (abschnitte || []).filter(Boolean);
if (!a.length) return "";
return `<section class="k__block">
<h2 class="k__schild2">Regeln &amp; Infos</h2>
${a.map((s) => `<div class="k__abschnitt" data-stufe="${s.stufe === 3 ? 3 : 2}">${
s.titel ? `<h3 class="k__titel">${sicher(s.titel)}</h3>` : ""}${
s.unter ? `<p class="k__unter">${sicher(s.unter)}</p>` : ""}${
zeilenHtml(s.zeilen)}</div>`).join("")}
</section>`;
}
function seitePlakat(d) {
const bildMarke = d.bild
? `<meta property="og:image" content="${sicher(d.bild)}" />
<meta name="twitter:card" content="summary_large_image" />`
: '<meta name="twitter:card" content="summary" />';
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${sicher(d.titel)} · Spicy Media</title>
<meta name="robots" content="noindex, nofollow, noarchive" />
<meta name="description" content="${sicher(d.beschreibung)}" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Spicy Media · Kampagnen &amp; Events" />
<meta property="og:title" content="${sicher(d.titel)}" />
<meta property="og:description" content="${sicher(d.beschreibung)}" />
<meta property="og:url" content="${sicher(d.basis)}" />
${bildMarke}
<meta name="theme-color" content="${d.vonTikTok ? "#3fbdf5" : "#cace02"}" />
<style>${STIL}</style>
</head>
<body>
<main class="k">
${d.bild ? `<img class="k__bild" src="${sicher(d.bild)}" alt="" />` : ""}
<div class="k__inhalt">
<span class="k__schild" data-quelle="${d.vonTikTok ? "tiktok" : "agentur"}">${
d.vonTikTok ? "TikTok" : "Agentur"}</span>
<h1>${sicher(d.titel)}</h1>
${d.zeitraum ? `<p class="k__zeit">${sicher(d.zeitraum)}</p>` : ""}
${d.text ? `<p class="k__text">${sicher(d.text)}</p>` : ""}
${blockAufgaben(d.aufgaben)}
${blockGaben(d)}
${blockRegeln(d.abschnitte)}
<p class="k__fuss">Geteilt aus dem Creator Workspace von Spicy Media.</p>
</div>
</main>
</body>
</html>`;
}
function seiteLeer() {
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Nicht gefunden</title>
<meta name="robots" content="noindex, nofollow, noarchive" />
<style>${STIL}</style>
</head>
<body>
<main class="k"><div class="k__inhalt">
<h1>Nicht gefunden</h1>
<p class="k__text">Diesen Link gibt es nicht (mehr).</p>
<p class="k__fuss">Spicy Media</p>
</div></main>
</body>
</html>`;
}