/* ===================================================================== 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, } 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. */ const heute = new Date(); const vor = new Date(heute.getTime() - 365 * 86400000).toISOString().slice(0, 10); const nach = new Date(heute.getTime() + 365 * 86400000).toISOString().slice(0, 10); 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" }); } });