Files
personal-dashboard/src/lib/calendar-fetchers.ts
T
Claude a4051ae132 Initial commit: Personal Dashboard
Next.js 16 dashboard with configurable widgets (favorites, notes, calendar,
clock, calculator, search, domain-check), multi-tab support, user auth,
dark mode, and Docker deployment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 10:02:05 +02:00

355 lines
9.7 KiB
TypeScript

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<string, string | string[] | undefined>;
body: string;
};
type ExchangeCredentials = {
username: string;
domain: string;
password: string;
};
function asArray<T>(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<string> {
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<DashboardCalendarEvent[]> {
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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
}
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
? `<t:Mailbox><t:EmailAddress>${escapeXml(source.exchangeMailbox)}</t:EmailAddress></t:Mailbox>`
: "";
return `<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types"
xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages">
<soap:Header>
<t:RequestServerVersion Version="Exchange2013" />
</soap:Header>
<soap:Body>
<m:FindItem Traversal="Shallow">
<m:ItemShape>
<t:BaseShape>Default</t:BaseShape>
</m:ItemShape>
<m:CalendarView StartDate="${now.toISOString()}" EndDate="${windowEnd.toISOString()}" />
<m:ParentFolderIds>
<t:DistinguishedFolderId Id="calendar">
${mailboxBlock}
</t:DistinguishedFolderId>
</m:ParentFolderIds>
</m:FindItem>
</soap:Body>
</soap:Envelope>`;
}
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<string, string | string[] | undefined>, 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<string, string>;
}): Promise<NtlmPostResult> {
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<DashboardCalendarEvent[]> {
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);
}