a4051ae132
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>
355 lines
9.7 KiB
TypeScript
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("&", "&")
|
|
.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
|
|
? `<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);
|
|
}
|