Filipe: "ich will dass du die komplette seite auf dem handy abcheckst.
ich will dass alles perfekt aussieht und bedienbar ist."
DER SCHWERSTE FUND: Auf ZWOELF Seiten war der "Meine Sicht"-Umschalter
nicht bedienbar -- wer darauf tippte, landete im Chat.
Bei 412 px (der haeufigsten Android-Breite ueberhaupt) brach die
Kopfleiste nicht um, der Umschalter wurde auf 50 px zusammengedrueckt,
sein Knopf behielt aber seine 104 px Mindestbreite und lag damit quer
ueber dem Chat-Knopf. Sichtbar war davon nichts.
Und die Ursache steht seit dem 05.09. woertlich im Kommentar daneben:
"Die Rechnung ging genau auf, solange rechts VIER Dinge standen. Mit der
Glocke sind es FUENF, und bei 320 px passte es nicht mehr." Am 06.09.
habe ich den Chat-Knopf dazugesetzt -- SECHS -- und die Schwelle bei 380
gelassen. Derselbe Fehler, eine Position weiter.
Die Lehre ist nicht "380 auf 430 erhoehen"; das waere er ein drittes
Mal, nur mit einer anderen Zahl. Eine feste Schwelle ist eine Rechnung,
die jemand einmal aufgestellt hat und die beim naechsten Knopf still
falsch wird. `flex-wrap: wrap` OHNE Schwelle rechnet nicht, sondern
misst -- es bricht genau dann um, wenn der Platz nicht reicht.
DASSELBE NOCH EINMAL, am anderen Ende: Bei 1280 px brauchte die Leiste
1235 px (Marke 375 + Bedienelemente 844 + Abstand 16) und hatte 1200.
Auch hier war der Chat-Knopf der Tropfen. Sie brach um, sobald das
Skript die Bedienelemente eingehaengt hatte, und schob die ganze Seite
52 px nach unten -- CLS 0,94. Jetzt gibt die MARKE nach (sie darf
gekuerzt werden, ein Knopf nicht), und umgebrochen wird nur noch auf
sehr schmalen Geraeten.
WEITERE ECHTE FUNDE:
* /workspace/api/chat/ungelesen wurde auf JEDER Seite ZWEIMAL geholt:
einmal beim Laden, einmal Millisekunden spaeter beim Aufgehen des
Ereignisstroms. Der 'open'-Zuhoerer war fuer Wiederverbindungen
gedacht und feuerte auch beim ersten Mal.
* Drei Kacheln teilten sich einen Farbton mit einer anderen (18 Farben
auf 21 Kacheln). Filipe wollte ausdruecklich, dass jede ihre eigene
hat. Nicht eine Farbe dazuerfunden -- der ganze Farbkreis ist mit
tools/kachel-farben.mjs neu in 21 geteilt; kleinster Abstand zweier
Nachbarn jetzt 120 Grad (vorher 106). Dabei fiel eine feste 16 in
der Mischschleife auf: Bei 18 Kacheln wurden die letzten beiden nie
mitgemischt.
* Der Agentur-Untertitel wurde auf dem Handy abgeschnitten.
* Ein zugeklappter <details>-Kasten (Kalender-Abo) verdeckte einen
Filter-Chip: Chromium versteckt dessen Inhalt ueber
`content-visibility`, nicht ueber `display` -- die Kaesten behalten
eine Groesse. Dieselbe Falle wie bei den Zeitbloecken, dieselbe
Loesung.
UND DREI PRUEFUNGEN, DIE SELBST FALSCH LAGEN:
* "achtzehn Kacheln" stand als feste Zahl im Test. Eine Pruefung, die
bei jeder neuen Kachel rot wird, erzieht dazu, ihr Rotwerden zu
ignorieren. Sie zaehlt jetzt aus bereiche.js.
* "das Licht der Kalender-Kachel (tuerkis) muss mehr Blau als Rot
haben" -- Wissen von aussen, und nach dem Neurechnen war der
Kalender rosa. Gemessen wird jetzt gegen den Ton der Kachel selbst.
* pruef-workspace-umzug meldete sein Ergebnis in eigenen Worten. Der
Gesamtlauf las daraus NULL Pruefungen und schrieb bei JEDEM Lauf ein
FEHL, obwohl alle 31 Punkte bestanden. Ein Fehlalarm, der immer
kommt, macht den einen echten unsichtbar.
Und weil "BUTTON 'Meine Sicht' verdeckt" mich eine Stunde gekostet hat,
sagen pruef-handy und pruef-tempo jetzt DAZU, was verdeckt und was
springt -- mit Elternkette und Koordinaten. Ein Befund, den man nicht
verorten kann, ist ein halber.
NEU: der Kalender zum Abonnieren (Stufe 3.2). Persoenlicher, jederzeit
widerrufbarer Link fuer Google, Apple und Outlook. 36 Pruefungen, die
das ICS ZURUECKLESEN statt es anzusehen -- entfaltet, entschluesselt,
verglichen. Wichtigster Punkt: Der Weg ohne Anmeldung darf nichts
zeigen, was der Weg mit Anmeldung nicht zeigt.
Co-Authored-By: Claude Opus 5 <[email protected]>
441 lines
18 KiB
JavaScript
441 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,
|
|
} 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" });
|
|
}
|
|
});
|