import { Injectable, Logger } from '@nestjs/common'; import type { AuthProviderCallback } from '@microsoft/microsoft-graph-client'; import { CalendarEvent, CalendarProvider, CalendarSourceUnreachableError, isNetworkUnreachableError, } from '../calendar.service'; /** Optionen, die ntlmPost() unten uebergibt — nichts darueber hinaus. */ interface NtlmOptions { url: string; username: string; password: string; domain: string; workstation: string; body: string; headers: Record; /** Millisekunden bis zum Abbruch mit `code: 'TIMEOUT'` (siehe ntlmPost). */ timeout: number; } /** * Antwortform von httpntlm.post, beschrieben aus dem, was gelesen wird. * * `body` ist `Buffer | string`: httpreq (unter httpntlm) liefert eine * Zeichenkette, solange `binary` nicht gesetzt ist (gemessen, * httpreq@1.1.1/lib/httpreq.js:391) — hier wird es nicht gesetzt. Siehe die * ausfuehrliche Begruendung in inbox/exchange-inbox.provider.ts. */ interface NtlmResponse { statusCode: number; body?: Buffer | string; } // eslint-disable-next-line @typescript-eslint/no-require-imports const httpntlm = require('httpntlm') as { post: (opts: NtlmOptions, cb: (err: Error | null, res: NtlmResponse) => void) => void; }; /** * quick-261005: Ohne Grenze wartete ein EWS-Aufruf auf eine nicht * erreichbare Adresse rund zwei Minuten (TCP-Verbindungsaufbau des * Betriebssystems), der Test-Knopf hing so lange auf „wird geprueft“. */ const EWS_TIMEOUT_MS = 15_000; const NS_SOAP = 'http://schemas.xmlsoap.org/soap/envelope/'; const NS_TYPES = 'http://schemas.microsoft.com/exchange/services/2006/types'; const NS_MESSAGES = 'http://schemas.microsoft.com/exchange/services/2006/messages'; function soapEnvelope(body: string): string { return ` ${body} `; } function escapeXml(s: string): string { return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function extractAll(xml: string, tag: string): string[] { const results: string[] = []; const open = `<${tag}`; const close = ``; let pos = 0; while (pos < xml.length) { const start = xml.indexOf(open, pos); if (start === -1) break; const end = xml.indexOf(close, start); if (end === -1) break; const innerStart = xml.indexOf('>', start) + 1; results.push(xml.slice(innerStart, end)); pos = end + close.length; } return results; } function extractAttr(xml: string, tag: string, attr: string): string { const tagStart = xml.indexOf(`<${tag}`); if (tagStart === -1) return ''; const tagEnd = xml.indexOf('>', tagStart); const tagStr = xml.slice(tagStart, tagEnd + 1); const attrMatch = tagStr.match(new RegExp(`${attr}="([^"]*)"`)); return attrMatch ? attrMatch[1] : ''; } function ntlmPost(opts: NtlmOptions): Promise<{ statusCode: number; body: string }> { return new Promise((resolve, reject) => { // Eigene Zeitgrenze zusaetzlich zu `opts.timeout`: httpreq setzt seine // nur als Leerlaufgrenze am Socket, die waehrend des Verbindungsaufbaus // ueber den Keep-alive-Agenten von httpntlm NICHT greift — gemessen // 05.10.: 134 s bis zum Fehler trotz `timeout: 15000`. const timer = setTimeout(() => { reject(Object.assign(new Error('EWS request timed out'), { code: 'TIMEOUT' })); }, opts.timeout); httpntlm.post(opts, (err, res) => { clearTimeout(timer); if (err) return reject(err); resolve({ statusCode: res.statusCode, body: typeof res.body === 'string' ? res.body : (res.body?.toString('utf-8') ?? ''), }); }); }); } /** * Exchange calendar provider — dispatches on exchangeMode ('graph' vs 'ews'). * * - 'graph': Uses @microsoft/microsoft-graph-client for Exchange Online / M365 * - 'ews': Uses ews-javascript-api for on-premise Exchange Server * * Both modes gracefully degrade: on auth failure, returns empty array and * surfaces a generic error (no credential details — Security V7 / T-05-13). */ @Injectable() export class ExchangeProvider implements CalendarProvider { private readonly logger = new Logger(ExchangeProvider.name); /** * Fetches events from Exchange, dispatching by exchangeMode. * D-08: source TYPE is configurable and attempted — widget must not crash. */ async fetchEvents( source: { url: string; username?: string; password?: string; exchangeMode?: string | null; domain?: string; id: string; color?: string | null; }, from: Date, to: Date, ): Promise { const mode = source.exchangeMode || 'graph'; try { if (mode === 'graph') { return await this.fetchViaGraph(source, from, to); } else { return await this.fetchViaEws(source, from, to); } } catch (error) { // Graceful degradation — T-05-13: no credential details in error this.logger.error( `Exchange (${mode}) fetch failed for source ${source.id}: ${(error as Error).message}`, ); return []; } } /** * Tests connection to Exchange. Returns false on any auth/network failure. */ async testConnection( source: { url: string; username?: string; password?: string; exchangeMode?: string | null; domain?: string; id: string; }, ): Promise { const mode = source.exchangeMode || 'graph'; try { if (mode === 'graph') { return await this.testGraphConnection(source); } else { return await this.testEwsConnection(source); } } catch (error) { // quick-261005: „nicht erreichbar“ getrennt melden, damit die // Oberflaeche nicht „Zugangsdaten pruefen“ sagt, wenn das Netz fehlt. if (isNetworkUnreachableError(error)) throw new CalendarSourceUnreachableError(); return false; } } /** * Fetches events via Microsoft Graph API (Exchange Online / M365). * Uses @microsoft/microsoft-graph-client with /me/calendarView. */ private async fetchViaGraph( source: { url: string; username?: string; password?: string; id: string; color?: string | null; }, from: Date, to: Date, ): Promise { // Dynamic import to avoid loading Graph SDK when not needed const { Client: GraphClient } = await import( '@microsoft/microsoft-graph-client' ); const client = GraphClient.init({ authProvider: (done: AuthProviderCallback) => { // Use the password as the access token (OAuth bearer token) // Users configure their OAuth token in the password field for Graph API done(null, source.password || ''); }, }); const result = await client .api('/me/calendarView') .query({ startDateTime: from.toISOString(), endDateTime: to.toISOString(), }) .select('id,subject,start,end,isAllDay,location,bodyPreview') .orderby('start/dateTime') .top(100) .get(); const events: CalendarEvent[] = []; if (result?.value) { for (const item of result.value) { events.push({ id: `${source.id}-${item.id}`, sourceId: source.id, title: item.subject || 'Untitled', start: new Date(`${item.start?.dateTime}Z`), end: new Date(`${item.end?.dateTime}Z`), allDay: item.isAllDay || false, location: item.location?.displayName || undefined, description: item.bodyPreview || undefined, color: source.color ?? undefined, }); } } return events; } /** * Fetches calendar events via EWS using NTLM authentication (on-premise Exchange). * Uses raw SOAP + httpntlm — replaces ews-javascript-api which only supports Basic Auth. */ private async fetchViaEws( source: { url: string; username?: string; password?: string; domain?: string; id: string; color?: string | null; }, from: Date, to: Date, ): Promise { const fromIso = from.toISOString(); const toIso = to.toISOString(); const findSoap = soapEnvelope(` IdOnly `); const res = await this.ewsNtlmPost(source, findSoap, 'FindItem'); if (res.statusCode !== 200) { this.logger.warn(`EWS FindItem calendar returned HTTP ${res.statusCode}`); return []; } const events: CalendarEvent[] = []; const itemBlocks = this.splitItemBlocks(res.body, 't:CalendarItem'); for (const block of itemBlocks) { const uid = extractAttr(block, 't:ItemId', 'Id'); const title = extractAll(block, 't:Subject')[0] ?? 'Untitled'; const startStr = extractAll(block, 't:Start')[0] ?? ''; const endStr = extractAll(block, 't:End')[0] ?? ''; const allDayStr = extractAll(block, 't:IsAllDayEvent')[0] ?? 'false'; const location = extractAll(block, 't:Location')[0] ?? undefined; events.push({ id: `${source.id}-${uid || String(Date.now())}`, sourceId: source.id, title, start: startStr ? new Date(startStr) : new Date(), end: endStr ? new Date(endStr) : new Date(), allDay: allDayStr === 'true', location: location || undefined, description: undefined, color: source.color ?? undefined, }); } return events; } private splitItemBlocks(xml: string, tag: string): string[] { const blocks: string[] = []; const open = `<${tag}`; const close = ``; let pos = 0; while (pos < xml.length) { const start = xml.indexOf(open, pos); if (start === -1) break; const end = xml.indexOf(close, start); if (end === -1) break; blocks.push(xml.slice(start, end + close.length)); pos = end + close.length; } return blocks; } private async ewsNtlmPost( source: { url: string; username?: string; password?: string; domain?: string }, soap: string, action: string, ): Promise<{ statusCode: number; body: string }> { return ntlmPost({ url: source.url, username: source.username ?? '', password: source.password ?? '', domain: source.domain ?? '', workstation: '', body: soap, timeout: EWS_TIMEOUT_MS, headers: { 'Content-Type': 'text/xml; charset=utf-8', 'SOAPAction': `"http://schemas.microsoft.com/exchange/services/2006/messages/${action}"`, }, }); } /** * Tests Graph API connection by requesting calendar list. */ private async testGraphConnection( source: { url: string; password?: string }, ): Promise { const { Client: GraphClient } = await import( '@microsoft/microsoft-graph-client' ); const client = GraphClient.init({ authProvider: (done: AuthProviderCallback) => { done(null, source.password || ''); }, }); const result = await client.api('/me/calendars').top(1).get(); return !!result?.value; } /** * Tests EWS connection using NTLM auth — GetFolder on calendar folder. */ private async testEwsConnection( source: { url: string; username?: string; password?: string; domain?: string }, ): Promise { const soap = soapEnvelope(` IdOnly `); const res = await this.ewsNtlmPost(source, soap, 'GetFolder'); return res.statusCode === 200 && !res.body.includes('ResponseClass="Error"'); } }