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>
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user