Files
dogfather-universe/server/workspace-ics.js
T
DogFatherGitandClaude Opus 5 7dc5356c80 Datum: der Tag kommt aus der Ortszeit, nicht aus UTC
GEFUNDEN UM 01:22, von pruef-kreislauf -- und nur, weil nachts
gearbeitet wurde.

Die Pruefung legt einen Wunsch mit dem HEUTIGEN Datum an und macht
daraus einen Termin. Gemessen:

    geschickt:   datum = 2026-10-01   (heuteLokal auf dem Server)
    gespeichert: datum = 2026-09-30

Der Termin lag also in der Vergangenheit. Auf „Was ansteht" sortiert
er sich damit in den Abschnitt „Vorbei" ein -- und der ist mit
Absicht zugeklappt. Beim Wunsch stand „Daraus wurde ein Termin", auf
dem Brett war er nicht zu sehen. Zwei Stunden jede Nacht (im Winter
eine), genau in den Stunden, in denen hier gearbeitet wird.

URSACHE

    const jetzt = () => new Date().toISOString();   // UTC
    ... jetzt().slice(0, 10) ...                     // UTC-Tag

ES WAR NICHT EINE STELLE. Nachgemessen: SECHZEHN in vierzehn Modulen,
davon ZEHN, die den falschen Tag in die Datenbank schreiben --
Termine, Wuensche, Highlights, Talente, Leads, Videos, Vorlagen --
und sechs, die „heute" vergleichen (ueberfaellige Aufgaben, Berichte,
die Frist einer Entwicklungsaufgabe).

NICHT ANGEFASST, WEIL RICHTIG: Rechnungen auf einem Datumstext mit
fester Uhrzeit (`Date.parse(tag + "T12:00:00Z") + n * 86400000`). Die
bleiben in jeder Zeitzone am selben Kalendertag -- kalender, teamlage
und serien machen es so, und das bleibt.

WARUM ES NIEMAND GEMERKT HAT

pruef-struktur sucht dieses Muster seit dem 06.09.2026. Aber:
  - sie sah NUR in die `pruef-*.mjs`, nie in die Anwendung
  - sie kannte die Schreibweise ueber eine FUNKTION nicht
    (`const jetzt = () => ...` statt `const jetzt = ...`)

Die Wache stand vor den Pruefungen, nicht vor dem Haus -- derselbe
Fehler wie heute Nacht bei den Messports: eine Sicherung, die nur die
halbe Menge kennt, faellt in der anderen Haelfte aus, und zwar
lautlos, denn sie meldet ja „nichts gefunden".

Jetzt sieht sie in beides und kennt beide Schreibweisen. Beim ersten
scharfen Lauf fand sie sofort 23 weitere Stellen in den Pruefdateien
selbst -- dieselben Zeitbomben, gegen die sie gebaut worden war.

EINE ZWEITE WACHE, WEIL ICH SELBST HINEINGELAUFEN BIN

Mein Umbauwerkzeug hat in elf Modulen `heuteLokal()` eingesetzt und
die Einfuhr weggelassen: Es hat erst ersetzt und DANN gefragt, ob der
Name schon in der Datei steht -- da stand er, mein eigener Aufruf.
`node --check` sagt dazu nichts, „Laedt jedes Server-Modul?" auch
nicht: Die Datei ist syntaktisch tadellos. Erst der Aufruf faellt um
mit `ReferenceError: heuteLokal is not defined`. Gefunden hat es
pruef-video, zufaellig. Die anderen zehn waeren durchgerutscht.

Deshalb neu: „Ruft ein Modul etwas, das es nie eingefuehrt hat?" --
die Namen des Hauses aus den export-Zeilen gelesen, nicht
aufgezaehlt. 460 Aufrufe in 350 Dateien, alle mit Einfuhr.

pruef-kreislauf STELLT JETZT DIE RICHTIGE FRAGE

