Files
Rotatory/server/nextcloud.js
T
ferdi2goandClaude Fable 5.1 95756778d8 Rotatory anlegen: Morning-Meeting-Moderation auslosen und nach Nextcloud schreiben
Express-App mit JSON-Speicher unter VCH_DATA_DIR, Monatskalender mit Fotos,
Personen mit Rollen und Teilzeit-Freitagen, Abwesenheiten, Feiertage je
Bundesland, faire Zufallsauslosung mit Fixieren und Neu-Auslosen.

Nextcloud: CalDAV-Discovery, Zielkalender wählbar, Termine je Werktag
schreiben und aktualisieren, Abwesenheiten aus Kalender importieren,
Personen samt Fotos aus dem Systemadressbuch übernehmen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 06:39:37 +00:00

228 lines
13 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.
// CalDAV/CardDAV-Client für Nextcloud ohne Abhängigkeiten: Discovery, Kalender, Termine, Adressbuch.
export function nextcloudConfigFromEnv(env = process.env) {
const baseUrl = (env.NEXTCLOUD_URL || '').trim().replace(/\/+$/, '');
const user = (env.NEXTCLOUD_USER || '').trim();
const password = (env.NEXTCLOUD_APP_PASSWORD || '').trim();
const calendar = (env.NEXTCLOUD_CALENDAR || 'morning-meeting').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
return { baseUrl, user, password, calendar, configured: Boolean(baseUrl && user && password) };
}
// ---------- XML-Hilfen (namespace-präfix-unabhängig) ----------
export function decodeXml(s) {
return String(s ?? '')
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
.replace(/&#x([0-9a-fA-F]+);/g, (m, h) => String.fromCodePoint(parseInt(h, 16)))
.replace(/&#(\d+);/g, (m, d) => String.fromCodePoint(Number(d)))
.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, '&');
}
export function encodeXml(s) {
return String(s ?? '').replace(/[<>&"']/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&apos;' }[c]));
}
// Lokaler Name mit beliebigem Präfix; das Ende ist erreicht, wenn kein Namenszeichen folgt ("write" ≠ "write-properties").
const tag = (local) => `(?:[A-Za-z0-9_-]+:)?${local}(?![A-Za-z0-9_-])`;
export function xmlResponses(xml) {
return [...String(xml).matchAll(new RegExp(`<${tag('response')}[^>]*>([\\s\\S]*?)</${tag('response')}\\s*>`, 'g'))].map((m) => m[1]);
}
export function xmlProp(block, local) {
const m = String(block ?? '').match(new RegExp(`<${tag(local)}[^>]*>([\\s\\S]*?)</${tag(local)}\\s*>`));
return m ? m[1] : null;
}
export function xmlHasEmpty(block, local) {
return new RegExp(`<${tag(local)}[^>]*/>`).test(String(block ?? ''));
}
export function xmlAll(block, local) {
return [...String(block ?? '').matchAll(new RegExp(`<${tag(local)}[^>]*>([\\s\\S]*?)</${tag(local)}\\s*>`, 'g'))].map((m) => m[1]);
}
const PROPFIND_BODY = (props) => `<?xml version="1.0" encoding="utf-8"?><d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:card="urn:ietf:params:xml:ns:carddav" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns"><d:prop>${props}</d:prop></d:propfind>`;
export function createNextcloudClient({ baseUrl, user, password, calendar = 'morning-meeting', displayName = 'Morning Meeting', fetchImpl = fetch }) {
const auth = 'Basic ' + Buffer.from(`${user}:${password}`).toString('base64');
const abs = (href) => new URL(href, baseUrl + '/').toString();
let discovered = null;
async function request(method, url, { headers = {}, body } = {}) {
try {
return await fetchImpl(url, { method, headers: { Authorization: auth, 'User-Agent': 'Rotatory/1.0', ...headers }, body, redirect: 'manual' });
} catch (err) {
throw new Error(`Nextcloud nicht erreichbar: ${err.cause?.message || err.message}`);
}
}
async function dav(method, url, body, depth = '0') {
const res = await request(method, url, { headers: { Depth: depth, 'Content-Type': 'application/xml; charset=utf-8' }, body });
if (res.status === 401) throw new Error('Nextcloud lehnt die Zugangsdaten ab (401). Benutzername oder App-Passwort prüfen.');
return res;
}
async function propfind(url, props, depth = '0') {
const res = await dav('PROPFIND', url, PROPFIND_BODY(props), depth);
return { status: res.status, xml: res.status === 207 ? await res.text() : '' };
}
const client = {
/** Principal, Kalender- und Adressbuch-Home des angemeldeten Kontos ermitteln. */
async discover() {
if (discovered) return discovered;
const root = await propfind(abs('remote.php/dav/'), '<d:current-user-principal/>');
if (root.status !== 207) throw new Error(`Nextcloud antwortet mit HTTP ${root.status} auf ${abs('remote.php/dav/')}`);
const principalHref = decodeXml(xmlProp(xmlProp(root.xml, 'current-user-principal'), 'href'));
if (!principalHref) throw new Error('Nextcloud nennt keinen Principal – ist das eine Nextcloud-URL?');
const p = await propfind(abs(principalHref), '<c:calendar-home-set/><card:addressbook-home-set/><d:displayname/>');
if (p.status !== 207) throw new Error(`Principal nicht lesbar: HTTP ${p.status}`);
const calendarHome = decodeXml(xmlProp(xmlProp(p.xml, 'calendar-home-set'), 'href'));
const addressbookHome = decodeXml(xmlProp(xmlProp(p.xml, 'addressbook-home-set'), 'href'));
if (!calendarHome) throw new Error('Kein Kalender-Home gefunden – ist die Kalender-App aktiviert?');
discovered = {
principalHref,
userId: decodeURIComponent(principalHref.replace(/\/+$/, '').split('/').pop()),
displayName: decodeXml(xmlProp(p.xml, 'displayname') || ''),
calendarHome: abs(calendarHome),
addressbookHome: addressbookHome ? abs(addressbookHome) : null,
};
return discovered;
},
async test() {
const d = await client.discover();
const calendars = await client.listCalendars();
return { ok: true, userId: d.userId, displayName: d.displayName, calendarHome: d.calendarHome, calendars };
},
/** Alle Ereigniskalender (eigene und geteilte) mit Schreibrecht-Information. */
async listCalendars() {
const d = await client.discover();
const r = await propfind(d.calendarHome, '<d:displayname/><d:resourcetype/><d:owner/><d:current-user-privilege-set/><c:supported-calendar-component-set/><oc:owner-displayname/>', '1');
if (r.status !== 207) throw new Error(`Kalenderliste nicht lesbar: HTTP ${r.status}`);
const out = [];
for (const block of xmlResponses(r.xml)) {
const href = decodeXml(xmlProp(block, 'href') || '');
const rt = xmlProp(block, 'resourcetype') || '';
if (!xmlHasEmpty(rt, 'calendar')) continue;
const comps = xmlProp(block, 'supported-calendar-component-set') || '';
const supportsEvents = !comps || /name="VEVENT"/.test(comps);
if (!supportsEvents) continue;
const priv = xmlProp(block, 'current-user-privilege-set') || '';
const writable = xmlHasEmpty(priv, 'write') || xmlHasEmpty(priv, 'write-content') || xmlHasEmpty(priv, 'all');
const ownerHref = decodeXml(xmlProp(xmlProp(block, 'owner'), 'href') || '');
const ownerId = ownerHref ? decodeURIComponent(ownerHref.replace(/\/+$/, '').split('/').pop()) : '';
out.push({
href: abs(href),
id: href.replace(/\/+$/, '').split('/').pop(),
name: decodeXml(xmlProp(block, 'displayname') || ''),
writable,
shared: ownerId !== d.userId,
ownerName: decodeXml(xmlProp(block, 'owner-displayname') || '') || ownerId,
});
}
return out.sort((a, b) => a.name.localeCompare(b.name, 'de'));
},
/** Zielkalender bereitstellen: vorhandener (href) oder eigener Kalender, der bei Bedarf angelegt wird. */
async ensureCalendar(targetHref) {
const d = await client.discover();
if (targetHref) {
const r = await propfind(targetHref, '<d:resourcetype/><d:current-user-privilege-set/>');
if (r.status !== 207) throw new Error(`Zielkalender nicht erreichbar (HTTP ${r.status}). In den Nextcloud-Einstellungen neu wählen.`);
return { href: targetHref, created: false };
}
const href = `${d.calendarHome}${encodeURIComponent(calendar)}/`;
const r = await propfind(href, '<d:resourcetype/>');
if (r.status === 207) return { href, created: false };
if (r.status !== 404) throw new Error(`Kalender-Abfrage fehlgeschlagen: HTTP ${r.status}`);
const body = '<?xml version="1.0" encoding="utf-8"?>'
+ '<c:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:a="http://apple.com/ns/ical/"><d:set><d:prop>'
+ `<d:displayname>${encodeXml(displayName)}</d:displayname>`
+ '<a:calendar-color>#2f6fed</a:calendar-color>'
+ '<c:supported-calendar-component-set><c:comp name="VEVENT"/></c:supported-calendar-component-set>'
+ '</d:prop></d:set></c:mkcalendar>';
const mk = await dav('MKCALENDAR', href, body);
if (mk.status < 200 || mk.status >= 300) throw new Error(`Kalender konnte nicht angelegt werden: HTTP ${mk.status}`);
return { href, created: true };
},
async putEvent(calendarHref, uid, ics) {
const res = await request('PUT', `${calendarHref}${encodeURIComponent(uid)}.ics`, { headers: { 'Content-Type': 'text/calendar; charset=utf-8' }, body: ics });
if (res.status < 200 || res.status >= 300) {
const text = await res.text().catch(() => '');
throw new Error(`Termin ${uid} nicht gespeichert: HTTP ${res.status} ${text.replace(/<[^>]+>/g, ' ').trim().slice(0, 160)}`);
}
},
async deleteEvent(calendarHref, uidOrHref) {
const url = /^https?:\/\//.test(uidOrHref) ? uidOrHref : `${calendarHref}${encodeURIComponent(uidOrHref)}.ics`;
const res = await request('DELETE', url);
if (res.status === 404) return false;
if (res.status < 200 || res.status >= 300) throw new Error(`Termin nicht gelöscht: HTTP ${res.status}`);
return true;
},
/** Termine eines Kalenders im Zeitraum (ISO-Tage, inklusiv). expand: Wiederholungen serverseitig auflösen. */
async queryEvents(calendarHref, from, to, { expand = false } = {}) {
const start = `${from.replace(/-/g, '')}T000000Z`;
const endDate = new Date(to + 'T00:00:00Z');
endDate.setUTCDate(endDate.getUTCDate() + 1);
// Der Server filtert grob (UTC-Mitternacht); Aufrufer prüfen die Überlappung mit den lokalen Tagen selbst.
const end = `${endDate.toISOString().slice(0, 10).replace(/-/g, '')}T000000Z`;
const data = expand ? `<c:calendar-data><c:expand start="${start}" end="${end}"/></c:calendar-data>` : '<c:calendar-data/>';
const body = `<?xml version="1.0" encoding="utf-8"?><c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:getetag/>${data}</d:prop><c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT"><c:time-range start="${start}" end="${end}"/></c:comp-filter></c:comp-filter></c:filter></c:calendar-query>`;
const res = await dav('REPORT', calendarHref, body, '1');
if (res.status !== 207) throw new Error(`Terminabfrage fehlgeschlagen: HTTP ${res.status}`);
const xml = await res.text();
return xmlResponses(xml).map((block) => ({
href: abs(decodeXml(xmlProp(block, 'href') || '')),
etag: decodeXml(xmlProp(block, 'getetag') || ''),
ics: decodeXml(xmlProp(block, 'calendar-data') || ''),
})).filter((e) => e.ics);
},
async listAddressBooks() {
const d = await client.discover();
if (!d.addressbookHome) return [];
const r = await propfind(d.addressbookHome, '<d:displayname/><d:resourcetype/>', '1');
if (r.status !== 207) throw new Error(`Adressbuchliste nicht lesbar: HTTP ${r.status}`);
const out = [];
for (const block of xmlResponses(r.xml)) {
const rt = xmlProp(block, 'resourcetype') || '';
if (!xmlHasEmpty(rt, 'addressbook')) continue;
const href = decodeXml(xmlProp(block, 'href') || '');
const id = href.replace(/\/+$/, '').split('/').pop();
out.push({ href: abs(href), id, name: decodeXml(xmlProp(block, 'displayname') || id), system: id === 'z-server-generated--system' });
}
return out.sort((a, b) => Number(b.system) - Number(a.system) || a.name.localeCompare(b.name, 'de'));
},
/** Alle vCards eines Adressbuchs als Text. */
async queryContacts(addressbookHref) {
const body = '<?xml version="1.0" encoding="utf-8"?><card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav"><d:prop><d:getetag/><card:address-data/></d:prop></card:addressbook-query>';
const res = await dav('REPORT', addressbookHref, body, '1');
if (res.status !== 207) throw new Error(`Adressbuch nicht lesbar: HTTP ${res.status}`);
const xml = await res.text();
return xmlResponses(xml).map((block) => decodeXml(xmlProp(block, 'address-data') || '')).filter(Boolean);
},
/** Profilbild eines Nextcloud-Kontos; null, wenn keines gesetzt ist (Buchstaben-Avatar zählt nicht). */
async fetchAvatar(userId, size = 256) {
const res = await request('GET', abs(`index.php/avatar/${encodeURIComponent(userId)}/${size}`));
if (res.status !== 200) return null;
const custom = res.headers.get('x-nc-iscustomavatar');
if (custom !== null && custom !== '1') return null;
const type = res.headers.get('content-type') || '';
if (!type.startsWith('image/')) return null;
return { buffer: Buffer.from(await res.arrayBuffer()), mime: type.split(';')[0] };
},
/** Bild von einer URL – nur innerhalb der eigenen Nextcloud, damit das App-Passwort nie an fremde Hosts geht. */
async fetchBinary(url) {
let target;
try { target = new URL(url); } catch { return null; }
if (target.origin !== new URL(baseUrl).origin) return null;
const res = await request('GET', url);
if (res.status !== 200) return null;
const type = (res.headers.get('content-type') || '').split(';')[0];
if (!type.startsWith('image/')) return null;
return { buffer: Buffer.from(await res.arrayBuffer()), mime: type };
},
};
return client;
}