import { XMLParser } from "fast-xml-parser"; import { decryptSecret } from "@/lib/secret-crypto"; export type DashboardCalendarEvent = { id: string; title: string; start: string; end: string | null; location: string | null; }; type CalendarSource = { id: string; type: string; name: string; color: string; icsUrl: string | null; exchangeEwsUrl: string | null; exchangeMailbox: string | null; exchangeUsername: string | null; exchangeDomain: string | null; exchangePasswordEnc: string | null; exchangePasswordIv: string | null; exchangePasswordTag: string | null; }; type NtlmPostResult = { statusCode: number; headers: Record; body: string; }; type ExchangeCredentials = { username: string; domain: string; password: string; }; function asArray(value: T | T[] | undefined | null): T[] { if (!value) { return []; } return Array.isArray(value) ? value : [value]; } function normalizeText(value: unknown): string { if (typeof value === "string") { return value; } if (typeof value === "number" || typeof value === "boolean") { return String(value); } return ""; } function buildEventId(prefix: string, value: unknown, fallback: string): string { const cleanValue = normalizeText(value).trim(); if (cleanValue) { return `${prefix}:${cleanValue}`; } return `${prefix}:${fallback}`; } async function fetchText(url: string, options?: RequestInit): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 15000); try { const response = await fetch(url, { ...options, signal: controller.signal }); const text = await response.text(); if (!response.ok) { throw new Error(`HTTP ${response.status} ${response.statusText}: ${text.slice(0, 300)}`); } return text; } finally { clearTimeout(timeout); } } function isEventInWindow(start: Date, end: Date | null, windowStart: Date, windowEnd: Date): boolean { const effectiveEnd = end ?? start; return start < windowEnd && effectiveEnd >= windowStart; } export async function fetchIcsEvents( source: CalendarSource, lookaheadDays: number, maxEvents: number ): Promise { if (!source.icsUrl) { return []; } const calendarText = await fetchText(source.icsUrl, { headers: { Accept: "text/calendar,*/*", "User-Agent": "personal-dashboard/0.1.0" } }); const icalModule = await import("node-ical"); const ical: any = icalModule.default ?? icalModule; const parsedCalendar = await ical.async.parseICS(calendarText); const now = new Date(); const windowEnd = new Date(now); windowEnd.setDate(now.getDate() + lookaheadDays); return Object.values(parsedCalendar) .filter((entry: any) => entry?.type === "VEVENT" && entry.start) .map((entry: any, index) => { const start = new Date(entry.start); const end = entry.end ? new Date(entry.end) : null; return { id: buildEventId("ics", entry.uid, String(index)), title: normalizeText(entry.summary).trim() || "Termin", start: start.toISOString(), end: end ? end.toISOString() : null, location: normalizeText(entry.location).trim() || null }; }) .filter((event) => isEventInWindow(new Date(event.start), event.end ? new Date(event.end) : null, now, windowEnd) ) .sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime()) .slice(0, maxEvents); } function escapeXml(value: string): string { return value .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function buildEwsRequest(source: CalendarSource, lookaheadDays: number): string { const now = new Date(); const windowEnd = new Date(now); windowEnd.setDate(now.getDate() + lookaheadDays); const mailboxBlock = source.exchangeMailbox ? `${escapeXml(source.exchangeMailbox)}` : ""; return ` Default ${mailboxBlock} `; } function getExchangePassword(source: CalendarSource): string { if (!source.exchangePasswordEnc || !source.exchangePasswordIv || !source.exchangePasswordTag) { throw new Error("Exchange-Passwort ist nicht konfiguriert."); } return decryptSecret(source.exchangePasswordEnc, source.exchangePasswordIv, source.exchangePasswordTag); } function parseExchangeCredentials(source: CalendarSource): ExchangeCredentials { const rawUsername = source.exchangeUsername?.trim(); if (!rawUsername) { throw new Error("Exchange-Benutzername fehlt."); } const configuredDomain = source.exchangeDomain?.trim() ?? ""; const password = getExchangePassword(source); if (rawUsername.includes("\\") && !configuredDomain) { const [domain, ...usernameParts] = rawUsername.split("\\"); const username = usernameParts.join("\\"); if (!domain || !username) { throw new Error("Exchange-Benutzername ist ungültig."); } return { username, domain, password }; } return { username: rawUsername, domain: configuredDomain, password }; } function getHeaderValue(headers: Record, name: string): string { const value = headers[name.toLowerCase()] ?? headers[name]; if (Array.isArray(value)) { return value.join(", "); } return value ?? ""; } function postWithNtlm(options: { url: string; username: string; password: string; domain: string; body: string; headers: Record; }): Promise { return new Promise((resolve, reject) => { const httpntlm = require("httpntlm"); httpntlm.post( { url: options.url, username: options.username, password: options.password, domain: options.domain, workstation: "", headers: options.headers, body: options.body, timeout: 15000 }, (error: unknown, response: NtlmPostResult) => { if (error) { reject(error); return; } resolve(response); } ); }); } function parseEwsEvents(xml: string, maxEvents: number): DashboardCalendarEvent[] { const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_", removeNSPrefix: true }); const parsed = parser.parse(xml); const body = parsed?.Envelope?.Body; if (body?.Fault) { throw new Error(normalizeText(body.Fault?.faultstring) || "Exchange EWS SOAP-Fehler."); } const responseMessage = body?.FindItemResponse?.ResponseMessages?.FindItemResponseMessage; const responseClass = responseMessage?.["@_ResponseClass"]; if (responseClass && responseClass !== "Success") { const messageText = normalizeText(responseMessage?.MessageText) || "Exchange EWS Anfrage fehlgeschlagen."; throw new Error(messageText); } const calendarItems = asArray(responseMessage?.RootFolder?.Items?.CalendarItem); return calendarItems .map((item: any, index) => { const itemId = item?.ItemId?.["@_Id"] ?? String(index); const start = item?.Start ? new Date(item.Start) : null; const end = item?.End ? new Date(item.End) : null; if (!start || Number.isNaN(start.getTime())) { return null; } return { id: buildEventId("ews", itemId, String(index)), title: normalizeText(item.Subject).trim() || "Termin", start: start.toISOString(), end: end && !Number.isNaN(end.getTime()) ? end.toISOString() : null, location: normalizeText(item.Location).trim() || null }; }) .filter((event): event is DashboardCalendarEvent => Boolean(event)) .sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime()) .slice(0, maxEvents); } export async function fetchExchangeEwsEvents( source: CalendarSource, lookaheadDays: number, maxEvents: number ): Promise { if (!source.exchangeEwsUrl) { return []; } const credentials = parseExchangeCredentials(source); const response = await postWithNtlm({ url: source.exchangeEwsUrl, username: credentials.username, password: credentials.password, domain: credentials.domain, body: buildEwsRequest(source, lookaheadDays), headers: { "Content-Type": "text/xml; charset=utf-8", Accept: "text/xml", "User-Agent": "personal-dashboard/0.1.0" } }); const statusCode = response.statusCode; const authenticateHeader = getHeaderValue(response.headers ?? {}, "www-authenticate"); if (statusCode === 401) { throw new Error( authenticateHeader ? `Exchange EWS Anmeldung fehlgeschlagen. Server meldet: ${authenticateHeader}` : "Exchange EWS Anmeldung fehlgeschlagen." ); } if (statusCode < 200 || statusCode >= 300) { throw new Error(`Exchange EWS HTTP ${statusCode}: ${response.body.slice(0, 300)}`); } return parseEwsEvents(response.body, maxEvents); }