// 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(/([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]*?)${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) => `${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;
}