// 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(//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(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&'); } export function encodeXml(s) { return String(s ?? '').replace(/[<>&"']/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[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]*?)`, 'g'))].map((m) => m[1]); } export function xmlProp(block, local) { const m = String(block ?? '').match(new RegExp(`<${tag(local)}[^>]*>([\\s\\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]*?)`, 'g'))].map((m) => m[1]); } const PROPFIND_BODY = (props) => `${props}`; 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/'), ''); 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), ''); 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, '', '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, ''); 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, ''); if (r.status === 207) return { href, created: false }; if (r.status !== 404) throw new Error(`Kalender-Abfrage fehlgeschlagen: HTTP ${r.status}`); const body = '' + '' + `${encodeXml(displayName)}` + '#2f6fed' + '' + ''; 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 ? `` : ''; const body = `${data}`; 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, '', '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 = ''; 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; }