e49d4c7c9d
EWS-Aufrufe ueber httpntlm bekommen eine eigene 15-s-Zeitgrenze; httpreqs timeout greift waehrend des Verbindungsaufbaus nicht (gemessen 134 s). Netzfehler liefern im Test den Schluessel 'unreachable', das Formular meldet 'nicht erreichbar' statt 'Zugangsdaten pruefen'. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
392 lines
12 KiB
TypeScript
392 lines
12 KiB
TypeScript
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<string, string>;
|
|
/** 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 `<?xml version="1.0" encoding="utf-8"?>
|
|
<soap:Envelope xmlns:soap="${NS_SOAP}" xmlns:t="${NS_TYPES}" xmlns:m="${NS_MESSAGES}">
|
|
<soap:Body>${body}</soap:Body>
|
|
</soap:Envelope>`;
|
|
}
|
|
|
|
function escapeXml(s: string): string {
|
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
function extractAll(xml: string, tag: string): string[] {
|
|
const results: string[] = [];
|
|
const open = `<${tag}`;
|
|
const close = `</${tag}>`;
|
|
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<CalendarEvent[]> {
|
|
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<boolean> {
|
|
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<CalendarEvent[]> {
|
|
// 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<CalendarEvent[]> {
|
|
const fromIso = from.toISOString();
|
|
const toIso = to.toISOString();
|
|
|
|
const findSoap = soapEnvelope(`
|
|
<m:FindItem Traversal="Shallow">
|
|
<m:ItemShape>
|
|
<t:BaseShape>IdOnly</t:BaseShape>
|
|
<t:AdditionalProperties>
|
|
<t:FieldURI FieldURI="item:Subject"/>
|
|
<t:FieldURI FieldURI="calendar:Start"/>
|
|
<t:FieldURI FieldURI="calendar:End"/>
|
|
<t:FieldURI FieldURI="calendar:IsAllDayEvent"/>
|
|
<t:FieldURI FieldURI="calendar:Location"/>
|
|
</t:AdditionalProperties>
|
|
</m:ItemShape>
|
|
<m:CalendarView StartDate="${escapeXml(fromIso)}" EndDate="${escapeXml(toIso)}" MaxEntriesReturned="100"/>
|
|
<m:ParentFolderIds>
|
|
<t:DistinguishedFolderId Id="calendar"/>
|
|
</m:ParentFolderIds>
|
|
</m:FindItem>`);
|
|
|
|
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 = `</${tag}>`;
|
|
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<boolean> {
|
|
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<boolean> {
|
|
const soap = soapEnvelope(`
|
|
<m:GetFolder>
|
|
<m:FolderShape>
|
|
<t:BaseShape>IdOnly</t:BaseShape>
|
|
</m:FolderShape>
|
|
<m:FolderIds>
|
|
<t:DistinguishedFolderId Id="calendar"/>
|
|
</m:FolderIds>
|
|
</m:GetFolder>`);
|
|
|
|
const res = await this.ewsNtlmPost(source, soap, 'GetFolder');
|
|
return res.statusCode === 200 && !res.body.includes('ResponseClass="Error"');
|
|
}
|
|
}
|