Sie war rot und hat den Fehler dabei nur gestreift: „`.kette` wird
nicht sichtbar", Zeitsperre nach 15 s. Das klingt nach der Anzeige
und schickt einen zur falschen Stelle. Neu:
  - eine Zeile fragt das DATUM (ohne Browser, nennt den Fehler beim
    Namen)
  - der Browserteil klappt zu, was zu ist, und misst dann die Kette;
    „gar nicht da" wird von „da und unsichtbar" unterschieden

NEBENBEFUND IN pruef-ics

Die Probe „fast richtig" war `echt.slice(0, -1) + "A"`. Der
Schluessel ist base64url; sein letztes Zeichen ist eines von
sechzehn. Endet er auf „A", IST die Probe der echte Schluessel, der
Server antwortet zu Recht mit 200, und die Pruefung meldet ein Loch,
das es nicht gibt -- einmal je sechzehn Laeufe. Heute Nacht zweimal
hintereinander, und die Suche ging eine halbe Stunde in eine
Aenderung, die damit nichts zu tun hatte.

GEPRUEFT

  pruef-struktur   44 -> 59 Pruefungen, 0 Fehler
  pruef-ics        37 -> 38, 0 Fehler
  pruef-kreislauf  Absturz bei Nr. 17 -> 25 Pruefungen, 0 Fehler

  und gruen geblieben: treff 85, arten 28, video 74, uebernahme 39,
  entwicklung 79, content 45, vorlagen 24, zuteilung 75,
  scout-zuteilung 37, unterstuetzung 70, aufbewahrung 45,
  bewerbung 91, treff-start 42, uebergang 65, nachwuchs 262,
  auskunft 46, modi-ideen 30, neue-seiten 109, spicy 85,
  wege-nach-draussen 67, aufgabenbrett 49, agentur 62,
  bereiche-lesend 37 -- beide Haeuser

GEGENPROBEN, DIE WIRKLICH ROT WERDEN

  - den UTC-Tag im `daraus`-Weg wieder eingebaut: pruef-kreislauf
    meldet „er liegt HEUTE, nicht gestern (2026-09-30, heute ist
    2026-10-01)", 25 Pruefungen, 1 Fehler -- und der Browserteil
    bleibt gruen, weil er jetzt aufklappt. Jede Frage bei ihrer
    eigenen Pruefung.
  - eine Einfuhr aus workspace-video.js entfernt: die neue Wache
    meldet „workspace-video.js: heuteLokal() (aus helfer-tag.mjs)"
  - beide Erkennungen je gegen einen gebauten Rueckschritt geprueft
    (Funktion, Variable, zwei Schritte, Date.now-Rechnung) und gegen
    das, was NICHT anschlagen darf (UTC-Mittag, voller Zeitstempel,
    fremdes Date, Name im Kommentar, Eigenschaft am Objekt)

EIN FEHLER BEIM UMBAU, HIER FESTGEHALTEN: Mein erster Lauf ueber die
Pruefdateien hat stumpf ersetzt und dabei KOMMENTARE umgeschrieben --
in sieben Dateien stand die alte Schreibweise als Beleg in der
Begruendung, und daraus wurde das Gegenteil. Bemerkt hat es der
Vergleich der Zahlen (34 Stellen statt der gemessenen 24), nicht die
Absicht. Zurueckgenommen und mit Schutz fuer Kommentare und
Zeichenketten wiederholt.

Datenbank vorher gesichert. Keine Schemaaenderung.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-01 01:45:16 +02:00

446 lines
18 KiB
JavaScript

