n8n-nodes-onedrive-business 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +452 -0
- package/dist/credentials/OneDriveBusinessOAuth2.credentials.d.ts +25 -0
- package/dist/credentials/OneDriveBusinessOAuth2.credentials.js +114 -0
- package/dist/nodes/OneDriveBusiness.node.d.ts +35 -0
- package/dist/nodes/OneDriveBusiness.node.js +661 -0
- package/dist/nodes/OneDriveBusinessTrigger.node.d.ts +63 -0
- package/dist/nodes/OneDriveBusinessTrigger.node.js +332 -0
- package/dist/onedrive.svg +1 -0
- package/dist/types.d.ts +218 -0
- package/dist/types.js +8 -0
- package/dist/utils/DeltaProcessor.d.ts +91 -0
- package/dist/utils/DeltaProcessor.js +267 -0
- package/dist/utils/GraphClient.d.ts +67 -0
- package/dist/utils/GraphClient.js +182 -0
- package/dist/utils/StateStore.d.ts +96 -0
- package/dist/utils/StateStore.js +280 -0
- package/dist/utils/WebhookHandler.d.ts +78 -0
- package/dist/utils/WebhookHandler.js +220 -0
- package/dist/utils/helpers.d.ts +113 -0
- package/dist/utils/helpers.js +250 -0
- package/package.json +56 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.StateStore = void 0;
|
|
4
|
+
const fs_1 = require("fs");
|
|
5
|
+
const path_1 = require("path");
|
|
6
|
+
/**
|
|
7
|
+
* State Store for OneDrive Business Trigger Node
|
|
8
|
+
*
|
|
9
|
+
* Provides persistent storage for:
|
|
10
|
+
* - Delta query links
|
|
11
|
+
* - Processed file versions (deduplication)
|
|
12
|
+
* - Last known item states (for event classification)
|
|
13
|
+
* - Webhook subscription information
|
|
14
|
+
*
|
|
15
|
+
* State MUST survive n8n restarts and redeployments to prevent:
|
|
16
|
+
* - Duplicate workflow executions
|
|
17
|
+
* - Re-processing old changes
|
|
18
|
+
* - Losing track of processed items
|
|
19
|
+
*/
|
|
20
|
+
class StateStore {
|
|
21
|
+
constructor(nodeId, storageDir = '.n8n-state') {
|
|
22
|
+
this.state = null;
|
|
23
|
+
this.saveDebounceTimer = null;
|
|
24
|
+
this.nodeId = nodeId;
|
|
25
|
+
// Store state in .n8n-state directory within the n8n user directory
|
|
26
|
+
// This ensures persistence across restarts
|
|
27
|
+
const userHome = process.env.N8N_USER_FOLDER || process.env.HOME || process.env.USERPROFILE || '/tmp';
|
|
28
|
+
const stateDir = (0, path_1.join)(userHome, storageDir, 'onedrive-business');
|
|
29
|
+
this.stateFilePath = (0, path_1.join)(stateDir, `${nodeId}.json`);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Initialize and load state from disk
|
|
33
|
+
* Creates default state if file doesn't exist
|
|
34
|
+
*/
|
|
35
|
+
async initialize(tenantId, driveId, userId, siteId) {
|
|
36
|
+
try {
|
|
37
|
+
await this.ensureStateDirectory();
|
|
38
|
+
// Try to load existing state
|
|
39
|
+
const existingState = await this.loadFromDisk();
|
|
40
|
+
if (existingState) {
|
|
41
|
+
// Validate that the state matches the current configuration
|
|
42
|
+
if (existingState.tenantId === tenantId &&
|
|
43
|
+
existingState.driveId === driveId) {
|
|
44
|
+
this.state = existingState;
|
|
45
|
+
return this.state;
|
|
46
|
+
}
|
|
47
|
+
// Configuration changed, reset state
|
|
48
|
+
console.log(`OneDrive Business state configuration changed for node ${this.nodeId}, resetting state`);
|
|
49
|
+
}
|
|
50
|
+
// Create fresh state
|
|
51
|
+
this.state = this.createDefaultState(tenantId, driveId, userId, siteId);
|
|
52
|
+
await this.saveToDisk();
|
|
53
|
+
return this.state;
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
console.error(`Failed to initialize state for node ${this.nodeId}:`, error);
|
|
57
|
+
// Return default state even if loading fails
|
|
58
|
+
this.state = this.createDefaultState(tenantId, driveId, userId, siteId);
|
|
59
|
+
return this.state;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Get current state
|
|
64
|
+
* Throws if not initialized
|
|
65
|
+
*/
|
|
66
|
+
getState() {
|
|
67
|
+
if (!this.state) {
|
|
68
|
+
throw new Error('State not initialized. Call initialize() first.');
|
|
69
|
+
}
|
|
70
|
+
return this.state;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Update delta link
|
|
74
|
+
*/
|
|
75
|
+
async updateDeltaLink(deltaLink) {
|
|
76
|
+
if (!this.state) {
|
|
77
|
+
throw new Error('State not initialized');
|
|
78
|
+
}
|
|
79
|
+
this.state.deltaLink = deltaLink;
|
|
80
|
+
this.state.lastDeltaQuery = Date.now();
|
|
81
|
+
await this.saveToDiskDebounced();
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Mark a file version as processed
|
|
85
|
+
* Returns true if this version was NOT already processed
|
|
86
|
+
*/
|
|
87
|
+
async markVersionProcessed(itemId, eTag) {
|
|
88
|
+
if (!this.state) {
|
|
89
|
+
throw new Error('State not initialized');
|
|
90
|
+
}
|
|
91
|
+
const versionKey = `${itemId}_${eTag}`;
|
|
92
|
+
// Check if already processed
|
|
93
|
+
if (this.state.processedVersions[versionKey]) {
|
|
94
|
+
return false; // Already processed
|
|
95
|
+
}
|
|
96
|
+
// Mark as processed
|
|
97
|
+
this.state.processedVersions[versionKey] = true;
|
|
98
|
+
// Save asynchronously
|
|
99
|
+
await this.saveToDiskDebounced();
|
|
100
|
+
return true; // Newly processed
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Check if a version has been processed
|
|
104
|
+
*/
|
|
105
|
+
isVersionProcessed(itemId, eTag) {
|
|
106
|
+
if (!this.state) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
const versionKey = `${itemId}_${eTag}`;
|
|
110
|
+
return !!this.state.processedVersions[versionKey];
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Update last known state of an item
|
|
114
|
+
* Used for event classification (created vs updated)
|
|
115
|
+
*/
|
|
116
|
+
async updateLastKnownItem(itemId, eTag, lastModifiedDateTime) {
|
|
117
|
+
if (!this.state) {
|
|
118
|
+
throw new Error('State not initialized');
|
|
119
|
+
}
|
|
120
|
+
this.state.lastKnownItems[itemId] = {
|
|
121
|
+
eTag,
|
|
122
|
+
lastModifiedDateTime,
|
|
123
|
+
};
|
|
124
|
+
await this.saveToDiskDebounced();
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Get last known state of an item
|
|
128
|
+
*/
|
|
129
|
+
getLastKnownItem(itemId) {
|
|
130
|
+
if (!this.state) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
return this.state.lastKnownItems[itemId] || null;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Update webhook subscription information
|
|
137
|
+
*/
|
|
138
|
+
async updateSubscription(subscriptionId, expirationTimestamp) {
|
|
139
|
+
if (!this.state) {
|
|
140
|
+
throw new Error('State not initialized');
|
|
141
|
+
}
|
|
142
|
+
this.state.subscriptionId = subscriptionId;
|
|
143
|
+
this.state.subscriptionExpiration = expirationTimestamp;
|
|
144
|
+
await this.saveToDiskDebounced();
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Clear webhook subscription information
|
|
148
|
+
*/
|
|
149
|
+
async clearSubscription() {
|
|
150
|
+
if (!this.state) {
|
|
151
|
+
throw new Error('State not initialized');
|
|
152
|
+
}
|
|
153
|
+
this.state.subscriptionId = undefined;
|
|
154
|
+
this.state.subscriptionExpiration = undefined;
|
|
155
|
+
await this.saveToDiskDebounced();
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Clean up old processed versions to prevent unbounded growth
|
|
159
|
+
* Keeps only the most recent versions per item
|
|
160
|
+
*/
|
|
161
|
+
async cleanupOldVersions(maxVersionsPerItem = 10) {
|
|
162
|
+
if (!this.state) {
|
|
163
|
+
throw new Error('State not initialized');
|
|
164
|
+
}
|
|
165
|
+
// Group versions by itemId
|
|
166
|
+
const itemVersions = {};
|
|
167
|
+
for (const versionKey of Object.keys(this.state.processedVersions)) {
|
|
168
|
+
const [itemId] = versionKey.split('_');
|
|
169
|
+
if (!itemVersions[itemId]) {
|
|
170
|
+
itemVersions[itemId] = [];
|
|
171
|
+
}
|
|
172
|
+
itemVersions[itemId].push(versionKey);
|
|
173
|
+
}
|
|
174
|
+
// Keep only recent versions
|
|
175
|
+
const cleaned = {};
|
|
176
|
+
for (const itemId of Object.keys(itemVersions)) {
|
|
177
|
+
const versions = itemVersions[itemId];
|
|
178
|
+
// Sort by eTag (not perfect, but good enough)
|
|
179
|
+
// In production, you might want to use timestamps
|
|
180
|
+
versions.sort();
|
|
181
|
+
// Keep the last N versions
|
|
182
|
+
const toKeep = versions.slice(-maxVersionsPerItem);
|
|
183
|
+
for (const versionKey of toKeep) {
|
|
184
|
+
cleaned[versionKey] = true;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
this.state.processedVersions = cleaned;
|
|
188
|
+
await this.saveToDisk();
|
|
189
|
+
console.log(`Cleaned up old versions for node ${this.nodeId}. Kept ${Object.keys(cleaned).length} versions.`);
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Create default state
|
|
193
|
+
*/
|
|
194
|
+
createDefaultState(tenantId, driveId, userId, siteId) {
|
|
195
|
+
return {
|
|
196
|
+
tenantId,
|
|
197
|
+
driveId,
|
|
198
|
+
userId,
|
|
199
|
+
siteId,
|
|
200
|
+
deltaLink: null,
|
|
201
|
+
lastDeltaQuery: 0,
|
|
202
|
+
processedVersions: {},
|
|
203
|
+
lastKnownItems: {},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Ensure state directory exists
|
|
208
|
+
*/
|
|
209
|
+
async ensureStateDirectory() {
|
|
210
|
+
const dir = this.stateFilePath.substring(0, this.stateFilePath.lastIndexOf('/'));
|
|
211
|
+
try {
|
|
212
|
+
await fs_1.promises.mkdir(dir, { recursive: true });
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
if (error.code !== 'EEXIST') {
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Load state from disk
|
|
222
|
+
*/
|
|
223
|
+
async loadFromDisk() {
|
|
224
|
+
try {
|
|
225
|
+
const data = await fs_1.promises.readFile(this.stateFilePath, 'utf-8');
|
|
226
|
+
return JSON.parse(data);
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
if (error.code === 'ENOENT') {
|
|
230
|
+
// File doesn't exist yet
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Save state to disk immediately
|
|
238
|
+
*/
|
|
239
|
+
async saveToDisk() {
|
|
240
|
+
if (!this.state) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
await this.ensureStateDirectory();
|
|
245
|
+
const data = JSON.stringify(this.state, null, 2);
|
|
246
|
+
await fs_1.promises.writeFile(this.stateFilePath, data, 'utf-8');
|
|
247
|
+
}
|
|
248
|
+
catch (error) {
|
|
249
|
+
console.error(`Failed to save state for node ${this.nodeId}:`, error);
|
|
250
|
+
// Don't throw - state is cached in memory
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Save state to disk with debouncing
|
|
255
|
+
* Prevents excessive writes during rapid updates
|
|
256
|
+
*/
|
|
257
|
+
async saveToDiskDebounced(debounceMs = 1000) {
|
|
258
|
+
// Clear existing timer
|
|
259
|
+
if (this.saveDebounceTimer) {
|
|
260
|
+
clearTimeout(this.saveDebounceTimer);
|
|
261
|
+
}
|
|
262
|
+
// Set new timer
|
|
263
|
+
this.saveDebounceTimer = setTimeout(async () => {
|
|
264
|
+
await this.saveToDisk();
|
|
265
|
+
this.saveDebounceTimer = null;
|
|
266
|
+
}, debounceMs);
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Force save any pending changes
|
|
270
|
+
* Call this before deactivating the node
|
|
271
|
+
*/
|
|
272
|
+
async flush() {
|
|
273
|
+
if (this.saveDebounceTimer) {
|
|
274
|
+
clearTimeout(this.saveDebounceTimer);
|
|
275
|
+
this.saveDebounceTimer = null;
|
|
276
|
+
}
|
|
277
|
+
await this.saveToDisk();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
exports.StateStore = StateStore;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
/// <reference types="node/http" />
|
|
3
|
+
/// <reference types="n8n-workflow" />
|
|
4
|
+
import type { IncomingMessage, ServerResponse } from 'http';
|
|
5
|
+
import type { GraphSubscription } from '../types';
|
|
6
|
+
import { GraphClient } from './GraphClient';
|
|
7
|
+
import { StateStore } from './StateStore';
|
|
8
|
+
/**
|
|
9
|
+
* Webhook Handler for OneDrive Business
|
|
10
|
+
*
|
|
11
|
+
* Manages Microsoft Graph webhook subscriptions lifecycle:
|
|
12
|
+
* 1. Creates subscription on node activation
|
|
13
|
+
* 2. Handles validation token handshake
|
|
14
|
+
* 3. Auto-renews subscription before expiration
|
|
15
|
+
* 4. Deletes subscription on node deactivation
|
|
16
|
+
*
|
|
17
|
+
* CRITICAL ARCHITECTURE DECISION:
|
|
18
|
+
* The webhook does NOT emit workflow data directly.
|
|
19
|
+
* It only acts as a NOTIFICATION that changes occurred.
|
|
20
|
+
* The actual data is fetched via Delta Query by DeltaProcessor.
|
|
21
|
+
*
|
|
22
|
+
* WHY:
|
|
23
|
+
* - Webhook notifications don't contain full file metadata
|
|
24
|
+
* - Multiple notifications may come for one upload
|
|
25
|
+
* - Delta Query provides the single source of truth
|
|
26
|
+
* - Deduplication happens in DeltaProcessor, not here
|
|
27
|
+
*/
|
|
28
|
+
export declare class WebhookHandler {
|
|
29
|
+
private graphClient;
|
|
30
|
+
private stateStore;
|
|
31
|
+
private subscriptionResource;
|
|
32
|
+
private clientState;
|
|
33
|
+
private readonly RENEWAL_BUFFER_MS;
|
|
34
|
+
private readonly SUBSCRIPTION_DURATION_DAYS;
|
|
35
|
+
constructor(graphClient: GraphClient, stateStore: StateStore, subscriptionResource: string);
|
|
36
|
+
/**
|
|
37
|
+
* Create or renew webhook subscription
|
|
38
|
+
* Called when trigger node is activated
|
|
39
|
+
*/
|
|
40
|
+
createOrRenewSubscription(notificationUrl: string): Promise<GraphSubscription>;
|
|
41
|
+
/**
|
|
42
|
+
* Create a new webhook subscription
|
|
43
|
+
*/
|
|
44
|
+
private createSubscription;
|
|
45
|
+
/**
|
|
46
|
+
* Renew an existing subscription
|
|
47
|
+
*/
|
|
48
|
+
private renewSubscription;
|
|
49
|
+
/**
|
|
50
|
+
* Delete webhook subscription
|
|
51
|
+
* Called when trigger node is deactivated
|
|
52
|
+
*/
|
|
53
|
+
deleteSubscription(): Promise<void>;
|
|
54
|
+
/**
|
|
55
|
+
* Handle incoming webhook request
|
|
56
|
+
*
|
|
57
|
+
* Microsoft Graph sends two types of requests:
|
|
58
|
+
* 1. Validation request (during subscription creation)
|
|
59
|
+
* 2. Notification request (when changes occur)
|
|
60
|
+
*
|
|
61
|
+
* CRITICAL: This method returns immediately and does NOT trigger workflows.
|
|
62
|
+
* It only validates the request and returns HTTP 200.
|
|
63
|
+
*/
|
|
64
|
+
handleWebhookRequest(req: IncomingMessage, res: ServerResponse): Promise<void>;
|
|
65
|
+
/**
|
|
66
|
+
* Handle validation request from Microsoft Graph
|
|
67
|
+
* Must respond with the validation token in plain text
|
|
68
|
+
*/
|
|
69
|
+
private handleValidationRequest;
|
|
70
|
+
/**
|
|
71
|
+
* Read request body from stream
|
|
72
|
+
*/
|
|
73
|
+
private readRequestBody;
|
|
74
|
+
/**
|
|
75
|
+
* Get the client state for validation
|
|
76
|
+
*/
|
|
77
|
+
getClientState(): string;
|
|
78
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.WebhookHandler = void 0;
|
|
4
|
+
const helpers_1 = require("./helpers");
|
|
5
|
+
/**
|
|
6
|
+
* Webhook Handler for OneDrive Business
|
|
7
|
+
*
|
|
8
|
+
* Manages Microsoft Graph webhook subscriptions lifecycle:
|
|
9
|
+
* 1. Creates subscription on node activation
|
|
10
|
+
* 2. Handles validation token handshake
|
|
11
|
+
* 3. Auto-renews subscription before expiration
|
|
12
|
+
* 4. Deletes subscription on node deactivation
|
|
13
|
+
*
|
|
14
|
+
* CRITICAL ARCHITECTURE DECISION:
|
|
15
|
+
* The webhook does NOT emit workflow data directly.
|
|
16
|
+
* It only acts as a NOTIFICATION that changes occurred.
|
|
17
|
+
* The actual data is fetched via Delta Query by DeltaProcessor.
|
|
18
|
+
*
|
|
19
|
+
* WHY:
|
|
20
|
+
* - Webhook notifications don't contain full file metadata
|
|
21
|
+
* - Multiple notifications may come for one upload
|
|
22
|
+
* - Delta Query provides the single source of truth
|
|
23
|
+
* - Deduplication happens in DeltaProcessor, not here
|
|
24
|
+
*/
|
|
25
|
+
class WebhookHandler {
|
|
26
|
+
constructor(graphClient, stateStore, subscriptionResource) {
|
|
27
|
+
// Subscription renewal: renew 2 hours before expiration
|
|
28
|
+
this.RENEWAL_BUFFER_MS = 2 * 60 * 60 * 1000;
|
|
29
|
+
// Subscription duration: 3 days (Microsoft Graph maximum)
|
|
30
|
+
this.SUBSCRIPTION_DURATION_DAYS = 3;
|
|
31
|
+
this.graphClient = graphClient;
|
|
32
|
+
this.stateStore = stateStore;
|
|
33
|
+
this.subscriptionResource = subscriptionResource;
|
|
34
|
+
// Generate unique client state for security validation
|
|
35
|
+
this.clientState = (0, helpers_1.generateSecureRandomString)(32);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Create or renew webhook subscription
|
|
39
|
+
* Called when trigger node is activated
|
|
40
|
+
*/
|
|
41
|
+
async createOrRenewSubscription(notificationUrl) {
|
|
42
|
+
const state = this.stateStore.getState();
|
|
43
|
+
// Check if we have an existing subscription
|
|
44
|
+
if (state.subscriptionId && state.subscriptionExpiration) {
|
|
45
|
+
const now = Date.now();
|
|
46
|
+
const expirationTime = state.subscriptionExpiration;
|
|
47
|
+
// If subscription is still valid for more than RENEWAL_BUFFER, reuse it
|
|
48
|
+
if (expirationTime - now > this.RENEWAL_BUFFER_MS) {
|
|
49
|
+
console.log(`Reusing existing subscription ${state.subscriptionId}`);
|
|
50
|
+
// Fetch subscription details
|
|
51
|
+
try {
|
|
52
|
+
const subscription = await this.graphClient.get(`/subscriptions/${state.subscriptionId}`);
|
|
53
|
+
return subscription;
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
console.log('Existing subscription not found, creating new one');
|
|
57
|
+
// Subscription doesn't exist, create new one
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
// Subscription is expiring soon, renew it
|
|
62
|
+
console.log(`Renewing subscription ${state.subscriptionId}`);
|
|
63
|
+
try {
|
|
64
|
+
const renewed = await this.renewSubscription(state.subscriptionId);
|
|
65
|
+
return renewed;
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
console.log('Failed to renew subscription, creating new one');
|
|
69
|
+
// Renewal failed, create new one
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Create new subscription
|
|
74
|
+
return await this.createSubscription(notificationUrl);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Create a new webhook subscription
|
|
78
|
+
*/
|
|
79
|
+
async createSubscription(notificationUrl) {
|
|
80
|
+
const expirationDateTime = new Date();
|
|
81
|
+
expirationDateTime.setDate(expirationDateTime.getDate() + this.SUBSCRIPTION_DURATION_DAYS);
|
|
82
|
+
const subscriptionRequest = {
|
|
83
|
+
changeType: 'created,updated',
|
|
84
|
+
notificationUrl,
|
|
85
|
+
resource: this.subscriptionResource,
|
|
86
|
+
expirationDateTime: expirationDateTime.toISOString(),
|
|
87
|
+
clientState: this.clientState,
|
|
88
|
+
};
|
|
89
|
+
console.log('Creating Microsoft Graph subscription:', {
|
|
90
|
+
resource: this.subscriptionResource,
|
|
91
|
+
notificationUrl,
|
|
92
|
+
expirationDateTime: expirationDateTime.toISOString(),
|
|
93
|
+
});
|
|
94
|
+
const subscription = await this.graphClient.post('/subscriptions', subscriptionRequest);
|
|
95
|
+
// Store subscription info
|
|
96
|
+
await this.stateStore.updateSubscription(subscription.id, new Date(subscription.expirationDateTime).getTime());
|
|
97
|
+
console.log(`Created subscription ${subscription.id}`);
|
|
98
|
+
return subscription;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Renew an existing subscription
|
|
102
|
+
*/
|
|
103
|
+
async renewSubscription(subscriptionId) {
|
|
104
|
+
const expirationDateTime = new Date();
|
|
105
|
+
expirationDateTime.setDate(expirationDateTime.getDate() + this.SUBSCRIPTION_DURATION_DAYS);
|
|
106
|
+
const subscription = await this.graphClient.patch(`/subscriptions/${subscriptionId}`, {
|
|
107
|
+
expirationDateTime: expirationDateTime.toISOString(),
|
|
108
|
+
});
|
|
109
|
+
// Update stored expiration
|
|
110
|
+
await this.stateStore.updateSubscription(subscription.id, new Date(subscription.expirationDateTime).getTime());
|
|
111
|
+
console.log(`Renewed subscription ${subscriptionId}`);
|
|
112
|
+
return subscription;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Delete webhook subscription
|
|
116
|
+
* Called when trigger node is deactivated
|
|
117
|
+
*/
|
|
118
|
+
async deleteSubscription() {
|
|
119
|
+
const state = this.stateStore.getState();
|
|
120
|
+
if (!state.subscriptionId) {
|
|
121
|
+
console.log('No subscription to delete');
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
await this.graphClient.delete(`/subscriptions/${state.subscriptionId}`);
|
|
126
|
+
console.log(`Deleted subscription ${state.subscriptionId}`);
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
// If subscription doesn't exist (404), that's fine
|
|
130
|
+
if (error.statusCode !== 404) {
|
|
131
|
+
console.error('Failed to delete subscription:', error);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// Clear subscription from state
|
|
135
|
+
await this.stateStore.clearSubscription();
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Handle incoming webhook request
|
|
139
|
+
*
|
|
140
|
+
* Microsoft Graph sends two types of requests:
|
|
141
|
+
* 1. Validation request (during subscription creation)
|
|
142
|
+
* 2. Notification request (when changes occur)
|
|
143
|
+
*
|
|
144
|
+
* CRITICAL: This method returns immediately and does NOT trigger workflows.
|
|
145
|
+
* It only validates the request and returns HTTP 200.
|
|
146
|
+
*/
|
|
147
|
+
async handleWebhookRequest(req, res) {
|
|
148
|
+
var _a;
|
|
149
|
+
// Handle validation request
|
|
150
|
+
const validationToken = (_a = req.query) === null || _a === void 0 ? void 0 : _a.validationToken;
|
|
151
|
+
if (validationToken) {
|
|
152
|
+
console.log('Received webhook validation request');
|
|
153
|
+
this.handleValidationRequest(res, validationToken);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
// Handle notification request
|
|
157
|
+
console.log('Received webhook notification');
|
|
158
|
+
try {
|
|
159
|
+
// Read body
|
|
160
|
+
const body = await this.readRequestBody(req);
|
|
161
|
+
const notification = JSON.parse(body);
|
|
162
|
+
// Validate client state
|
|
163
|
+
if (notification.value && notification.value.length > 0) {
|
|
164
|
+
const firstNotification = notification.value[0];
|
|
165
|
+
if (firstNotification.clientState !== this.clientState) {
|
|
166
|
+
console.error('Invalid clientState in webhook notification');
|
|
167
|
+
res.writeHead(401);
|
|
168
|
+
res.end();
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// Return 200 OK immediately
|
|
173
|
+
// DO NOT trigger workflow here
|
|
174
|
+
// The trigger node will poll delta query separately
|
|
175
|
+
res.writeHead(200);
|
|
176
|
+
res.end();
|
|
177
|
+
console.log('Webhook notification acknowledged');
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
console.error('Error handling webhook notification:', error);
|
|
181
|
+
res.writeHead(500);
|
|
182
|
+
res.end();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Handle validation request from Microsoft Graph
|
|
187
|
+
* Must respond with the validation token in plain text
|
|
188
|
+
*/
|
|
189
|
+
handleValidationRequest(res, validationToken) {
|
|
190
|
+
res.writeHead(200, {
|
|
191
|
+
'Content-Type': 'text/plain',
|
|
192
|
+
});
|
|
193
|
+
res.end(validationToken);
|
|
194
|
+
console.log('Webhook validation successful');
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Read request body from stream
|
|
198
|
+
*/
|
|
199
|
+
readRequestBody(req) {
|
|
200
|
+
return new Promise((resolve, reject) => {
|
|
201
|
+
let body = '';
|
|
202
|
+
req.on('data', (chunk) => {
|
|
203
|
+
body += chunk.toString();
|
|
204
|
+
});
|
|
205
|
+
req.on('end', () => {
|
|
206
|
+
resolve(body);
|
|
207
|
+
});
|
|
208
|
+
req.on('error', (error) => {
|
|
209
|
+
reject(error);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Get the client state for validation
|
|
215
|
+
*/
|
|
216
|
+
getClientState() {
|
|
217
|
+
return this.clientState;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
exports.WebhookHandler = WebhookHandler;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { DriveLocation } from '../types';
|
|
2
|
+
/**
|
|
3
|
+
* Helper Functions for OneDrive Business Node
|
|
4
|
+
*
|
|
5
|
+
* These utilities handle common operations like drive resolution,
|
|
6
|
+
* path sanitization, and binary data handling.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Resolve the drive path for Microsoft Graph API
|
|
10
|
+
*
|
|
11
|
+
* OneDrive Business drives are accessed through:
|
|
12
|
+
* - User context: /users/{userId}/drive
|
|
13
|
+
* - Site context: /sites/{siteId}/drive
|
|
14
|
+
*
|
|
15
|
+
* NEVER use /me/drive without tenant context
|
|
16
|
+
*/
|
|
17
|
+
export declare function resolveDrivePath(location: DriveLocation): string;
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the subscription resource for webhooks
|
|
20
|
+
*
|
|
21
|
+
* For delta queries and webhooks, we use:
|
|
22
|
+
* - User context: /users/{userId}/drive/root
|
|
23
|
+
* - Site context: /sites/{siteId}/drive
|
|
24
|
+
*/
|
|
25
|
+
export declare function resolveSubscriptionResource(location: DriveLocation): string;
|
|
26
|
+
/**
|
|
27
|
+
* Sanitize file or folder path for OneDrive API
|
|
28
|
+
*
|
|
29
|
+
* - Removes leading/trailing slashes
|
|
30
|
+
* - Converts backslashes to forward slashes
|
|
31
|
+
* - Encodes special characters
|
|
32
|
+
*/
|
|
33
|
+
export declare function sanitizePath(path: string): string;
|
|
34
|
+
/**
|
|
35
|
+
* Build the full item path for OneDrive API
|
|
36
|
+
*
|
|
37
|
+
* Constructs paths like:
|
|
38
|
+
* - /drive/root:/Documents/file.pdf
|
|
39
|
+
* - /drive/items/{itemId}
|
|
40
|
+
*/
|
|
41
|
+
export declare function buildItemPath(drivePath: string, itemPath?: string, itemId?: string): string;
|
|
42
|
+
/**
|
|
43
|
+
* Extract file extension from filename
|
|
44
|
+
*/
|
|
45
|
+
export declare function getFileExtension(filename: string): string;
|
|
46
|
+
/**
|
|
47
|
+
* Format bytes to human-readable size
|
|
48
|
+
*/
|
|
49
|
+
export declare function formatBytes(bytes: number): string;
|
|
50
|
+
/**
|
|
51
|
+
* Calculate exponential backoff delay for retries
|
|
52
|
+
*
|
|
53
|
+
* Used for throttling (429) and transient errors
|
|
54
|
+
*/
|
|
55
|
+
export declare function calculateBackoffDelay(retryCount: number, initialDelayMs?: number, maxDelayMs?: number, backoffMultiplier?: number): number;
|
|
56
|
+
/**
|
|
57
|
+
* Sleep for specified milliseconds
|
|
58
|
+
* Used in retry logic
|
|
59
|
+
*/
|
|
60
|
+
export declare function sleep(ms: number): Promise<void>;
|
|
61
|
+
/**
|
|
62
|
+
* Parse Retry-After header from 429 responses
|
|
63
|
+
* Returns delay in milliseconds
|
|
64
|
+
*/
|
|
65
|
+
export declare function parseRetryAfter(retryAfterHeader?: string): number;
|
|
66
|
+
/**
|
|
67
|
+
* Generate a cryptographically secure random string
|
|
68
|
+
* Used for clientState in webhook subscriptions
|
|
69
|
+
*/
|
|
70
|
+
export declare function generateSecureRandomString(length?: number): string;
|
|
71
|
+
/**
|
|
72
|
+
* Validate Microsoft Graph item ID format
|
|
73
|
+
*/
|
|
74
|
+
export declare function isValidItemId(itemId: string): boolean;
|
|
75
|
+
/**
|
|
76
|
+
* Check if an item is a file (vs folder)
|
|
77
|
+
*/
|
|
78
|
+
export declare function isFile(item: {
|
|
79
|
+
file?: any;
|
|
80
|
+
folder?: any;
|
|
81
|
+
}): boolean;
|
|
82
|
+
/**
|
|
83
|
+
* Check if an item is a folder (vs file)
|
|
84
|
+
*/
|
|
85
|
+
export declare function isFolder(item: {
|
|
86
|
+
file?: any;
|
|
87
|
+
folder?: any;
|
|
88
|
+
}): boolean;
|
|
89
|
+
/**
|
|
90
|
+
* Extract MIME type from DriveItem
|
|
91
|
+
*/
|
|
92
|
+
export declare function getMimeType(item: {
|
|
93
|
+
file?: {
|
|
94
|
+
mimeType?: string;
|
|
95
|
+
};
|
|
96
|
+
}): string;
|
|
97
|
+
/**
|
|
98
|
+
* Build a version key for deduplication
|
|
99
|
+
* Format: ${itemId}_${eTag}
|
|
100
|
+
*/
|
|
101
|
+
export declare function buildVersionKey(itemId: string, eTag: string): string;
|
|
102
|
+
/**
|
|
103
|
+
* Clean up old processed versions to prevent memory bloat
|
|
104
|
+
* Keeps only the most recent maxVersions per item
|
|
105
|
+
*/
|
|
106
|
+
export declare function cleanupProcessedVersions(processedVersions: Record<string, boolean>, maxVersionsPerItem?: number): Record<string, boolean>;
|
|
107
|
+
/**
|
|
108
|
+
* Parse Graph API error response
|
|
109
|
+
*/
|
|
110
|
+
export declare function parseGraphError(error: any): {
|
|
111
|
+
code: string;
|
|
112
|
+
message: string;
|
|
113
|
+
};
|