389ac9b692
- Implement ICSProvider with fetch + node-ical parsing + RRULE expansion - Implement CalDAVProvider with tsdav DAVClient + time-range filtering - Implement ExchangeProvider dispatching on exchangeMode (graph vs ews) - Graph mode uses @microsoft/microsoft-graph-client /me/calendarView - EWS mode uses ews-javascript-api FindAppointments - Exchange gracefully degrades: returns empty array on failure (D-08) - No provider logs decrypted passwords (T-05-13)
228 lines
6.7 KiB
TypeScript
228 lines
6.7 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import { CalendarEvent, CalendarProvider } from '../calendar.service';
|
|
|
|
/**
|
|
* 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;
|
|
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;
|
|
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 events via Exchange Web Services (on-premise Exchange).
|
|
* Uses ews-javascript-api with FindAppointments over a CalendarView.
|
|
*
|
|
* Note: ews-javascript-api has no TypeScript definitions;
|
|
* we use dynamic import + any casting for type safety.
|
|
*/
|
|
private async fetchViaEws(
|
|
source: {
|
|
url: string;
|
|
username?: string;
|
|
password?: string;
|
|
id: string;
|
|
color?: string | null;
|
|
},
|
|
from: Date,
|
|
to: Date,
|
|
): Promise<CalendarEvent[]> {
|
|
// Dynamic import — ews-javascript-api is JS-only, no .d.ts
|
|
const ews: any = await import('ews-javascript-api');
|
|
|
|
const service = new ews.ExchangeService(ews.ExchangeVersion.Exchange2013);
|
|
service.Url = new ews.Uri(source.url);
|
|
service.Credentials = new ews.WebCredentials(
|
|
source.username || '',
|
|
source.password || '',
|
|
);
|
|
|
|
// CalendarView constructor accepts JS Dates in ews-javascript-api
|
|
const calendarView = new ews.CalendarView(from, to, 100);
|
|
const findResults = await service.FindAppointments(
|
|
ews.WellKnownFolderName.Calendar,
|
|
calendarView,
|
|
);
|
|
|
|
const events: CalendarEvent[] = [];
|
|
|
|
for (const appointment of findResults.Items) {
|
|
events.push({
|
|
id: `${source.id}-${appointment.Id?.UniqueId || String(Date.now())}`,
|
|
sourceId: source.id,
|
|
title: appointment.Subject || 'Untitled',
|
|
start: appointment.Start ? new Date(String(appointment.Start)) : new Date(),
|
|
end: appointment.End ? new Date(String(appointment.End)) : new Date(),
|
|
allDay: appointment.IsAllDayEvent || false,
|
|
location: appointment.Location || undefined,
|
|
description: undefined, // Body requires separate load call
|
|
color: source.color ?? undefined,
|
|
});
|
|
}
|
|
|
|
return events;
|
|
}
|
|
|
|
/**
|
|
* 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 by binding to the calendar folder.
|
|
*/
|
|
private async testEwsConnection(
|
|
source: { url: string; username?: string; password?: string },
|
|
): Promise<boolean> {
|
|
const ews: any = await import('ews-javascript-api');
|
|
|
|
const service = new ews.ExchangeService(ews.ExchangeVersion.Exchange2013);
|
|
service.Url = new ews.Uri(source.url);
|
|
service.Credentials = new ews.WebCredentials(
|
|
source.username || '',
|
|
source.password || '',
|
|
);
|
|
|
|
await ews.Folder.Bind(service, ews.WellKnownFolderName.Calendar);
|
|
return true;
|
|
}
|
|
}
|