/* =====================================================================
workspace-ics.js — der eigene Kalender zum Abonnieren.
Stufe 3.2 des Plans vom 31.08.2026.
---------------------------------------------------------------------
WARUM DAS MEHR WERT IST ALS NOCH EINE ERINNERUNG
Der Workspace hat seit dem 05.09. Web Push. Trotzdem bleibt ein
Problem, das keine Benachrichtigung loest: Die Termine stehen in
EINEM Kalender, und alle anderen Kalender im Leben eines Menschen
wissen nichts davon. Wer am Dienstag um drei einen Zahnarzttermin
eintraegt, sieht nicht, dass da schon ein Call steht.
Ein abonnierter Kalender dreht das um: Die Termine kommen dorthin, wo
ohnehin nachgesehen wird. Einmal eingerichtet, nie wieder anfassen.
---------------------------------------------------------------------
DIE DREI ENTSCHEIDUNGEN
1. EIN GEHEIMER LINK, KEIN LOGIN.
Google, Apple und Outlook holen den Kalender im Hintergrund ab --
ohne Browser, ohne Sitzung, ohne Anmeldemaske. Ein Kalender, der
eine Anmeldung verlangt, laesst sich schlicht nicht abonnieren.
Der Schluessel IST deshalb der Zugang, und er ist entsprechend
lang (32 Byte Zufall).
2. DER LINK MUSS WIDERRUFBAR SEIN.
Ein Geheimnis, das man nicht zuruecknehmen kann, ist keins. Wer
seinen Link versehentlich weitergibt, drueckt auf "Neuen Link
erzeugen" -- der alte ist im selben Moment tot. Deshalb steht der
Schluessel in einer eigenen Tabelle mit genau einer Zeile je
Person und wird beim Erneuern ueberschrieben.
3. NUR LESEN, NUR EIGENES.
Der Link kann nichts aendern und nichts anderes zeigen als die
Termine, die diese Person auch im Workspace sieht -- dieselbe
Regel (termineSichtbar), nicht eine zweite daneben. Zwei
Sichtbarkeitsregeln laufen mit der Zeit auseinander, und dann
zeigt ausgerechnet der Weg ohne Anmeldung mehr als der mit.
---------------------------------------------------------------------
WAS BEWUSST NICHT DRIN STEHT
Interne Notizen, Bewertungen, Protokolle. Ein abonnierter Kalender
landet auf Geraeten, ueber die wir nichts wissen -- auf dem Handy
eines Creators, in einem Google-Konto, vielleicht auf einem
Familien-Tablet. Dort gehoert hin, WANN etwas ist und MIT WEM, mehr
nicht. Die Beschreibung wird deshalb gekuerzt und enthaelt keinen
internen Vermerk.
===================================================================== */
import express from "express";
import { randomBytes, timingSafeEqual } from "node:crypto";
import {
db, protokolliere, echteIp, sitzungLesen, termineSichtbar, externSql,
tagLokal,
} from "./workspace.js";
export const icsRouter = express.Router();
const jetzt = () => new Date().toISOString();
/* ---------- Der Schluessel --------------------------------------------- */
/** Legt die Tabelle an. Eine Zeile je Person -- mehr braucht es nicht,
* und "genau eine" macht das Widerrufen zu einem Ueberschreiben statt
* zu einem Aufraeumen. */
function tabelle() {
db().exec(`
CREATE TABLE IF NOT EXISTS ics_schluessel (
person_id INTEGER PRIMARY KEY REFERENCES personen(id) ON DELETE CASCADE,
schluessel TEXT NOT NULL UNIQUE,
erstellt TEXT NOT NULL,
zuletzt TEXT
)`);
}
/** Holt den Schluessel dieser Person -- oder legt einen an.
* `neu = true` erzwingt einen frischen; der alte ist danach tot. */
export function schluesselFuer(personId, { neu = false } = {}) {
tabelle();
const d = db();
if (!neu) {
const da = d.prepare("SELECT schluessel FROM ics_schluessel WHERE person_id = ?")
.get(personId);
if (da) return da.schluessel;
}
/* 32 Byte aus randomBytes, als Basis64url. Das ist der ganze Schutz
dieses Weges -- entsprechend lang. Kein Zeitstempel und keine
Personennummer darin: Beides waere ratbar. */
const s = randomBytes(32).toString("base64url");
d.prepare(`
INSERT INTO ics_schluessel (person_id, schluessel, erstellt)
VALUES (?,?,?)
ON CONFLICT(person_id) DO UPDATE SET schluessel = excluded.schluessel,
erstellt = excluded.erstellt,
zuletzt = NULL`)
.run(personId, s, jetzt());
return s;
}
/** Wem gehoert dieser Schluessel?
*
* VERGLICHEN WIRD IN GLEICHBLEIBENDER ZEIT. Ein gewoehnlicher Vergleich
* bricht beim ersten falschen Zeichen ab; wer die Antwortzeit misst,
* kann daraus Zeichen fuer Zeichen den Schluessel erraten. Bei einem
* Geheimnis, das ohne Anmeldung gilt, ist das kein theoretischer
* Einwand.
*
* Die Suche selbst laeuft ueber den Index (UNIQUE) -- deshalb wird
* danach NOCH EINMAL Byte fuer Byte verglichen, damit das Ergebnis
* nicht allein von der Datenbank abhaengt. */
function personZu(schluessel) {
tabelle();
const s = String(schluessel || "");
if (s.length < 40 || s.length > 64 || !/^[A-Za-z0-9_-]+$/.test(s)) return null;
const z = db().prepare(`
SELECT k.person_id, k.schluessel, p.name, p.rolle, p.aktiv
FROM ics_schluessel k JOIN personen p ON p.id = k.person_id
WHERE k.schluessel = ?`).get(s);
if (!z || !z.aktiv) return null;
const a = Buffer.from(z.schluessel, "utf8");
const b = Buffer.from(s, "utf8");
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
return { id: z.person_id, name: z.name, rolle: z.rolle };
}
/* ---------- ICS bauen ---------------------------------------------------- */
/** Text so verpacken, wie RFC 5545 es verlangt.
*
* Backslash, Semikolon, Komma und Zeilenumbruch haben im Format eine
* Bedeutung. Ein Titel wie "Call: Ziele, Technik" wuerde ohne das hier
* am Komma auseinanderfallen -- und zwar so, dass der Kalender
* entweder Unsinn anzeigt oder die ganze Datei verwirft. */
function text(roh) {
return String(roh ?? "")
.replace(/\\/g, "\\\\")
.replace(/;/g, "\\;")
.replace(/,/g, "\\,")
.replace(/\r?\n/g, "\\n");
}
/** Zeilen auf 75 Oktette falten.
*
* Das Format schreibt es vor, und Outlook nimmt es ernst: Eine lange
* Zeile wird dort abgeschnitten, nicht umgebrochen -- der Termin
* verliert dann seinen halben Titel.
*
* Gezaehlt werden BYTES, nicht Zeichen. Ein Umlaut ist in UTF-8 zwei
* Byte; wer Zeichen zaehlt, ueberschreitet die Grenze bei deutschen
* Titeln regelmaessig. Und es wird nie MITTEN in ein Zeichen
* geschnitten -- sonst steht dort ein Fragezeichen. */
function falten(zeile) {
const bytes = Buffer.from(zeile, "utf8");
if (bytes.length <= 75) return zeile;
const stuecke = [];
let ab = 0;
let grenze = 75;
while (ab < bytes.length) {
let bis = Math.min(ab + grenze, bytes.length);
/* Nicht in ein Mehrbyte-Zeichen hineinschneiden: Fortsetzungsbytes
beginnen mit 10xxxxxx. */
while (bis > ab && bis < bytes.length && (bytes[bis] & 0xc0) === 0x80) bis--;
stuecke.push(bytes.subarray(ab, bis).toString("utf8"));
ab = bis;
grenze = 74; // Folgezeilen beginnen mit einem Leerzeichen
}
return stuecke.join("\r\n ");
}
/** "2026-09-08T14:30" (Ortszeit) -> "20260908T143000".
*
* Die Termine stehen in der Datenbank als ORTSZEIT ohne Zone. Genau so
* gehen sie hier hinaus, zusammen mit TZID=Europe/Berlin -- die
* Umrechnung macht der Kalender des Empfaengers. Wer hier selbst nach
* UTC rechnet, muss die Sommerzeit nachbilden und liegt zweimal im
* Jahr eine Stunde daneben. */
function stempel(beginn) {
const t = String(beginn || "").replace(" ", "T");
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(t);
if (!m) return null;
return `${m[1]}${m[2]}${m[3]}T${m[4]}${m[5]}00`;
}
const ART_NAME = { call: "Call", termin: "Termin", review: "Review", frist: "Frist" };
/** Die Zeitzone als eigener Block.
*
* Ohne ihn kennt ein Kalender "Europe/Berlin" zwar meist trotzdem --
* aber "meist" ist bei Terminen zu wenig. Outlook zeigt Termine sonst
* je nach Fassung eine Stunde daneben, und das faellt erst auf, wenn
* jemand zu spaet kommt. */
const ZEITZONE = [
"BEGIN:VTIMEZONE",
"TZID:Europe/Berlin",
"BEGIN:DAYLIGHT",
"TZOFFSETFROM:+0100",
"TZOFFSETTO:+0200",
"TZNAME:CEST",
"DTSTART:19700329T020000",
"RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU",
"END:DAYLIGHT",
"BEGIN:STANDARD",
"TZOFFSETFROM:+0200",
"TZOFFSETTO:+0100",
"TZNAME:CET",
"DTSTART:19701025T030000",
"RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU",
"END:STANDARD",
"END:VTIMEZONE",
];
export function icsBauen(person, termine, gastgeber) {
const zeilen = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Dogfather Universe//Creator Workspace//DE",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
`X-WR-CALNAME:${text("Creator Workspace")}`,
"X-WR-TIMEZONE:Europe/Berlin",
/* Wie oft der Kalender nachsehen soll. Eine Stunde ist der
Kompromiss: Google haelt sich ohnehin selten daran, Apple schon.
Kuerzer waere Hoeflichkeit gegenueber dem eigenen Server. */
"REFRESH-INTERVAL;VALUE=DURATION:PT1H",
"X-PUBLISHED-TTL:PT1H",
...ZEITZONE,
];
const jetztStempel = new Date().toISOString().replace(/[-:]/g, "").slice(0, 15) + "Z";
for (const t of termine) {
const start = stempel(t.beginn);
if (!start) continue; // unlesbares Datum: lieber weglassen
const dauer = Math.max(5, Math.min(Number(t.dauer_min) || 30, 1440));
/* Die Kennung muss ueber Jahre DIESELBE bleiben. Aendert sie sich,
loescht der Kalender den alten Eintrag und legt einen neuen an --
aus einem verschobenen Termin werden dann zwei, und die
Erinnerung des Nutzers haengt an der falschen Haelfte. Deshalb
aus der Datenbanknummer gebildet und aus nichts sonst. */
zeilen.push("BEGIN:VEVENT");
zeilen.push(`UID:termin-${t.id}@workspace.dogfather-universe.com`);
zeilen.push(`DTSTAMP:${jetztStempel}`);
zeilen.push(`DTSTART;TZID=Europe/Berlin:${start}`);
zeilen.push(`DURATION:PT${dauer}M`);
const art = ART_NAME[t.art] || "Termin";
const mit = t.teilnehmer_name || t.creator_name || null;
zeilen.push(falten(`SUMMARY:${text(`${art}: ${t.titel}`)}`));
/* NUR WANN UND MIT WEM. Interne Notizen bleiben draussen -- dieser
Kalender landet auf Geraeten, ueber die wir nichts wissen. */
const teile = [];
if (mit) teile.push(`Mit: ${mit}`);
if (t.beschreibung) teile.push(String(t.beschreibung).slice(0, 500));
teile.push(`Im Workspace: ${gastgeber}/workspace/kalender.html`);
zeilen.push(falten(`DESCRIPTION:${text(teile.join("\n"))}`));
if (t.ort) zeilen.push(falten(`LOCATION:${text(t.ort)}`));
/* Ein Link im eigenen Feld -- Apple und Google machen daraus einen
Knopf "Beitreten". */
if (t.ort && /^https?:\/\//i.test(String(t.ort))) {
zeilen.push(falten(`URL:${text(t.ort)}`));
}
/* Erledigte Termine bleiben drin, aber als abgesagt gekennzeichnet:
Sie einfach verschwinden zu lassen hiesse, dass ein Kalender, der
gestern noch etwas zeigte, heute schweigt -- und niemand weiss,
ob der Termin weg ist oder die Anbindung kaputt. */
zeilen.push(`STATUS:${t.erledigt ? "CANCELLED" : "CONFIRMED"}`);
/* Eine Erinnerung 15 Minuten vorher. Der Kalender kann sie
ueberschreiben; ohne sie gaebe es gar keine. */
if (!t.erledigt) {
zeilen.push("BEGIN:VALARM");
zeilen.push("ACTION:DISPLAY");
zeilen.push("TRIGGER:-PT15M");
zeilen.push(falten(`DESCRIPTION:${text(`${art}: ${t.titel}`)}`));
zeilen.push("END:VALARM");
}
zeilen.push("END:VEVENT");
}
zeilen.push("END:VCALENDAR");
/* CRLF, nicht LF. Das Format schreibt es vor, und Outlook verwirft
eine Datei mit blossen Zeilenvorschueben wortlos. */
return zeilen.join("\r\n") + "\r\n";
}
/* =====================================================================
Die Wege
===================================================================== */
const SPALTEN = `
t.id, t.titel, t.beschreibung, t.art, t.beginn, t.dauer_min, t.ort,
t.erledigt,
${externSql("pc.name", "t.teilnehmer_extern")} AS creator_name,
${externSql("pt.name", "t.teilnehmer_extern")} AS teilnehmer_name`;
const VERBUND = `
FROM termine t
LEFT JOIN personen pc ON pc.id = t.creator_id
LEFT JOIN personen pt ON pt.id = t.teilnehmer_id`;
/** Der abonnierbare Kalender. OHNE Anmeldung, mit Schluessel. */
/* DER PFAD ENDET AUF .ics -- viele Kalenderprogramme bestehen darauf.
Die Endung wird hier aber SELBST abgeschnitten und nicht dem
Wegemuster ueberlassen (`:schluessel.ics`). Wie Express einen Punkt
in einem Muster behandelt, haengt an der Fassung seiner
Wegebibliothek -- und ein Weg, der nach einem Update still nicht mehr
trifft, waere hier besonders unangenehm: Der Kalender hoert einfach
auf, sich zu aktualisieren, und niemand bekommt eine Meldung. */
icsRouter.get("/workspace/kalender/:datei", (req, res) => {
try {
const datei = String(req.params.datei || "");
if (!datei.endsWith(".ics")) {
return res.status(404).type("text/plain").send("Nicht gefunden.");
}
const person = personZu(datei.slice(0, -4));
if (!person) {
/* 404, nicht 401: Was es fuer diesen Schluessel nicht gibt, soll
auch nicht bestaetigen, dass es die Adresse gibt. */
return res.status(404).type("text/plain").send("Nicht gefunden.");
}
/* DIESELBE Sichtbarkeitsregel wie im Workspace. Eine zweite hier
liefe mit der Zeit auseinander -- und dann zeigte ausgerechnet
der Weg ohne Anmeldung mehr als der mit. */
const { wo, werte } = termineSichtbar(person, "t");
/* Ein Jahr rueckwaerts, ein Jahr vorwaerts. Alles auszuliefern
waere bei jedem Abruf die ganze Geschichte; ein Kalender braucht
sie nicht, und die Datei waechst sonst ohne Ende. */
/* ORTSZEIT (01.10.2026). Der Schaden waere hier klein -- ein
Fenster von zwei Jahren verschiebt sich um einen Tag --, aber
eine Ausnahme von der Regel ist teurer als die Aenderung: Wer
spaeter eine Liste der erlaubten Ausnahmen pflegen muss, pflegt
sie nicht. */
const vor = tagLokal(-365);
const nach = tagLokal(365);
const termine = db().prepare(`
SELECT ${SPALTEN} ${VERBUND}
WHERE ${wo} AND t.beginn >= ? AND t.beginn <= ?
ORDER BY t.beginn`).all(...werte, vor, nach + "T23:59:59");
const gastgeber = `${req.protocol}://${req.get("host")}`;
const ics = icsBauen(person, termine, gastgeber);
db().prepare("UPDATE ics_schluessel SET zuletzt = ? WHERE person_id = ?")
.run(jetzt(), person.id);
res.set("Content-Type", "text/calendar; charset=utf-8");
res.set("Content-Disposition", 'inline; filename="creator-workspace.ics"');
/* Nicht zwischenspeichern: Ein Kalender, der eine alte Fassung
zeigt, ist schlimmer als keiner -- man glaubt ihm ja. */
res.set("Cache-Control", "no-store");
/* Und nicht in einer fremden Seite einbetten lassen. */
res.set("X-Content-Type-Options", "nosniff");
res.send(ics);
} catch (fehler) {
console.error("[ics] Ausliefern:", fehler?.message);
res.status(503).type("text/plain").send("Gerade nicht verfuegbar.");
}
});
/* ---- Die Verwaltung des eigenen Links (mit Anmeldung) ------------------- */
function angemeldet(req, res, next) {
const person = sitzungLesen(req);
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
req.person = person;
next();
}
function gleicheHerkunft(req, res, next) {
const herkunft = req.get("origin");
if (!herkunft) return next();
let erlaubt;
try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; }
if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" });
next();
}
/** Der eigene Link. JEDE Rolle hat einen -- es sind die eigenen Termine. */
icsRouter.get("/workspace/api/ics", angemeldet, (req, res) => {
try {
tabelle();
const da = db().prepare(
"SELECT schluessel, erstellt, zuletzt FROM ics_schluessel WHERE person_id = ?")
.get(req.person.id);
const gastgeber = `${req.protocol}://${req.get("host")}`;
res.json({
/* Der Link wird NICHT von selbst angelegt. Ein Geheimnis, das
entsteht, ohne dass jemand es wollte, liegt danach herum --
angelegt wird er beim ersten Klick auf "Link erzeugen". */
link: da ? `${gastgeber}/workspace/kalender/${da.schluessel}.ics` : null,
erstellt: da?.erstellt || null,
zuletzt: da?.zuletzt || null,
});
} catch (fehler) {
console.error("[ics] Lesen:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Anlegen oder erneuern. Erneuern macht den alten Link im selben
* Moment ungueltig -- das ist der Zweck. */
icsRouter.post("/workspace/api/ics", angemeldet, gleicheHerkunft,
express.json({ limit: "2kb" }), (req, res) => {
try {
const vorhanden = !!db().prepare(
"SELECT 1 FROM ics_schluessel WHERE person_id = ?").get(req.person.id);
const s = schluesselFuer(req.person.id, { neu: true });
const gastgeber = `${req.protocol}://${req.get("host")}`;
protokolliere(vorhanden ? "ics_erneuert" : "ics_angelegt", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
});
res.json({ link: `${gastgeber}/workspace/kalender/${s}.ics`, erneuert: vorhanden });
} catch (fehler) {
console.error("[ics] Erzeugen:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Abschalten. Danach gibt es keinen Link mehr -- nicht einen neuen. */
icsRouter.delete("/workspace/api/ics", angemeldet, gleicheHerkunft, (req, res) => {
try {
tabelle();
const e = db().prepare("DELETE FROM ics_schluessel WHERE person_id = ?")
.run(req.person.id);
protokolliere("ics_abgeschaltet", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
});
res.json({ ok: true, entfernt: Number(e.changes) });
} catch (fehler) {
console.error("[ics] Abschalten:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});