Aufgefallen beim Nachweis auf der echten Adresse, nicht beim Bauen: Der Kasten haengt an `bereich === "agentur"` und an der Rolle. DogFather ist aber in BEIDEN Haeusern `admin`. Oeffnet er auf crew.dogfather-universe.com von Hand `bereich.html?b=agentur`, dann stimmt der Bereich, die Rolle stimmt -- und `hausFuerNeuenEintrag` gaebe der Kampagne das Haus "crew". Eine Agentur-Kampagne staende im Rudel, und die Trennung waere an genau der Stelle unterlaufen, an der niemand nachsieht. Bauplan, Teil 11: "Nichts im Haus Team Dogi." ZWEI FRAGEN, ZWEI SCHWELLEN -- und das ist Absicht: /api/ich (ZEIGEN) haus !== "crew" Route (SCHREIBEN) haus === "agentur" Der Unterschied betrifft genau einen Fall: `null`. Den gibt es nur auf einer PRUEFADRESSE -- `sitzungLesen` vergibt dort seit dem 24.09.2026 bewusst kein Haus, und die Begruendung dort nennt den Schaden: Als 127.0.0.1 einmal ein Haus bekam, fiel pruef-chat-kanaele mit 13 Fehlschlaegen um, und die naechsten Pruefungen waeren still gruen geblieben, ohne noch etwas zu messen. Eine echte Anfrage hat immer eine der beiden Waende; eine Sitzung zu einer dritten lehnt `sitzungPasstZurAdresse` ohnehin ab. Beim SCHREIBEN wird trotzdem streng gefragt: Ein Eintrag mit `haus = null` waere in BEIDEN Haeusern sichtbar -- genau das, was Teil 11 ausschliesst. WAS ICH DABEI ZWEIMAL FALSCH VERSUCHT HABE Zuerst sollte das Bildwerkzeug ueber die Agenturwand kommen, damit es den Kasten weiter messen kann. Den Host-Kopf beim Durchreichen zu ueberschreiben wirkt nicht -- gemessen: /api/ich meldete weiter `darf: false`, der Wirt blieb "127.0.0.1:4338". Chromium laesst `Host` nicht ueberschreiben. Dann den Namen wirklich aufloesen (--host-resolver-rules). Das funktioniert, bringt aber die Anmeldemaske der echten Wand mit einer Tuer davor, die ein Bildwerkzeug nicht aufmachen soll. Gemessen werden soll die KACHEL, nicht der Zugang. Deshalb bleibt bild-kampagne auf der Pruefadresse -- und genau deshalb fragt /api/ich `!== "crew"` statt `=== "agentur"`. Beide Zeilen tragen jetzt die Begruendung bei sich, damit sie niemand "vereinheitlicht": Das macht entweder die Pruefungen blind oder laesst eine Kampagne ins Rudel. Zwei Gegenproben gefahren, beide schlagen an: Route nimmt jede Wand an -> 2 rot. /api/ich fragt nicht nach dem Haus -> 1 rot. GEPRUEFT: pruef-kampagne 98 -> 99 gruen · bild-kampagne 20 Messungen gruen · pruef-struktur, pruef-haus-trennung 107, pruef-agentur 62, pruef-eventkarte 94, pruef-alle-wege, pruef-schranke, pruef-zeichen 8 Co-Authored-By: Claude Opus 5 <[email protected]>
540 lines
23 KiB
JavaScript
540 lines
23 KiB
JavaScript
/* =====================================================================
|
|
EINE KAMPAGNEN-KACHEL AUS EINEM TIKTOK-LINK (07.10.2026) — Etappe 3
|
|
|
|
Haus: workspace.dogfather-universe.com (Agentur / Spicy Media).
|
|
Team Dogi bleibt unberührt — `hausFuerNeuenEintrag` entscheidet das,
|
|
nicht diese Datei.
|
|
|
|
Der Auftrag in einem Satz: Im Agentur-Bereich gibt es ein Feld, in
|
|
das die GANZE Einladungsnachricht eingefügt wird — und daraus
|
|
entsteht eine fertige Agentur-Event-Kachel mit Titel, Zeitraum,
|
|
Banner, Aufgaben zum Abhaken, Regeln, Ligen und Preisen. Ohne einen
|
|
einzigen Handgriff danach.
|
|
|
|
Eingabe ist genau das, was TikTok schickt, mitsamt dem Fließtext:
|
|
|
|
Dein Creator Network lädt dich ein, an einer Kampagne
|
|
teilzunehmen. Klicke hier, um weitere Informationen zu
|
|
erhalten: https://www.tiktok.com/t/ZSbqPsFa8/
|
|
|
|
---------------------------------------------------------------------
|
|
HIER WIRD WENIG ERFUNDEN
|
|
|
|
Die Kachel gibt es schon: Bereich `agentur`, Art `kampagne`, Tabelle
|
|
`eintraege` mit `event_ende`, `event_aufgaben`, `event_regeln`,
|
|
Haken über `event_punkte`, Banner über `dateien.eintrag_id`. Diese
|
|
Datei füllt vorhandene Felder — sie baut keine neuen.
|
|
|
|
`workspace-bereiche.js` wird NICHT angefasst. Fiele beim Bauen auf,
|
|
dass doch etwas nötig ist, wäre das ein Zeichen, dass die Abbildung
|
|
nicht passt — dann lieber die Abbildung ändern als die
|
|
Bereichslogik.
|
|
|
|
Gelesen wird in `kampagne-lesen.mjs` (kein Netz, keine Datenbank),
|
|
geholt wird mit `helfer-tiktok.mjs` (kein Schreiben). Hier steht nur
|
|
die Reihenfolge, die Rechtefrage und das Schreiben.
|
|
|
|
---------------------------------------------------------------------
|
|
DIE REIHENFOLGE — und warum sie so ist
|
|
|
|
1. Link aus dem Text holen ohne Netz, ohne Datenbank
|
|
2. Bremse VOR dem ersten Abruf nach draußen
|
|
3. Umleitung Station für Station jede Zwischenadresse geprüft
|
|
4. Kampagnennummer erst aus der Adresse (billig)
|
|
5. Dublettenprüfung spart die ganze Seite
|
|
6. Seite holen 12 s, 3 MB beim Lesen
|
|
7. Lesen drei Ausgänge
|
|
8. Banner herunterladen eigene Frist, Byteprüfung
|
|
9. Schreiben in EINER Transaktion
|
|
10. Protokoll
|
|
|
|
Die Bremse steht an Stelle 2 und nicht an Stelle 9: Eine Bremse, die
|
|
erst nach dem Abruf greift, bremst den fremden Server nicht.
|
|
|
|
Die Dublettenprüfung steht zweimal da — einmal hier (Stelle 5, fängt
|
|
den Normalfall) und einmal als eindeutiger Index in der Datenbank
|
|
(fängt den Wettlauf zweier gleichzeitiger Aufrufe). Beides, nicht
|
|
eins davon.
|
|
|
|
---------------------------------------------------------------------
|
|
JEDER FEHLSCHLAG WIRD PROTOKOLLIERT, nicht nur der Erfolg
|
|
|
|
Sonst lässt sich auf „warum kam meine Kampagne nicht an?" nicht
|
|
einmal sagen, OB der Versuch den Server erreicht hat. Genau diese
|
|
Reihenfolge hat am 06.09.2026 im Shop zwei Stunden gekostet: Die
|
|
Frage „warum kommt die Mail nicht an?" setzt voraus, dass „wurde sie
|
|
überhaupt abgeschickt?" schon beantwortet ist.
|
|
|
|
Aufrufen: POST /workspace/api/kampagne/einlesen { text }
|
|
===================================================================== */
|
|
|
|
import express from "express";
|
|
import { randomBytes } from "node:crypto";
|
|
import { join } from "node:path";
|
|
import { mkdirSync, writeFileSync, unlinkSync } from "node:fs";
|
|
import {
|
|
db, protokolliere, echteIp, sitzungLesen, istLeitung,
|
|
DATEN_ORDNER, hausFuerNeuenEintrag } from "./workspace.js";
|
|
import { bildArtVon } from "./workspace-dateien.js";
|
|
import {
|
|
linkAusText, folgeUmleitung, holeSeite, holeMitFrist, bildAdresseErlaubt,
|
|
hostVon, FRIST_SEITE_MS } from "./helfer-tiktok.mjs";
|
|
import { leseKampagne, regelnText, aufgabenText, KampagneUnlesbar }
|
|
from "./kampagne-lesen.mjs";
|
|
|
|
export const kampagneRouter = express.Router();
|
|
|
|
const ORDNER = join(DATEN_ORDNER, "dateien");
|
|
try { mkdirSync(ORDNER, { recursive: true }); } catch { /* gibt es schon */ }
|
|
|
|
/* WIE OFT IN EINER STUNDE? Wir lesen eine Seite, zu der wir eingeladen
|
|
wurden, einmal je Link, ausgelöst durch einen Menschen. Zwanzig ist
|
|
reichlich für einen Menschen und wenig für ein Skript. Die Zahl
|
|
steht hier und nicht in der Route, damit sie an genau einer Stelle
|
|
steht -- die Prüfung liest sie mit. Vorbild: BEITRAEGE_JE_STUNDE
|
|
in workspace-bereiche.js. */
|
|
export const KAMPAGNEN_JE_STUNDE = 20;
|
|
|
|
/* Das Banner. Dieselbe Grenze wie beim Video-Cover: Was von draußen
|
|
kommt, ist von draußen. */
|
|
const BANNER_MAX = 4 * 1024 * 1024;
|
|
|
|
/* Ab wann drängt es? Der Bauplan: „hoch", wenn das Ende weniger als
|
|
sieben Tage entfernt ist. Gerechnet, nicht geraten -- und hier, nicht
|
|
im Leser: Eine Lesefunktion, die die Wanduhr befragt, liefert zu
|
|
verschiedenen Zeiten verschiedene Ergebnisse auf dieselbe Eingabe. */
|
|
const DRAENGT_AB_TAGEN = 7;
|
|
|
|
/* SO VIEL FASST EIN TEXTFELD DER KACHEL. Nicht hier erfunden:
|
|
TEXT_MAX in workspace-bereiche.js, und die PUT-Route dort lehnt
|
|
mehr mit "Die Regeln sind zu lang." ab.
|
|
|
|
WARUM DAS UEBERHAUPT EINE FRAGE IST: Der Regeltext von
|
|
"Gipfelstuermer" ist gemessen 7474 Zeichen lang -- Geschenke, sechs
|
|
Preisgruppen, zwei Klapptexte mit Ligen und Teilnahmebedingungen.
|
|
Wer das ungekuerzt hineinschreibt, baut eine Kachel, die DogFather
|
|
OEFFNEN, aber nicht SPEICHERN kann: Er aendert ein Wort, drueckt
|
|
Speichern und bekommt eine Fehlermeldung ueber etwas, das er nie
|
|
getippt hat. Ein Formular, das der Server ablehnt, laesst den
|
|
Menschen davor sich selbst fuer den Fehler halten. */
|
|
const FELD_MAX = 6000;
|
|
|
|
/** Kuerzt an einer ABSCHNITTSGRENZE und sagt, dass gekuerzt wurde.
|
|
*
|
|
* Mitten im Wort abzuschneiden waere das Schlimmere von zweien: Es
|
|
* sieht nicht nach einer Grenze aus, sondern nach einem Fehler beim
|
|
* Einlesen -- und danach sucht jemand im Code statt im Textfeld.
|
|
*
|
|
* Verloren geht nichts: Das Gelesene steht gegliedert in
|
|
* kampagne_daten. */
|
|
function kuerzen(text, max = FELD_MAX) {
|
|
const s = String(text || "");
|
|
if (s.length <= max) return s;
|
|
const fussnote = "\n\n* Der Regeltext war länger als das Feld fasst und endet "
|
|
+ "hier. Vollständig steht er in den Rohdaten der Kampagne.";
|
|
const platz = max - fussnote.length;
|
|
const schnitt = s.lastIndexOf("\n\n", platz);
|
|
/* Gibt es keine Abschnittsgrenze, dann die letzte Zeilengrenze --
|
|
und erst wenn auch die fehlt, hart. */
|
|
const bis = schnitt > platz * 0.4 ? schnitt
|
|
: (s.lastIndexOf("\n", platz) > platz * 0.4 ? s.lastIndexOf("\n", platz) : platz);
|
|
return s.slice(0, bis).trimEnd() + fussnote;
|
|
}
|
|
|
|
const jetztIso = () => 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();
|
|
}
|
|
kampagneRouter.use("/workspace/api/kampagne", angemeldet);
|
|
|
|
/** Kurz und für Menschen. Steht im Protokoll, nicht auf dem Bildschirm. */
|
|
const kurz = (s, n = 180) => String(s ?? "").slice(0, n);
|
|
|
|
/** Die Kampagnennummer aus einer Adresse -- oder null.
|
|
* `new URL()`, kein regulärer Ausdruck: Ein Muster, das `activityId=`
|
|
* irgendwo im Text sucht, findet es auch in einem Pfadstück. */
|
|
export function nummerAusAdresse(roh) {
|
|
try {
|
|
const n = new URL(String(roh)).searchParams.get("activityId");
|
|
return n && /^\d{6,25}$/.test(n) ? n : null;
|
|
} catch { return null; }
|
|
}
|
|
|
|
/** Wie viele Einlesevorgänge hatte diese Person in der letzten Stunde?
|
|
*
|
|
* GEZÄHLT WIRD IM PROTOKOLL, nicht in `eintraege`. Ein Versuch, der
|
|
* scheitert, erzeugt keinen Eintrag -- wer Einträge zählt, bremst
|
|
* genau den nicht, der in einer Schleife hängt und bei jedem Versuch
|
|
* eine fremde Seite abruft. Gezählt werden deshalb VERSUCHE, und das
|
|
* Protokoll ist die Stelle, an der beide Ausgänge stehen.
|
|
*
|
|
* UND "kampagne_schon_da" GEHOERT DAZU (nachgetragen 07.10.2026,
|
|
* gefunden von der Pruefung). Zuerst standen hier nur "eingelesen"
|
|
* und "gescheitert". Damit war die Bremse genau fuer den haeufigsten
|
|
* Missbrauchsfall blind: Wer denselben Link wieder und wieder
|
|
* einfuegt, bekommt jedes Mal "schon da" -- und loeste jedes Mal
|
|
* einen Abruf nach draussen aus, ohne gezaehlt zu werden. Gemessen:
|
|
* 21 Versuche, 0 gebremst.
|
|
*
|
|
* "kampagne_bremse" steht BEWUSST nicht in der Liste: Sonst zaehlte
|
|
* die Bremse ihre eigenen Meldungen mit und verlaengerte sich
|
|
* selbst -- man kaeme nie wieder heraus. */
|
|
function versucheLetzteStunde(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 ('kampagne_eingelesen','kampagne_gescheitert',
|
|
'kampagne_schon_da')`)
|
|
.get(personId, seit);
|
|
return { anzahl: z?.anzahl || 0, aeltester: z?.aeltester || null };
|
|
}
|
|
|
|
/** Das Banner holen und ablegen. Gibt `{ name, groesse, art }` zurück
|
|
* -- oder `null`, wenn es kein Bild ist.
|
|
*
|
|
* WARUM HERUNTERLADEN UND NICHT VERLINKEN: Beim Video ist es bewiesen
|
|
* (`x-expires`, gemessen 48 Stunden). Bei den Kampagnenbildern ist
|
|
* nicht einmal nachweisbar, OB sie eine Frist tragen -- die Adressen
|
|
* sehen anders aus. Das ist kein Grund, es zu riskieren: Ein fremdes
|
|
* CDN kann jederzeit umziehen, und wer die Adresse gespeichert hat,
|
|
* merkt es nicht beim Bauen und nicht beim Testen, sondern Wochen
|
|
* später an leeren Kacheln.
|
|
*
|
|
* Und es ist nicht nur Haltbarkeit: Als Datei mit `eintrag_id` greift
|
|
* automatisch `darfEintragSehen()`, und das Bild ist für Creator
|
|
* sichtbar. Am 03.10.2026 war genau das schon einmal ein 404. */
|
|
async function bannerAblegen(adresse) {
|
|
if (!adresse) return null;
|
|
/* Die Adresse kommt aus einer FREMDEN Seite. Siehe bildAdresseErlaubt. */
|
|
if (!bildAdresseErlaubt(adresse)) return null;
|
|
let a;
|
|
try { a = await holeMitFrist(adresse, {}, { frist: FRIST_SEITE_MS }); }
|
|
catch { return null; }
|
|
if (!a.ok) return null;
|
|
const roh = a.roh;
|
|
if (!roh.length || roh.length > BANNER_MAX) return null;
|
|
/* DIESELBE BYTEPRÜFUNG WIE BEI HOCHGELADENEN DATEIEN. `dateien.typ`
|
|
wäre hier sogar noch wertloser als sonst: Den Typ behauptet dann
|
|
ein fremder Server. */
|
|
const art = bildArtVon(roh.subarray(0, 16));
|
|
if (!art) return null;
|
|
const endung = { "image/png": ".png", "image/jpeg": ".jpg",
|
|
"image/gif": ".gif", "image/webp": ".webp" }[art] || ".bin";
|
|
const name = `${Date.now().toString(36)}-${randomBytes(8).toString("hex")}${endung}`;
|
|
writeFileSync(join(ORDNER, name), roh, { flag: "wx" });
|
|
return { name, groesse: roh.length, art };
|
|
}
|
|
|
|
/* =====================================================================
|
|
DIE ROUTE
|
|
===================================================================== */
|
|
|
|
kampagneRouter.post("/workspace/api/kampagne/einlesen", async (req, res) => {
|
|
const person = req.person;
|
|
const ip = echteIp(req);
|
|
let abgelegt = null;
|
|
|
|
/** Ein Fehlschlag, immer gleich: protokollieren UND antworten.
|
|
* Zwei getrennte Stellen dafür wären zwei Stellen, an denen man das
|
|
* Protokollieren vergessen kann. */
|
|
const schiefgegangen = (status, nachricht, grund) => {
|
|
protokolliere("kampagne_gescheitert", {
|
|
personId: person.id, rolle: person.rolle, ip,
|
|
detail: kurz(grund),
|
|
});
|
|
return res.status(status).json({ fehler: nachricht });
|
|
};
|
|
|
|
try {
|
|
/* ---- Recht --------------------------------------------------
|
|
Eine Kampagne ist eine Ansage an das ganze Team. Wer sie anlegen
|
|
darf, ist dieselbe Frage wie „wer darf ein Agentur-Event
|
|
anlegen" -- also wird die VORHANDENE Regel gefragt, nicht eine
|
|
neue erfunden. Eine zweite Rechteregel neben einer ersten ist
|
|
die Sorte Dopplung, die beim nächsten Umbau auseinanderläuft. */
|
|
if (!istLeitung(person)) {
|
|
return schiefgegangen(403,
|
|
"Kampagnen anlegen dürfen DogFather und die Manager.",
|
|
`keine Leitung (${person.rolle})`);
|
|
}
|
|
|
|
/* ---- Und nur auf der AGENTURADRESSE ---------------------
|
|
|
|
Bauplan, Teil 11: "Nichts im Haus Team Dogi."
|
|
|
|
WARUM DAS EINE EIGENE ZEILE BRAUCHT: DogFather ist in BEIDEN
|
|
Haeusern `admin`, und `istLeitung` fragt nur nach der Rolle.
|
|
Meldet er sich auf crew.dogfather-universe.com an und oeffnet
|
|
dort `bereich.html?b=agentur`, dann ist er Leitung, der
|
|
Bereich stimmt -- und `hausFuerNeuenEintrag` gaebe der
|
|
Kampagne das Haus "crew". Eine Agentur-Kampagne staende
|
|
damit im Rudel, und die Trennung waere genau an der Stelle
|
|
unterlaufen, an der sie niemand nachsieht.
|
|
|
|
Gefragt wird POSITIV ("ist es die Agentur?") und nicht
|
|
negativ ("ist es nicht die Crew?"): Ohne Adresse gibt
|
|
`hausFuerNeuenEintrag` `null` zurueck, und ein Eintrag ohne
|
|
Haus ist in BEIDEN sichtbar. Fuer die meisten Eintraege ist
|
|
das die bewusst sichere Richtung -- fuer eine Kampagne waere
|
|
es das Gegenteil.
|
|
|
|
Dasselbe Haus wird unten beim Schreiben benutzt, nicht ein
|
|
zweites Mal berechnet. */
|
|
const haus = hausFuerNeuenEintrag(person, "agentur");
|
|
if (haus !== "agentur") {
|
|
return schiefgegangen(403,
|
|
"Kampagnen gehören zur Agentur. Bitte auf "
|
|
+ "workspace.dogfather-universe.com anmelden.",
|
|
`falsches Haus: ${haus ?? "ohne Adresse"}`);
|
|
}
|
|
|
|
const text = String(req.body?.text ?? "");
|
|
if (!text.trim()) {
|
|
return schiefgegangen(400,
|
|
"Da war kein Text. Füge die ganze Einladung ein.",
|
|
"leerer Text");
|
|
}
|
|
|
|
/* ---- 1. Den Link herauslösen -------------------------------- */
|
|
const link = linkAusText(text);
|
|
if (!link) {
|
|
return schiefgegangen(400,
|
|
"In diesem Text steht keine TikTok-Adresse. "
|
|
+ "Füge die Einladung noch einmal ein — der ganze Text reicht.",
|
|
`kein Link in ${text.length} Zeichen`);
|
|
}
|
|
|
|
/* ---- 2. Bremse, VOR dem ersten Abruf nach draußen ------------ */
|
|
{
|
|
const { anzahl, aeltester } = versucheLetzteStunde(person.id);
|
|
if (anzahl >= KAMPAGNEN_JE_STUNDE) {
|
|
const frei = new Date(Date.parse(aeltester) + 3600_000);
|
|
const min = Math.max(1, Math.ceil((frei.getTime() - Date.now()) / 60000));
|
|
/* Hier NICHT über `schiefgegangen`: Sonst zählte die Bremse
|
|
ihre eigene Meldung mit und verlängerte sich selbst. */
|
|
protokolliere("kampagne_bremse", {
|
|
personId: person.id, rolle: person.rolle, ip,
|
|
detail: `${anzahl} Versuche in einer Stunde`,
|
|
});
|
|
return res.status(429).json({
|
|
fehler: `Du hast in der letzten Stunde ${anzahl} Kampagnen eingelesen. `
|
|
+ `In ${min} ${min === 1 ? "Minute" : "Minuten"} geht es weiter.`,
|
|
});
|
|
}
|
|
}
|
|
|
|
/* ---- 3. Umleitung Station für Station ------------------------ */
|
|
const weg = await folgeUmleitung(link);
|
|
if (!weg.ok) {
|
|
if (weg.grund === "fremd") {
|
|
return schiefgegangen(400,
|
|
`Dieser Link führt zu ${hostVon(weg.url)}, nicht zu TikTok.`,
|
|
`Umleitung verlässt TikTok: ${weg.url}`);
|
|
}
|
|
if (weg.grund === "zu_viele") {
|
|
return schiefgegangen(400,
|
|
"Dieser Link führt im Kreis. Bitte die Einladung noch einmal öffnen.",
|
|
`mehr als 5 Umleitungen ab ${link}`);
|
|
}
|
|
if (weg.grund === "frist") {
|
|
return schiefgegangen(502,
|
|
"TikTok hat nicht geantwortet (12 s). Bitte später noch einmal.",
|
|
`Frist bei der Umleitung ab ${link}`);
|
|
}
|
|
return schiefgegangen(502,
|
|
"TikTok war nicht erreichbar. Bitte später noch einmal.",
|
|
`Umleitung ${weg.grund}: ${weg.fehler || weg.url}`);
|
|
}
|
|
const adresse = weg.url;
|
|
|
|
/* ---- 4./5. Kampagnennummer und Dublettenprüfung --------------
|
|
Erst aus der ADRESSE, weil das nichts kostet: Steht sie da und
|
|
kennen wir die Kampagne schon, sparen wir die ganze Seite.
|
|
Fehlt sie dort, wird sie unten aus der Seite gelesen -- und dann
|
|
noch einmal geprüft. */
|
|
const vorab = nummerAusAdresse(adresse);
|
|
if (vorab) {
|
|
const da = db().prepare(
|
|
"SELECT id FROM eintraege WHERE kampagne_id = ?").get(vorab);
|
|
if (da) {
|
|
protokolliere("kampagne_schon_da", {
|
|
personId: person.id, rolle: person.rolle, ip,
|
|
detail: kurz(`${vorab} -> Eintrag ${da.id}`),
|
|
});
|
|
return res.status(200).json({ id: da.id, schon: true });
|
|
}
|
|
}
|
|
|
|
/* ---- 6. Die Seite holen -------------------------------------- */
|
|
const seite = await holeSeite(adresse);
|
|
if (!seite.ok) {
|
|
if (seite.grund === "frist") {
|
|
return schiefgegangen(502,
|
|
"TikTok hat nicht geantwortet (12 s). Bitte später noch einmal.",
|
|
`Frist beim Holen von ${adresse}`);
|
|
}
|
|
if (seite.grund === "zu_gross") {
|
|
return schiefgegangen(502,
|
|
"Die Seite von TikTok war unerwartet groß und wurde abgebrochen.",
|
|
`über 3 MB (${seite.bytes}, angesagt: ${seite.angesagt})`);
|
|
}
|
|
if (seite.grund === "status") {
|
|
return schiefgegangen(502,
|
|
`TikTok hat die Seite nicht herausgegeben (${seite.status}).`,
|
|
`HTTP ${seite.status} bei ${adresse}`);
|
|
}
|
|
return schiefgegangen(502,
|
|
"TikTok war nicht erreichbar. Bitte später noch einmal.",
|
|
`Abruf ${seite.grund}: ${seite.fehler || adresse}`);
|
|
}
|
|
|
|
/* ---- 7. Lesen ------------------------------------------------ */
|
|
let gelesen;
|
|
try {
|
|
gelesen = leseKampagne(seite.text);
|
|
} catch (fehler) {
|
|
if (fehler instanceof KampagneUnlesbar) {
|
|
/* Die beiden Sorten werden verschieden beantwortet -- aber
|
|
beide mit 502 und beide mit dem GRUND im Klartext. Eine
|
|
Kachel ohne Zeitraum wäre eine Falschauskunft; sie behauptet
|
|
einen Termin, den sie nicht kennt. */
|
|
const satz = fehler.art === "pflicht"
|
|
? `Die Seite wurde gelesen, aber ${fehler.grund}. Bitte von Hand anlegen.`
|
|
: `Die Seite konnte nicht gelesen werden: ${fehler.grund}. `
|
|
+ "Vermutlich hat TikTok die Seite umgebaut.";
|
|
return schiefgegangen(502, satz, `${fehler.art}: ${fehler.grund}`);
|
|
}
|
|
throw fehler;
|
|
}
|
|
|
|
/* Die Nummer aus der Seite ist die verbindliche. Falls die Adresse
|
|
keine trug, wird jetzt zum ersten Mal auf Dubletten geprüft. */
|
|
const nummer = gelesen.pflicht.id;
|
|
if (!vorab) {
|
|
const da = db().prepare(
|
|
"SELECT id FROM eintraege WHERE kampagne_id = ?").get(nummer);
|
|
if (da) {
|
|
protokolliere("kampagne_schon_da", {
|
|
personId: person.id, rolle: person.rolle, ip,
|
|
detail: kurz(`${nummer} -> Eintrag ${da.id} (aus der Seite)`),
|
|
});
|
|
return res.status(200).json({ id: da.id, schon: true });
|
|
}
|
|
}
|
|
|
|
/* ---- 8. Banner ------------------------------------------------ */
|
|
abgelegt = await bannerAblegen(gelesen.kuer.bannerUrl);
|
|
const hinweise = [...gelesen.hinweise];
|
|
if (gelesen.kuer.bannerUrl && !abgelegt) {
|
|
hinweise.push("Das Banner konnte nicht geholt werden.");
|
|
}
|
|
|
|
/* ---- 9. Schreiben, in EINER Transaktion ----------------------
|
|
Eintrag und Banner gehören zusammen. Bricht das zweite INSERT
|
|
ab, darf das erste nicht stehen bleiben: Eine Kachel mit einem
|
|
Bild, das es nicht gibt, sieht aus wie ein Fehler im Anzeigen --
|
|
und man sucht ihn dort. */
|
|
const jetzt = jetztIso();
|
|
const tageBisEnde = Math.round(
|
|
(Date.parse(`${gelesen.pflicht.ende}T12:00:00Z`) - Date.parse(jetzt)) / 86400_000);
|
|
const dringlichkeit = tageBisEnde < DRAENGT_AB_TAGEN ? "hoch" : "mittel";
|
|
|
|
const d = db();
|
|
let eintragId = null;
|
|
let meine = false;
|
|
try {
|
|
try { d.exec("BEGIN IMMEDIATE"); meine = true; } catch { meine = false; }
|
|
const { lastInsertRowid } = d.prepare(`
|
|
INSERT INTO eintraege
|
|
(bereich, art, titel, text, datum, event_ende,
|
|
event_aufgaben, event_regeln, dringlichkeit, status, nur_leitung,
|
|
erstellt, erstellt_von, haus,
|
|
kampagne_id, kampagne_quelle, kampagne_geholt_am, kampagne_zone,
|
|
kampagne_daten)
|
|
VALUES ('agentur','kampagne', ?,?,?,?, ?,?,?, 'offen', 0, ?,?,?, ?,?,?,?,?)`)
|
|
.run(
|
|
gelesen.pflicht.titel.slice(0, 160),
|
|
kuerzen(gelesen.kuer.einleitung || ""),
|
|
/* Der TAG der Kampagne in IHRER Zone -- nicht heuteLokal().
|
|
Eine Kampagne, die morgen beginnt, steht sonst heute in der
|
|
Liste. */
|
|
gelesen.pflicht.start,
|
|
gelesen.pflicht.ende,
|
|
kuerzen(aufgabenText(gelesen.kuer)),
|
|
kuerzen(regelnText(gelesen.kuer, hinweise)),
|
|
dringlichkeit,
|
|
jetzt, person.id,
|
|
/* NICHT von Hand auf "agentur" gesetzt, sondern dieselbe
|
|
Angabe, die oben schon ueber die Annahme entschieden
|
|
hat. Zweimal zu rechnen waeren zwei Fassungen derselben
|
|
Regel -- und die laufen auseinander. */
|
|
haus,
|
|
nummer, link, jetzt, gelesen.pflicht.zone,
|
|
JSON.stringify(gelesen.auszug));
|
|
eintragId = Number(lastInsertRowid);
|
|
|
|
if (abgelegt) {
|
|
d.prepare(`
|
|
INSERT INTO dateien (name_original, name_datei, groesse, typ, status, notiz,
|
|
hochgeladen_von, erstellt, eintrag_id, haus)
|
|
VALUES (?,?,?,?, 'entwurf', NULL, ?,?,?,?)`)
|
|
.run(`banner-${nummer}${abgelegt.name.slice(abgelegt.name.lastIndexOf("."))}`,
|
|
abgelegt.name, abgelegt.groesse, abgelegt.art,
|
|
person.id, jetzt, eintragId, person.haus || null);
|
|
abgelegt = null; /* ab hier gehört die Datei dem Eintrag */
|
|
}
|
|
if (meine) d.exec("COMMIT");
|
|
} catch (fehler) {
|
|
if (meine) { try { d.exec("ROLLBACK"); } catch { /* schon zurück */ } }
|
|
/* Der eindeutige Index hat zugeschlagen: zwei gleichzeitige
|
|
Aufrufe für dieselbe Kampagne. Das ist kein Fehler, das ist
|
|
genau sein Zweck -- und die Antwort ist dieselbe wie bei der
|
|
Prüfung oben. */
|
|
if (/UNIQUE|constraint/i.test(String(fehler?.message))) {
|
|
const da = db().prepare(
|
|
"SELECT id FROM eintraege WHERE kampagne_id = ?").get(nummer);
|
|
if (da) {
|
|
protokolliere("kampagne_schon_da", {
|
|
personId: person.id, rolle: person.rolle, ip,
|
|
detail: kurz(`${nummer} -> Eintrag ${da.id} (Wettlauf, Index)`),
|
|
});
|
|
return res.status(200).json({ id: da.id, schon: true });
|
|
}
|
|
}
|
|
throw fehler;
|
|
}
|
|
|
|
/* ---- 10. Protokoll ------------------------------------------- */
|
|
protokolliere("kampagne_eingelesen", {
|
|
personId: person.id, rolle: person.rolle, ip,
|
|
detail: kurz(`${nummer} "${gelesen.pflicht.titel}" `
|
|
+ `${gelesen.pflicht.start}..${gelesen.pflicht.ende} -> Eintrag ${eintragId}`
|
|
+ (hinweise.length ? ` (${hinweise.length} Hinweise)` : "")),
|
|
});
|
|
|
|
return res.status(201).json({
|
|
id: eintragId,
|
|
titel: gelesen.pflicht.titel,
|
|
von: gelesen.pflicht.start,
|
|
bis: gelesen.pflicht.ende,
|
|
hinweise,
|
|
});
|
|
} catch (fehler) {
|
|
console.error("[kampagne] einlesen:", fehler?.message);
|
|
/* Ein abgelegtes Banner ohne Eintrag wäre eine Datei, die niemand
|
|
je wieder findet -- und die Ablage wächst still. */
|
|
if (abgelegt) {
|
|
try { unlinkSync(join(ORDNER, abgelegt.name)); } catch { /* weg ist weg */ }
|
|
}
|
|
return schiefgegangen(500,
|
|
"Beim Anlegen ist etwas schiefgegangen. Bitte noch einmal versuchen.",
|
|
`unerwartet: ${fehler?.message}`);
|
|
}
|
|
});
|