Files
tessera-ctl/apps/api/src/calendar/providers/exchange.provider.ts
T
schalli 636fe0df8f refactor(quick-260921-bi2): maschinelle Lint-Fixe und toten Code abbauen
- Aufgabe 2: vier sichere Biome-Regeln (useImportType pfadgebunden auf
  apps/web+packages, noUselessEscapeInRegex, useConst,
  useExponentiationOperator) sowie fuenf ungesicherte Regeln
  (useNodejsImportProtocol, useLiteralKeys, useOptionalChain, useTemplate,
  useParseIntRadix) angewendet und den gesamten Diff von Hand gelesen
  (ldap.service.ts zeichenweise gegen Gross-/Kleinschreibung der
  AD-Merkmale, auth.service.ts/jwt.strategy.ts gegen Durchwinken bei
  fehlender Sitzung geprueft)
- noUselessSwitchCase bleibt bewusst stehen (tender-normalizer.service.ts:60,
  die Fallmarke dokumentiert Absicht)
- Toter Code (D-03): fuenf folgenlose Auffangvariablen entfernt, eine
  nicht benutzte Funktion (forSystemQuery, Pruefskript) entfernt, ein
  positionsgebundener Dekoratorparameter umbenannt (current-user.decorator.ts),
  fuenf Symptomfunde entfernt und als Folgeaufgaben zu melden (siehe unten)
- Sechs weitere, im Plan nicht namentlich gelistete aber
  gleich-kategorische Dead-Code-Fundstellen in Testdateien zusaetzlich
  bereinigt (groups.service.spec.ts, cert-manager.test.tsx,
  ldap.service.spec.ts, prisma-tenant.extension.spec.ts x3) — noetig, um
  die vom Plan selbst verlangten Nullstaende bei noUnusedVariables/
  noUnusedImports/noUnusedFunctionParameters zu erreichen

Dekoratordaten aus apps/api unveraendert (593 Zeilen, sha256 6e1583f1...).
Endstand 620 Befunde (541 echt, 79 Test) statt der im Plan geschaetzten
621/542 — eine Differenz von 1, weil das Streichen des Namens aus
`catch (e: any)` in calendar.service.ts (Symptom-Fix) den dort ebenfalls
gemeldeten noExplicitAny-Befund miteliminiert; das ist eine erwuenschte
Nebenwirkung, keine Regression. Fehlerstufe 0, beide Testlaeufe
punktgleich gruen (69/1124, 66/459), pnpm type-check 4/4, pnpm lint
--force 5/5.

Folgeaufgaben aus D-03 (nicht in diesem Vorgang behoben):
- force-password-change.interceptor.ts: Freigabeliste prueft nur den Pfad,
  nicht die HTTP-Methode
- change-password/page.tsx: nach erzwungenem Wechsel bleibt die Person auf
  der Seite stehen (keine Weiterleitung, keine Aktualisierung der
  Benutzerablage)
- VehicleTable.tsx: Loeschschaltflaeche hat keinen Besetztzustand, laesst
  sich doppelt ausloesen
- SplitTab.tsx: downloadAllAsZip erhielt eine ungenutzte
  Uebersetzungsfunktion, Hinweis auf fest verdrahtete Texte im Zip-Pfad

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 08:58:32 +02:00

342 lines
10 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common';
import { CalendarEvent, CalendarProvider } from '../calendar.service';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const httpntlm = require('httpntlm') as {
post: (opts: any, cb: (err: Error | null, res: any) => void) => void;
};
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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: {
url: string; username: string; password: string;
domain: string; workstation: string; body: string;
headers: Record<string, string>;
}): Promise<{ statusCode: number; body: string }> {
return new Promise((resolve, reject) => {
httpntlm.post(opts, (err, res) => {
if (err) return reject(err);
resolve({ statusCode: res.statusCode, 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 {
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: (error: any, token: string) => void) => {
// 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,
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: (error: any, token: string) => void) => {
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"');
}
}