m365-agent-cli 1.2.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/LICENSE +22 -0
- package/README.md +916 -0
- package/package.json +50 -0
- package/src/cli.ts +100 -0
- package/src/commands/auto-reply.ts +182 -0
- package/src/commands/calendar.ts +576 -0
- package/src/commands/counter.ts +87 -0
- package/src/commands/create-event.ts +544 -0
- package/src/commands/delegates.ts +286 -0
- package/src/commands/delete-event.ts +321 -0
- package/src/commands/drafts.ts +502 -0
- package/src/commands/files.ts +532 -0
- package/src/commands/find.ts +195 -0
- package/src/commands/findtime.ts +270 -0
- package/src/commands/folders.ts +177 -0
- package/src/commands/forward-event.ts +49 -0
- package/src/commands/graph-calendar.ts +217 -0
- package/src/commands/login.ts +195 -0
- package/src/commands/mail.ts +950 -0
- package/src/commands/oof.ts +263 -0
- package/src/commands/outlook-categories.ts +173 -0
- package/src/commands/outlook-graph.ts +880 -0
- package/src/commands/planner.ts +1678 -0
- package/src/commands/respond.ts +291 -0
- package/src/commands/rooms.ts +210 -0
- package/src/commands/rules.ts +511 -0
- package/src/commands/schedule.ts +109 -0
- package/src/commands/send.ts +204 -0
- package/src/commands/serve.ts +14 -0
- package/src/commands/sharepoint.ts +179 -0
- package/src/commands/site-pages.ts +163 -0
- package/src/commands/subscribe.ts +103 -0
- package/src/commands/subscriptions.ts +29 -0
- package/src/commands/suggest.ts +155 -0
- package/src/commands/todo.ts +2092 -0
- package/src/commands/update-event.ts +608 -0
- package/src/commands/update.ts +88 -0
- package/src/commands/verify-token.ts +62 -0
- package/src/commands/whoami.ts +74 -0
- package/src/index.ts +190 -0
- package/src/lib/atomic-write.ts +20 -0
- package/src/lib/attach-link-spec.test.ts +24 -0
- package/src/lib/attach-link-spec.ts +70 -0
- package/src/lib/attachments.ts +79 -0
- package/src/lib/auth.ts +192 -0
- package/src/lib/calendar-range.test.ts +41 -0
- package/src/lib/calendar-range.ts +103 -0
- package/src/lib/dates.test.ts +74 -0
- package/src/lib/dates.ts +137 -0
- package/src/lib/delegate-client.test.ts +74 -0
- package/src/lib/delegate-client.ts +322 -0
- package/src/lib/ews-client.ts +3418 -0
- package/src/lib/git-commit.ts +4 -0
- package/src/lib/glitchtip-eligibility.ts +220 -0
- package/src/lib/glitchtip.ts +253 -0
- package/src/lib/global-env.ts +3 -0
- package/src/lib/graph-auth.ts +223 -0
- package/src/lib/graph-calendar-client.test.ts +118 -0
- package/src/lib/graph-calendar-client.ts +112 -0
- package/src/lib/graph-client.test.ts +107 -0
- package/src/lib/graph-client.ts +1058 -0
- package/src/lib/graph-constants.ts +12 -0
- package/src/lib/graph-directory.ts +116 -0
- package/src/lib/graph-event.ts +134 -0
- package/src/lib/graph-schedule.ts +173 -0
- package/src/lib/graph-subscriptions.ts +94 -0
- package/src/lib/graph-user-path.ts +13 -0
- package/src/lib/jwt-utils.ts +34 -0
- package/src/lib/markdown.test.ts +21 -0
- package/src/lib/markdown.ts +174 -0
- package/src/lib/mime-type.ts +106 -0
- package/src/lib/oof-client.test.ts +59 -0
- package/src/lib/oof-client.ts +122 -0
- package/src/lib/outlook-graph-client.test.ts +146 -0
- package/src/lib/outlook-graph-client.ts +649 -0
- package/src/lib/outlook-master-categories.ts +145 -0
- package/src/lib/package-info.ts +59 -0
- package/src/lib/places-client.ts +144 -0
- package/src/lib/planner-client.ts +1226 -0
- package/src/lib/rules-client.ts +178 -0
- package/src/lib/sharepoint-client.ts +101 -0
- package/src/lib/site-pages-client.ts +73 -0
- package/src/lib/todo-client.test.ts +298 -0
- package/src/lib/todo-client.ts +1309 -0
- package/src/lib/url-validation.ts +40 -0
- package/src/lib/utils.ts +45 -0
- package/src/lib/webhook-server.ts +51 -0
- package/src/test/auth.test.ts +104 -0
- package/src/test/cli.integration.test.ts +1083 -0
- package/src/test/ews-client.test.ts +268 -0
- package/src/test/mocks/index.ts +375 -0
- package/src/test/mocks/responses.ts +861 -0
|
@@ -0,0 +1,1058 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { createReadStream, createWriteStream } from 'node:fs';
|
|
3
|
+
import { mkdir, open, realpath, rename, stat, unlink } from 'node:fs/promises';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { basename, dirname, resolve } from 'node:path';
|
|
6
|
+
import { Readable } from 'node:stream';
|
|
7
|
+
import { GRAPH_BASE_URL } from './graph-constants.js';
|
|
8
|
+
|
|
9
|
+
export { GRAPH_BASE_URL };
|
|
10
|
+
|
|
11
|
+
const GRAPH_TIMEOUT_MS = Number(process.env.GRAPH_TIMEOUT_MS) || 30_000; // 30s default
|
|
12
|
+
|
|
13
|
+
export interface GraphError {
|
|
14
|
+
message: string;
|
|
15
|
+
code?: string;
|
|
16
|
+
status?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class GraphApiError extends Error {
|
|
20
|
+
constructor(
|
|
21
|
+
message: string,
|
|
22
|
+
public readonly code?: string,
|
|
23
|
+
public readonly status?: number
|
|
24
|
+
) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = 'GraphApiError';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface GraphResponse<T> {
|
|
31
|
+
ok: boolean;
|
|
32
|
+
data?: T;
|
|
33
|
+
error?: GraphError;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface DriveItemReference {
|
|
37
|
+
driveId?: string;
|
|
38
|
+
id?: string;
|
|
39
|
+
path?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface DriveItem {
|
|
43
|
+
id: string;
|
|
44
|
+
name: string;
|
|
45
|
+
webUrl?: string;
|
|
46
|
+
size?: number;
|
|
47
|
+
createdDateTime?: string;
|
|
48
|
+
lastModifiedDateTime?: string;
|
|
49
|
+
file?: { mimeType?: string };
|
|
50
|
+
folder?: { childCount?: number };
|
|
51
|
+
parentReference?: { driveId?: string; id?: string; path?: string };
|
|
52
|
+
'@microsoft.graph.downloadUrl'?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface DriveItemVersion {
|
|
56
|
+
id: string;
|
|
57
|
+
lastModifiedDateTime?: string;
|
|
58
|
+
size?: number;
|
|
59
|
+
lastModifiedBy?: { user?: { displayName?: string; email?: string } };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface DriveItemListResponse {
|
|
63
|
+
value: DriveItem[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface SharingLinkResult {
|
|
67
|
+
id?: string;
|
|
68
|
+
webUrl?: string;
|
|
69
|
+
type?: string;
|
|
70
|
+
scope?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface OfficeCollabLinkResult {
|
|
74
|
+
item: DriveItem;
|
|
75
|
+
link: SharingLinkResult;
|
|
76
|
+
collaborationUrl?: string;
|
|
77
|
+
lockAcquired: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface CheckinResult {
|
|
81
|
+
item: DriveItem;
|
|
82
|
+
checkedIn: boolean;
|
|
83
|
+
comment?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const MAX_DOWNLOAD_STREAM_BYTES = 5 * 1024 * 1024 * 1024;
|
|
87
|
+
|
|
88
|
+
/** Streams HTTPS response body to disk with a hard size cap (mitigates unbounded http-to-file writes). */
|
|
89
|
+
async function streamWebToFile(
|
|
90
|
+
body: ReadableStream<Uint8Array>,
|
|
91
|
+
filePath: string,
|
|
92
|
+
maxBytes: number = MAX_DOWNLOAD_STREAM_BYTES
|
|
93
|
+
): Promise<number> {
|
|
94
|
+
const stream = createWriteStream(filePath, { flags: 'w', mode: 0o600 });
|
|
95
|
+
let bytesWritten = 0;
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
for await (const chunk of body) {
|
|
99
|
+
if (bytesWritten + chunk.byteLength > maxBytes) {
|
|
100
|
+
stream.destroy();
|
|
101
|
+
await unlink(filePath).catch(() => {});
|
|
102
|
+
throw new Error(`Download exceeded maximum size (${maxBytes} bytes)`);
|
|
103
|
+
}
|
|
104
|
+
if (!stream.write(chunk)) {
|
|
105
|
+
await new Promise<void>((resolveDrain, rejectDrain) => {
|
|
106
|
+
const onDrain = () => {
|
|
107
|
+
stream.off('error', onError);
|
|
108
|
+
resolveDrain();
|
|
109
|
+
};
|
|
110
|
+
const onError = (err: Error) => {
|
|
111
|
+
stream.off('drain', onDrain);
|
|
112
|
+
rejectDrain(err);
|
|
113
|
+
};
|
|
114
|
+
stream.once('drain', onDrain);
|
|
115
|
+
stream.once('error', onError);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
bytesWritten += chunk.byteLength;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
await new Promise<void>((resolveClose, rejectClose) => {
|
|
122
|
+
stream.end((err?: Error | null) => {
|
|
123
|
+
if (err) rejectClose(err);
|
|
124
|
+
else resolveClose();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
return bytesWritten;
|
|
129
|
+
} catch (err) {
|
|
130
|
+
stream.destroy();
|
|
131
|
+
try {
|
|
132
|
+
await unlink(filePath);
|
|
133
|
+
} catch {}
|
|
134
|
+
throw err;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function graphResult<T>(data: T): GraphResponse<T> {
|
|
139
|
+
return { ok: true, data };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function graphError(message: string, code?: string, status?: number): GraphResponse<never> {
|
|
143
|
+
return { ok: false, error: { message, code, status } };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function resolveNextPath(nextLink: string, baseUrl: string): string {
|
|
147
|
+
try {
|
|
148
|
+
const normalizedBase = baseUrl.replace(/\/$/, '');
|
|
149
|
+
if (nextLink.startsWith(normalizedBase)) {
|
|
150
|
+
return nextLink.substring(normalizedBase.length);
|
|
151
|
+
}
|
|
152
|
+
const nextUrl = new URL(nextLink);
|
|
153
|
+
const baseUrlUrl = new URL(normalizedBase);
|
|
154
|
+
if (nextUrl.origin === baseUrlUrl.origin) {
|
|
155
|
+
const baseDir = baseUrlUrl.pathname.replace(/\/$/, '');
|
|
156
|
+
if (nextUrl.pathname.startsWith(baseDir)) {
|
|
157
|
+
return nextUrl.pathname.substring(baseDir.length) + nextUrl.search;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return '';
|
|
161
|
+
} catch {
|
|
162
|
+
return '';
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function fetchAllPages<T>(
|
|
167
|
+
token: string,
|
|
168
|
+
initialPath: string,
|
|
169
|
+
errorMessage: string,
|
|
170
|
+
baseUrl: string = GRAPH_BASE_URL,
|
|
171
|
+
requestInit?: RequestInit
|
|
172
|
+
): Promise<GraphResponse<T[]>> {
|
|
173
|
+
const items: T[] = [];
|
|
174
|
+
let path = initialPath;
|
|
175
|
+
|
|
176
|
+
while (path) {
|
|
177
|
+
let result: GraphResponse<{ value: T[]; '@odata.nextLink'?: string }>;
|
|
178
|
+
try {
|
|
179
|
+
result = await callGraphAt<{ value: T[]; '@odata.nextLink'?: string }>(baseUrl, token, path, requestInit ?? {});
|
|
180
|
+
} catch (err) {
|
|
181
|
+
if (err instanceof GraphApiError) {
|
|
182
|
+
return graphError(err.message, err.code, err.status) as GraphResponse<T[]>;
|
|
183
|
+
}
|
|
184
|
+
return graphError(err instanceof Error ? err.message : errorMessage) as GraphResponse<T[]>;
|
|
185
|
+
}
|
|
186
|
+
if (!result.ok || !result.data) {
|
|
187
|
+
return graphError(
|
|
188
|
+
result.error?.message || errorMessage,
|
|
189
|
+
result.error?.code,
|
|
190
|
+
result.error?.status
|
|
191
|
+
) as GraphResponse<T[]>;
|
|
192
|
+
}
|
|
193
|
+
items.push(...(result.data.value || []));
|
|
194
|
+
path = result.data['@odata.nextLink'] ? resolveNextPath(result.data['@odata.nextLink']!, baseUrl) : '';
|
|
195
|
+
}
|
|
196
|
+
return graphResult(items);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function fetchGraphRaw(token: string, path: string, options: RequestInit = {}): Promise<Response> {
|
|
200
|
+
// codeql[js/file-access-to-http]: Bearer token may come from the local OAuth cache; path is a Graph API path string.
|
|
201
|
+
return fetch(`${GRAPH_BASE_URL}${path}`, {
|
|
202
|
+
...options,
|
|
203
|
+
headers: {
|
|
204
|
+
Authorization: `Bearer ${token}`,
|
|
205
|
+
...(options.headers || {})
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export async function callGraphAt<T>(
|
|
211
|
+
baseUrl: string,
|
|
212
|
+
token: string,
|
|
213
|
+
path: string,
|
|
214
|
+
options: RequestInit = {},
|
|
215
|
+
expectJson: boolean = true
|
|
216
|
+
): Promise<GraphResponse<T>> {
|
|
217
|
+
const controller = new AbortController();
|
|
218
|
+
const timeout = setTimeout(() => controller.abort(), GRAPH_TIMEOUT_MS);
|
|
219
|
+
|
|
220
|
+
let response: Response;
|
|
221
|
+
try {
|
|
222
|
+
// codeql[js/file-access-to-http]: Bearer token may come from the local OAuth cache; request body is JSON or binary from callers.
|
|
223
|
+
response = await fetch(`${baseUrl.replace(/\/$/, '')}${path}`, {
|
|
224
|
+
...options,
|
|
225
|
+
headers: {
|
|
226
|
+
Authorization: `Bearer ${token}`,
|
|
227
|
+
...(expectJson ? { Accept: 'application/json' } : {}),
|
|
228
|
+
...(options.body && !(options.body instanceof Uint8Array) && !(options.body instanceof ArrayBuffer)
|
|
229
|
+
? { 'Content-Type': 'application/json' }
|
|
230
|
+
: {}),
|
|
231
|
+
...(options.headers || {})
|
|
232
|
+
},
|
|
233
|
+
signal: controller.signal
|
|
234
|
+
});
|
|
235
|
+
} catch (err) {
|
|
236
|
+
clearTimeout(timeout);
|
|
237
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
238
|
+
throw new GraphApiError(`Graph request timed out after ${GRAPH_TIMEOUT_MS / 1000}s`, undefined, 408);
|
|
239
|
+
}
|
|
240
|
+
throw new GraphApiError(err instanceof Error ? err.message : 'Graph request failed');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (!response.ok) {
|
|
244
|
+
let message = `Graph request failed: HTTP ${response.status}`;
|
|
245
|
+
let code: string | undefined;
|
|
246
|
+
try {
|
|
247
|
+
const json = (await response.json()) as { error?: { code?: string; message?: string } };
|
|
248
|
+
message = json.error?.message || message;
|
|
249
|
+
code = json.error?.code;
|
|
250
|
+
} catch {
|
|
251
|
+
clearTimeout(timeout);
|
|
252
|
+
throw new GraphApiError(message, code, response.status);
|
|
253
|
+
}
|
|
254
|
+
clearTimeout(timeout);
|
|
255
|
+
throw new GraphApiError(message, code, response.status);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (!expectJson || response.status === 204) {
|
|
259
|
+
clearTimeout(timeout);
|
|
260
|
+
return graphResult(undefined as T);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const result = await response.json();
|
|
264
|
+
clearTimeout(timeout);
|
|
265
|
+
return graphResult(result as T);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export async function callGraph<T>(
|
|
269
|
+
token: string,
|
|
270
|
+
path: string,
|
|
271
|
+
options: RequestInit = {},
|
|
272
|
+
expectJson: boolean = true
|
|
273
|
+
): Promise<GraphResponse<T>> {
|
|
274
|
+
return callGraphAt(GRAPH_BASE_URL, token, path, options, expectJson);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** GET/PATCH a full Graph URL (e.g. `@odata.nextLink` / `@odata.deltaLink`). */
|
|
278
|
+
export async function callGraphAbsolute<T>(
|
|
279
|
+
token: string,
|
|
280
|
+
absoluteUrl: string,
|
|
281
|
+
options: RequestInit = {},
|
|
282
|
+
expectJson: boolean = true
|
|
283
|
+
): Promise<GraphResponse<T>> {
|
|
284
|
+
const controller = new AbortController();
|
|
285
|
+
const timeout = setTimeout(() => controller.abort(), GRAPH_TIMEOUT_MS);
|
|
286
|
+
|
|
287
|
+
let response: Response;
|
|
288
|
+
try {
|
|
289
|
+
// codeql[js/file-access-to-http]: Bearer token may come from the local OAuth cache; absoluteUrl is Graph `@odata.nextLink` / `@odata.deltaLink`.
|
|
290
|
+
response = await fetch(absoluteUrl, {
|
|
291
|
+
...options,
|
|
292
|
+
headers: {
|
|
293
|
+
Authorization: `Bearer ${token}`,
|
|
294
|
+
...(expectJson ? { Accept: 'application/json' } : {}),
|
|
295
|
+
...(options.body && !(options.body instanceof Uint8Array) && !(options.body instanceof ArrayBuffer)
|
|
296
|
+
? { 'Content-Type': 'application/json' }
|
|
297
|
+
: {}),
|
|
298
|
+
...(options.headers || {})
|
|
299
|
+
},
|
|
300
|
+
signal: controller.signal
|
|
301
|
+
});
|
|
302
|
+
} catch (err) {
|
|
303
|
+
clearTimeout(timeout);
|
|
304
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
305
|
+
throw new GraphApiError(`Graph request timed out after ${GRAPH_TIMEOUT_MS / 1000}s`, undefined, 408);
|
|
306
|
+
}
|
|
307
|
+
throw new GraphApiError(err instanceof Error ? err.message : 'Graph request failed');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (!response.ok) {
|
|
311
|
+
let message = `Graph request failed: HTTP ${response.status}`;
|
|
312
|
+
let code: string | undefined;
|
|
313
|
+
try {
|
|
314
|
+
const json = (await response.json()) as { error?: { code?: string; message?: string } };
|
|
315
|
+
message = json.error?.message || message;
|
|
316
|
+
code = json.error?.code;
|
|
317
|
+
} catch {
|
|
318
|
+
clearTimeout(timeout);
|
|
319
|
+
throw new GraphApiError(message, code, response.status);
|
|
320
|
+
}
|
|
321
|
+
clearTimeout(timeout);
|
|
322
|
+
throw new GraphApiError(message, code, response.status);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (!expectJson || response.status === 204) {
|
|
326
|
+
clearTimeout(timeout);
|
|
327
|
+
return graphResult(undefined as T);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const result = await response.json();
|
|
331
|
+
clearTimeout(timeout);
|
|
332
|
+
return graphResult(result as T);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function buildItemPath(reference?: DriveItemReference): string {
|
|
336
|
+
if (!reference?.id) return '/me/drive/root';
|
|
337
|
+
|
|
338
|
+
const drivePrefix = reference.driveId ? `/drives/${encodeURIComponent(reference.driveId)}` : '/me/drive';
|
|
339
|
+
return `${drivePrefix}/items/${encodeURIComponent(reference.id)}`;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Encode a query string for Graph Drive search.
|
|
344
|
+
*
|
|
345
|
+
* encodeURIComponent encodes most characters, but it leaves certain characters
|
|
346
|
+
* like apostrophes and parentheses unescaped. AQS search uses single-quoted
|
|
347
|
+
* strings in the URL path (for example, search(q='...')), so we also percent-
|
|
348
|
+
* encode [!'()*] to prevent query syntax injection and keep the path safe.
|
|
349
|
+
*
|
|
350
|
+
* @param query - Raw search query string
|
|
351
|
+
* @returns Percent-encoded query safe for use in Graph search URLs
|
|
352
|
+
*/
|
|
353
|
+
function encodeGraphSearchQuery(query: string): string {
|
|
354
|
+
return encodeURIComponent(query).replace(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export async function listFiles(token: string, folder?: DriveItemReference): Promise<GraphResponse<DriveItem[]>> {
|
|
358
|
+
const basePath = buildItemPath(folder);
|
|
359
|
+
return fetchAllPages<DriveItem>(token, `${basePath}/children`, 'Failed to list files');
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export async function searchFiles(token: string, query: string): Promise<GraphResponse<DriveItem[]>> {
|
|
363
|
+
return fetchAllPages<DriveItem>(
|
|
364
|
+
token,
|
|
365
|
+
`/me/drive/root/search(q='${encodeGraphSearchQuery(query)}')`,
|
|
366
|
+
'Failed to search files'
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export async function getFileMetadata(token: string, itemId: string): Promise<GraphResponse<DriveItem>> {
|
|
371
|
+
try {
|
|
372
|
+
return await callGraph<DriveItem>(token, `/me/drive/items/${encodeURIComponent(itemId)}`);
|
|
373
|
+
} catch (err) {
|
|
374
|
+
if (err instanceof GraphApiError) {
|
|
375
|
+
return graphError(err.message, err.code, err.status);
|
|
376
|
+
}
|
|
377
|
+
return graphError(err instanceof Error ? err.message : 'Failed to get file metadata');
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export interface UploadLargeResult {
|
|
382
|
+
uploadUrl: string;
|
|
383
|
+
expirationDateTime?: string;
|
|
384
|
+
driveItem?: DriveItem;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export async function uploadFile(
|
|
388
|
+
token: string,
|
|
389
|
+
localPath: string,
|
|
390
|
+
folder?: DriveItemReference
|
|
391
|
+
): Promise<GraphResponse<DriveItem>> {
|
|
392
|
+
try {
|
|
393
|
+
const absolutePath = resolve(localPath);
|
|
394
|
+
const st0 = await stat(absolutePath).catch(() => null);
|
|
395
|
+
if (!st0?.isFile()) return graphError(`Not a file or not found: ${absolutePath}`);
|
|
396
|
+
let resolvedPath: string;
|
|
397
|
+
try {
|
|
398
|
+
resolvedPath = await realpath(absolutePath);
|
|
399
|
+
} catch {
|
|
400
|
+
resolvedPath = absolutePath;
|
|
401
|
+
}
|
|
402
|
+
const fileStats = await stat(resolvedPath);
|
|
403
|
+
if (!fileStats.isFile()) return graphError(`Not a file: ${resolvedPath}`);
|
|
404
|
+
if (fileStats.size > 250 * 1024 * 1024) {
|
|
405
|
+
return graphError('File exceeds 250MB simple upload limit. Use upload-large instead.');
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const fileName = basename(resolvedPath);
|
|
409
|
+
const folderPath = folder?.id ? `${buildItemPath(folder)}:/` : '/me/drive/root:/';
|
|
410
|
+
const stream = createReadStream(resolvedPath);
|
|
411
|
+
try {
|
|
412
|
+
// codeql[js/file-access-to-http]: intentional upload of a user-selected local file to Microsoft Graph after resolve+stat+isFile.
|
|
413
|
+
return await callGraph<DriveItem>(token, `${folderPath}${encodeURIComponent(fileName)}:/content`, {
|
|
414
|
+
method: 'PUT',
|
|
415
|
+
body: Readable.toWeb(stream) as unknown as BodyInit,
|
|
416
|
+
headers: {
|
|
417
|
+
'Content-Type': 'application/octet-stream'
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
} catch (err) {
|
|
421
|
+
if (err instanceof GraphApiError) {
|
|
422
|
+
return graphError(err.message, err.code, err.status);
|
|
423
|
+
}
|
|
424
|
+
return graphError(err instanceof Error ? err.message : 'Upload failed');
|
|
425
|
+
} finally {
|
|
426
|
+
stream.destroy();
|
|
427
|
+
}
|
|
428
|
+
} catch (err) {
|
|
429
|
+
return graphError(err instanceof Error ? err.message : 'Upload failed');
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export async function uploadLargeFile(
|
|
434
|
+
token: string,
|
|
435
|
+
localPath: string,
|
|
436
|
+
folder?: DriveItemReference
|
|
437
|
+
): Promise<GraphResponse<UploadLargeResult>> {
|
|
438
|
+
try {
|
|
439
|
+
const absolutePath = resolve(localPath);
|
|
440
|
+
const st0 = await stat(absolutePath).catch(() => null);
|
|
441
|
+
if (!st0?.isFile()) return graphError(`Not a file or not found: ${absolutePath}`);
|
|
442
|
+
let resolvedPath: string;
|
|
443
|
+
try {
|
|
444
|
+
resolvedPath = await realpath(absolutePath);
|
|
445
|
+
} catch {
|
|
446
|
+
resolvedPath = absolutePath;
|
|
447
|
+
}
|
|
448
|
+
let fileHandle: any;
|
|
449
|
+
try {
|
|
450
|
+
fileHandle = await open(resolvedPath, 'r');
|
|
451
|
+
} catch (err: any) {
|
|
452
|
+
return graphError(`Failed to open file: ${err.message}`);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
try {
|
|
456
|
+
const fileStats = await fileHandle.stat();
|
|
457
|
+
if (!fileStats.isFile()) return graphError(`Not a file: ${resolvedPath}`);
|
|
458
|
+
if (fileStats.size > 4 * 1024 * 1024 * 1024) {
|
|
459
|
+
return graphError('File exceeds 4GB large upload limit.');
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const fileName = basename(resolvedPath);
|
|
463
|
+
const folderPath = folder?.id ? `${buildItemPath(folder)}:/` : '/me/drive/root:/';
|
|
464
|
+
|
|
465
|
+
// Step 1: Create the upload session
|
|
466
|
+
let sessionResult: GraphResponse<UploadLargeResult>;
|
|
467
|
+
try {
|
|
468
|
+
sessionResult = await callGraph<UploadLargeResult>(
|
|
469
|
+
token,
|
|
470
|
+
`${folderPath}${encodeURIComponent(fileName)}:/createUploadSession`,
|
|
471
|
+
{
|
|
472
|
+
method: 'POST',
|
|
473
|
+
body: JSON.stringify({ item: { '@microsoft.graph.conflictBehavior': 'replace', name: fileName } })
|
|
474
|
+
}
|
|
475
|
+
);
|
|
476
|
+
} catch (err) {
|
|
477
|
+
if (err instanceof GraphApiError) {
|
|
478
|
+
return graphError(err.message, err.code, err.status);
|
|
479
|
+
}
|
|
480
|
+
return graphError(err instanceof Error ? err.message : 'Failed to create upload session');
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (!sessionResult.ok || !sessionResult.data) {
|
|
484
|
+
return sessionResult;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const { uploadUrl, expirationDateTime } = sessionResult.data;
|
|
488
|
+
|
|
489
|
+
// Step 2: Upload the file in chunks
|
|
490
|
+
const fileSize = fileStats.size;
|
|
491
|
+
|
|
492
|
+
if (fileSize === 0) {
|
|
493
|
+
return graphError('Cannot upload zero-byte files using large upload session. Use simple upload instead.');
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const CHUNK_SIZE = 10 * 1024 * 1024; // 10MB chunks
|
|
497
|
+
const chunkBuffer = new Uint8Array(CHUNK_SIZE);
|
|
498
|
+
|
|
499
|
+
let offset = 0;
|
|
500
|
+
let lastSuccessfulResponse: Response | null = null;
|
|
501
|
+
|
|
502
|
+
while (offset < fileSize) {
|
|
503
|
+
const chunkLength = Math.min(CHUNK_SIZE, fileSize - offset);
|
|
504
|
+
const { bytesRead } = await fileHandle.read(chunkBuffer, 0, chunkLength, offset);
|
|
505
|
+
|
|
506
|
+
if (bytesRead === 0) break;
|
|
507
|
+
|
|
508
|
+
const endOffset = offset + bytesRead - 1;
|
|
509
|
+
const contentRange = `bytes ${offset}-${endOffset}/${fileSize}`;
|
|
510
|
+
const chunkData = chunkBuffer.subarray(0, bytesRead);
|
|
511
|
+
|
|
512
|
+
const controller = new AbortController();
|
|
513
|
+
const timeoutId = setTimeout(() => controller.abort(), GRAPH_TIMEOUT_MS);
|
|
514
|
+
|
|
515
|
+
try {
|
|
516
|
+
lastSuccessfulResponse = await fetch(uploadUrl, {
|
|
517
|
+
method: 'PUT',
|
|
518
|
+
headers: {
|
|
519
|
+
'Content-Length': String(bytesRead),
|
|
520
|
+
'Content-Range': contentRange
|
|
521
|
+
},
|
|
522
|
+
body: chunkData,
|
|
523
|
+
signal: controller.signal,
|
|
524
|
+
redirect: 'manual'
|
|
525
|
+
});
|
|
526
|
+
} catch (err: any) {
|
|
527
|
+
if (err && err.name === 'AbortError') {
|
|
528
|
+
return graphError(
|
|
529
|
+
`Chunk upload timed out after ${GRAPH_TIMEOUT_MS} ms at offset ${offset}`,
|
|
530
|
+
'RequestTimeout',
|
|
531
|
+
408
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
throw err;
|
|
535
|
+
} finally {
|
|
536
|
+
clearTimeout(timeoutId);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (!lastSuccessfulResponse.ok) {
|
|
540
|
+
const errorBody = await lastSuccessfulResponse.text().catch(() => '');
|
|
541
|
+
return graphError(
|
|
542
|
+
`Chunk upload failed at offset ${offset} (HTTP ${lastSuccessfulResponse.status}): ${errorBody}`,
|
|
543
|
+
String(lastSuccessfulResponse.status),
|
|
544
|
+
lastSuccessfulResponse.status
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
offset += bytesRead;
|
|
549
|
+
|
|
550
|
+
if (offset < fileSize) {
|
|
551
|
+
await lastSuccessfulResponse.text().catch(() => {});
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (offset !== fileSize) {
|
|
556
|
+
return graphError(`Upload stopped early. Expected to upload ${fileSize} bytes but uploaded ${offset}`);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Step 3: Parse the final response
|
|
560
|
+
if (lastSuccessfulResponse) {
|
|
561
|
+
const status = lastSuccessfulResponse.status;
|
|
562
|
+
if (status === 200 || status === 201) {
|
|
563
|
+
let body: unknown;
|
|
564
|
+
try {
|
|
565
|
+
body = await lastSuccessfulResponse.json();
|
|
566
|
+
} catch {
|
|
567
|
+
return graphError('Upload completed but failed to parse final response');
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const maybeDriveItem = body as Partial<DriveItem> | null;
|
|
571
|
+
if (
|
|
572
|
+
maybeDriveItem &&
|
|
573
|
+
typeof maybeDriveItem === 'object' &&
|
|
574
|
+
typeof maybeDriveItem.id === 'string' &&
|
|
575
|
+
typeof maybeDriveItem.name === 'string'
|
|
576
|
+
) {
|
|
577
|
+
const driveItem = maybeDriveItem as DriveItem;
|
|
578
|
+
return {
|
|
579
|
+
ok: true,
|
|
580
|
+
data: { uploadUrl, expirationDateTime, driveItem }
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
return graphError('Upload completed but final response did not contain drive item metadata');
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
return graphError('Upload completed but final response was not valid');
|
|
589
|
+
} finally {
|
|
590
|
+
await fileHandle.close();
|
|
591
|
+
}
|
|
592
|
+
} catch (err) {
|
|
593
|
+
return graphError(err instanceof Error ? err.message : 'Upload failed');
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
export async function downloadFile(
|
|
598
|
+
token: string,
|
|
599
|
+
itemId: string,
|
|
600
|
+
outputPath?: string,
|
|
601
|
+
item?: DriveItem
|
|
602
|
+
): Promise<GraphResponse<{ path: string; item: DriveItem }>> {
|
|
603
|
+
let resolvedItem = item;
|
|
604
|
+
let targetPath: string | undefined;
|
|
605
|
+
let tmpPath: string | undefined;
|
|
606
|
+
|
|
607
|
+
// Step 1: resolve item metadata
|
|
608
|
+
if (!resolvedItem) {
|
|
609
|
+
const metadata = await getFileMetadata(token, itemId);
|
|
610
|
+
if (!metadata.ok || !metadata.data) {
|
|
611
|
+
return graphError(
|
|
612
|
+
metadata.error?.message || 'Failed to fetch file metadata before download',
|
|
613
|
+
metadata.error?.code,
|
|
614
|
+
metadata.error?.status
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
resolvedItem = metadata.data;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const downloadUrl = resolvedItem['@microsoft.graph.downloadUrl'];
|
|
621
|
+
if (!downloadUrl) {
|
|
622
|
+
return graphError('Download URL missing from Graph metadata response.');
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Security: validate downloadUrl before fetching to prevent SSRF and token exfiltration
|
|
626
|
+
let url: URL;
|
|
627
|
+
try {
|
|
628
|
+
url = new URL(downloadUrl);
|
|
629
|
+
} catch {
|
|
630
|
+
return graphError('Download URL is not a valid URL.');
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (url.protocol !== 'https:') {
|
|
634
|
+
return graphError('Download URL has unsupported scheme. Only HTTPS is permitted.');
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Allowed Microsoft domains for download URLs (supports both exact and suffix matching)
|
|
638
|
+
// Includes sovereign cloud domains: .us (GCC High/DoD), .cn (China/21Vianet)
|
|
639
|
+
const allowedDomains = [
|
|
640
|
+
'onedrive.live.com',
|
|
641
|
+
'sharepoint.com',
|
|
642
|
+
'sharepoint.us',
|
|
643
|
+
'sharepoint.cn',
|
|
644
|
+
'graph.microsoft.com',
|
|
645
|
+
'graph.microsoft.us',
|
|
646
|
+
'microsoftgraph.chinacloudapi.cn',
|
|
647
|
+
'files.1drv.com'
|
|
648
|
+
];
|
|
649
|
+
|
|
650
|
+
const isAllowedHost = allowedDomains.some((domain) => url.hostname === domain || url.hostname.endsWith(`.${domain}`));
|
|
651
|
+
|
|
652
|
+
if (!isAllowedHost) {
|
|
653
|
+
return graphError(`Download URL hostname '${url.hostname}' is not in the allowlist.`);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
targetPath = resolve(outputPath || defaultDownloadPath(basename(resolvedItem.name || itemId)));
|
|
657
|
+
await mkdir(dirname(targetPath), { recursive: true });
|
|
658
|
+
|
|
659
|
+
// Step 2: retry loop for transient network errors
|
|
660
|
+
const MAX_RETRIES = 2;
|
|
661
|
+
let lastError: unknown;
|
|
662
|
+
|
|
663
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
664
|
+
try {
|
|
665
|
+
const response = await fetch(url.toString(), { redirect: 'manual' });
|
|
666
|
+
|
|
667
|
+
// Reject redirects to prevent SSRF bypass
|
|
668
|
+
if (response.status >= 300 && response.status < 400) {
|
|
669
|
+
return graphError('Download failed: redirects are not permitted for security reasons');
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
if (!response.ok) {
|
|
673
|
+
// Non-transient HTTP errors: don't retry
|
|
674
|
+
return graphError(`Download failed: HTTP ${response.status}`);
|
|
675
|
+
}
|
|
676
|
+
if (!response.body) {
|
|
677
|
+
return graphError('Download failed: response body missing');
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const contentLength = response.headers.get('content-length');
|
|
681
|
+
const tmpFileName = `.${resolvedItem.name ?? itemId}.${randomBytes(8).toString('hex')}.tmp`;
|
|
682
|
+
tmpPath = resolve(dirname(targetPath), 'tmp', tmpFileName);
|
|
683
|
+
await mkdir(dirname(tmpPath), { recursive: true });
|
|
684
|
+
|
|
685
|
+
const bytesWritten = await streamWebToFile(response.body, tmpPath);
|
|
686
|
+
|
|
687
|
+
// Verify integrity when Content-Length is available
|
|
688
|
+
if (contentLength !== null) {
|
|
689
|
+
const expected = Number(contentLength);
|
|
690
|
+
if (!Number.isFinite(expected)) {
|
|
691
|
+
await unlink(tmpPath).catch(() => {});
|
|
692
|
+
tmpPath = undefined;
|
|
693
|
+
return graphError(`Download failed: invalid Content-Length header`);
|
|
694
|
+
}
|
|
695
|
+
if (bytesWritten !== expected) {
|
|
696
|
+
// Clean up corrupted temp file
|
|
697
|
+
await unlink(tmpPath).catch(() => {});
|
|
698
|
+
tmpPath = undefined;
|
|
699
|
+
return graphError(`Download failed: size mismatch (expected ${expected} bytes, got ${bytesWritten})`);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Atomic rename: temp → final path
|
|
704
|
+
await rename(tmpPath, targetPath);
|
|
705
|
+
|
|
706
|
+
return graphResult({ path: targetPath, item: resolvedItem });
|
|
707
|
+
} catch (err) {
|
|
708
|
+
lastError = err;
|
|
709
|
+
|
|
710
|
+
// Clean up temp file on any error
|
|
711
|
+
if (tmpPath) {
|
|
712
|
+
await unlink(tmpPath).catch(() => {});
|
|
713
|
+
tmpPath = undefined;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// Only retry on network/stream errors, not on business-logic errors (size mismatch etc.)
|
|
717
|
+
const isRetryable =
|
|
718
|
+
err instanceof Error &&
|
|
719
|
+
(err.message.includes('fetch failed') ||
|
|
720
|
+
err.message.includes('network') ||
|
|
721
|
+
err.message.includes('ECONNREFUSED') ||
|
|
722
|
+
err.message.includes('ETIMEDOUT') ||
|
|
723
|
+
err.message.includes('ENOTFOUND'));
|
|
724
|
+
|
|
725
|
+
if (isRetryable && attempt < MAX_RETRIES) {
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
break;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
return graphError(lastError instanceof Error ? lastError.message : 'Download failed');
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
export async function deleteFile(token: string, itemId: string): Promise<GraphResponse<void>> {
|
|
736
|
+
try {
|
|
737
|
+
return await callGraph<void>(token, `/me/drive/items/${encodeURIComponent(itemId)}`, { method: 'DELETE' }, false);
|
|
738
|
+
} catch (err) {
|
|
739
|
+
if (err instanceof GraphApiError) {
|
|
740
|
+
return graphError(err.message, err.code, err.status);
|
|
741
|
+
}
|
|
742
|
+
return graphError(err instanceof Error ? err.message : 'Failed to delete file');
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
export async function shareFile(
|
|
747
|
+
token: string,
|
|
748
|
+
itemId: string,
|
|
749
|
+
type: 'view' | 'edit' = 'view',
|
|
750
|
+
scope: 'anonymous' | 'organization' = 'organization'
|
|
751
|
+
): Promise<GraphResponse<SharingLinkResult>> {
|
|
752
|
+
let result: GraphResponse<{ link?: SharingLinkResult }>;
|
|
753
|
+
try {
|
|
754
|
+
result = await callGraph<{ link?: SharingLinkResult }>(
|
|
755
|
+
token,
|
|
756
|
+
`/me/drive/items/${encodeURIComponent(itemId)}/createLink`,
|
|
757
|
+
{
|
|
758
|
+
method: 'POST',
|
|
759
|
+
body: JSON.stringify({ type, scope })
|
|
760
|
+
}
|
|
761
|
+
);
|
|
762
|
+
} catch (err) {
|
|
763
|
+
if (err instanceof GraphApiError) {
|
|
764
|
+
return graphError(err.message, err.code, err.status);
|
|
765
|
+
}
|
|
766
|
+
return graphError(err instanceof Error ? err.message : 'Failed to share file');
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
if (!result.ok || !result.data) return result as GraphResponse<SharingLinkResult>;
|
|
770
|
+
return graphResult(result.data.link || {});
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
export async function checkoutFile(token: string, itemId: string): Promise<GraphResponse<void>> {
|
|
774
|
+
try {
|
|
775
|
+
return await callGraph<void>(
|
|
776
|
+
token,
|
|
777
|
+
`/me/drive/items/${encodeURIComponent(itemId)}/checkout`,
|
|
778
|
+
{ method: 'POST' },
|
|
779
|
+
false
|
|
780
|
+
);
|
|
781
|
+
} catch (err) {
|
|
782
|
+
if (err instanceof GraphApiError) {
|
|
783
|
+
return graphError(err.message, err.code, err.status);
|
|
784
|
+
}
|
|
785
|
+
return graphError(err instanceof Error ? err.message : 'Failed to checkout file');
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
export async function checkinFile(
|
|
790
|
+
token: string,
|
|
791
|
+
itemId: string,
|
|
792
|
+
comment?: string
|
|
793
|
+
): Promise<GraphResponse<CheckinResult>> {
|
|
794
|
+
let result: GraphResponse<void>;
|
|
795
|
+
try {
|
|
796
|
+
result = await callGraph<void>(
|
|
797
|
+
token,
|
|
798
|
+
`/me/drive/items/${encodeURIComponent(itemId)}/checkin`,
|
|
799
|
+
{
|
|
800
|
+
method: 'POST',
|
|
801
|
+
body: JSON.stringify({ comment: comment || '' })
|
|
802
|
+
},
|
|
803
|
+
false
|
|
804
|
+
);
|
|
805
|
+
} catch (err) {
|
|
806
|
+
if (err instanceof GraphApiError) {
|
|
807
|
+
return graphError(err.message, err.code, err.status);
|
|
808
|
+
}
|
|
809
|
+
return graphError(err instanceof Error ? err.message : 'Check-in failed');
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
if (!result.ok) {
|
|
813
|
+
return graphError(result.error?.message || 'Check-in failed', result.error?.code, result.error?.status);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
const item = await getFileMetadata(token, itemId);
|
|
817
|
+
if (!item.ok || !item.data) {
|
|
818
|
+
return graphError(
|
|
819
|
+
item.error?.message || 'Checked in, but failed to refresh file metadata',
|
|
820
|
+
item.error?.code,
|
|
821
|
+
item.error?.status
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
return graphResult({ item: item.data, checkedIn: true, comment });
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
export async function createOfficeCollaborationLink(
|
|
829
|
+
token: string,
|
|
830
|
+
itemId: string,
|
|
831
|
+
options: { lock?: boolean } = {}
|
|
832
|
+
): Promise<GraphResponse<OfficeCollabLinkResult>> {
|
|
833
|
+
const item = await getFileMetadata(token, itemId);
|
|
834
|
+
if (!item.ok || !item.data) {
|
|
835
|
+
return graphError(item.error?.message || 'Failed to fetch file metadata', item.error?.code, item.error?.status);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const extension = item.data.name.includes('.') ? item.data.name.split('.').pop()?.toLowerCase() : undefined;
|
|
839
|
+
const supported = new Set(['docx', 'xlsx', 'pptx']);
|
|
840
|
+
if (!extension || !supported.has(extension)) {
|
|
841
|
+
return graphError(
|
|
842
|
+
'Office Online collaboration is only supported for .docx, .xlsx, and .pptx files. Convert legacy Office formats first.'
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
if (options.lock) {
|
|
847
|
+
const lock = await checkoutFile(token, itemId);
|
|
848
|
+
if (!lock.ok) {
|
|
849
|
+
return graphError(
|
|
850
|
+
lock.error?.message || 'Failed to checkout file before sharing',
|
|
851
|
+
lock.error?.code,
|
|
852
|
+
lock.error?.status
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const link = await shareFile(token, itemId, 'edit', 'organization');
|
|
858
|
+
if (!link.ok || !link.data) {
|
|
859
|
+
if (options.lock) {
|
|
860
|
+
await checkinFile(token, itemId);
|
|
861
|
+
}
|
|
862
|
+
return graphError(
|
|
863
|
+
link.error?.message || 'Failed to create collaboration link',
|
|
864
|
+
link.error?.code,
|
|
865
|
+
link.error?.status
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
return graphResult({
|
|
870
|
+
item: item.data,
|
|
871
|
+
link: link.data,
|
|
872
|
+
collaborationUrl: item.data.webUrl || link.data.webUrl,
|
|
873
|
+
lockAcquired: !!options.lock
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
export function defaultDownloadPath(fileName: string): string {
|
|
878
|
+
return resolve(homedir(), 'Downloads', basename(fileName));
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
export async function listFileVersions(token: string, itemId: string): Promise<GraphResponse<DriveItemVersion[]>> {
|
|
882
|
+
return fetchAllPages<DriveItemVersion>(
|
|
883
|
+
token,
|
|
884
|
+
`/me/drive/items/${encodeURIComponent(itemId)}/versions`,
|
|
885
|
+
'Failed to list versions'
|
|
886
|
+
);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
export async function restoreFileVersion(
|
|
890
|
+
token: string,
|
|
891
|
+
itemId: string,
|
|
892
|
+
versionId: string
|
|
893
|
+
): Promise<GraphResponse<void>> {
|
|
894
|
+
try {
|
|
895
|
+
return await callGraph<void>(
|
|
896
|
+
token,
|
|
897
|
+
`/me/drive/items/${encodeURIComponent(itemId)}/versions/${encodeURIComponent(versionId)}/restoreVersion`,
|
|
898
|
+
{ method: 'POST' },
|
|
899
|
+
false
|
|
900
|
+
);
|
|
901
|
+
} catch (err) {
|
|
902
|
+
if (err instanceof GraphApiError) {
|
|
903
|
+
return graphError(err.message, err.code, err.status);
|
|
904
|
+
}
|
|
905
|
+
return graphError(err instanceof Error ? err.message : 'Failed to restore version');
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
export async function cleanupDownloadedFile(path: string): Promise<void> {
|
|
910
|
+
try {
|
|
911
|
+
await unlink(path);
|
|
912
|
+
} catch {
|
|
913
|
+
// Ignore cleanup failures
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
export interface FileAnalytics {
|
|
918
|
+
allTime?: {
|
|
919
|
+
access?: { actionCount?: number; actorCount?: number };
|
|
920
|
+
};
|
|
921
|
+
lastSevenDays?: {
|
|
922
|
+
access?: { actionCount?: number; actorCount?: number };
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
export async function getFileAnalytics(token: string, itemId: string): Promise<GraphResponse<FileAnalytics>> {
|
|
927
|
+
const [allTimeResult, lastSevenDaysResult] = await Promise.allSettled([
|
|
928
|
+
callGraph<FileAnalytics['allTime']>(token, `/me/drive/items/${encodeURIComponent(itemId)}/analytics/allTime`),
|
|
929
|
+
callGraph<FileAnalytics['lastSevenDays']>(
|
|
930
|
+
token,
|
|
931
|
+
`/me/drive/items/${encodeURIComponent(itemId)}/analytics/lastSevenDays`
|
|
932
|
+
)
|
|
933
|
+
]);
|
|
934
|
+
|
|
935
|
+
const analytics: FileAnalytics = {};
|
|
936
|
+
|
|
937
|
+
if (allTimeResult.status === 'fulfilled' && allTimeResult.value.ok && allTimeResult.value.data) {
|
|
938
|
+
analytics.allTime = allTimeResult.value.data;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
if (lastSevenDaysResult.status === 'fulfilled' && lastSevenDaysResult.value.ok && lastSevenDaysResult.value.data) {
|
|
942
|
+
analytics.lastSevenDays = lastSevenDaysResult.value.data;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
if (allTimeResult.status === 'rejected' && lastSevenDaysResult.status === 'rejected') {
|
|
946
|
+
const error = allTimeResult.reason;
|
|
947
|
+
if (error instanceof GraphApiError) {
|
|
948
|
+
return graphError(error.message, error.code, error.status);
|
|
949
|
+
}
|
|
950
|
+
return graphError(error instanceof Error ? error.message : 'Failed to get file analytics');
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
return graphResult(analytics);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
export async function downloadConvertedFile(
|
|
957
|
+
token: string,
|
|
958
|
+
itemId: string,
|
|
959
|
+
format: string = 'pdf',
|
|
960
|
+
outputPath?: string
|
|
961
|
+
): Promise<GraphResponse<{ path: string }>> {
|
|
962
|
+
let tmpPath: string | undefined;
|
|
963
|
+
try {
|
|
964
|
+
const metadata = await getFileMetadata(token, itemId);
|
|
965
|
+
if (!metadata.ok || !metadata.data) {
|
|
966
|
+
return graphError(
|
|
967
|
+
metadata.error?.message || 'Failed to fetch file metadata',
|
|
968
|
+
metadata.error?.code,
|
|
969
|
+
metadata.error?.status
|
|
970
|
+
);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const item = metadata.data;
|
|
974
|
+
const originalName = item.name || itemId;
|
|
975
|
+
const newName = originalName.includes('.')
|
|
976
|
+
? `${originalName.substring(0, originalName.lastIndexOf('.'))}.${format}`
|
|
977
|
+
: `${originalName}.${format}`;
|
|
978
|
+
|
|
979
|
+
const targetPath = resolve(outputPath || defaultDownloadPath(newName));
|
|
980
|
+
await mkdir(dirname(targetPath), { recursive: true });
|
|
981
|
+
|
|
982
|
+
const path = `/me/drive/items/${encodeURIComponent(itemId)}/content?format=${encodeURIComponent(format)}`;
|
|
983
|
+
|
|
984
|
+
const redirectResponse = await fetchGraphRaw(token, path, { redirect: 'manual' });
|
|
985
|
+
|
|
986
|
+
if (redirectResponse.status < 300 || redirectResponse.status >= 400) {
|
|
987
|
+
if (!redirectResponse.ok) {
|
|
988
|
+
return graphError(`Failed to convert file: HTTP ${redirectResponse.status}`);
|
|
989
|
+
}
|
|
990
|
+
return graphError('Expected a redirect for file conversion, but got a direct response.');
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
const location = redirectResponse.headers.get('location');
|
|
994
|
+
if (!location) {
|
|
995
|
+
return graphError('Missing redirect location for converted file');
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
let url: URL;
|
|
999
|
+
try {
|
|
1000
|
+
url = new URL(location);
|
|
1001
|
+
} catch {
|
|
1002
|
+
return graphError('Redirect location is not a valid URL.');
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
if (url.protocol !== 'https:') {
|
|
1006
|
+
return graphError('Redirect URL has unsupported scheme. Only HTTPS is permitted.');
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
const allowedDomains = [
|
|
1010
|
+
'onedrive.live.com',
|
|
1011
|
+
'sharepoint.com',
|
|
1012
|
+
'sharepoint.us',
|
|
1013
|
+
'sharepoint.cn',
|
|
1014
|
+
'graph.microsoft.com',
|
|
1015
|
+
'graph.microsoft.us',
|
|
1016
|
+
'microsoftgraph.chinacloudapi.cn',
|
|
1017
|
+
'files.1drv.com'
|
|
1018
|
+
];
|
|
1019
|
+
|
|
1020
|
+
const isAllowedHost = allowedDomains.some(
|
|
1021
|
+
(domain) => url.hostname === domain || url.hostname.endsWith(`.${domain}`)
|
|
1022
|
+
);
|
|
1023
|
+
|
|
1024
|
+
if (!isAllowedHost) {
|
|
1025
|
+
return graphError(`Redirect URL hostname '${url.hostname}' is not in the allowlist.`);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
const response = await fetch(url.toString(), { redirect: 'manual' });
|
|
1029
|
+
|
|
1030
|
+
if (response.status >= 300 && response.status < 400) {
|
|
1031
|
+
return graphError('Download failed: further redirects are not permitted for security reasons');
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
if (!response.ok) {
|
|
1035
|
+
return graphError(`Failed to download converted file: HTTP ${response.status}`);
|
|
1036
|
+
}
|
|
1037
|
+
if (!response.body) {
|
|
1038
|
+
return graphError('Response body is empty');
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
const tmpFileName = `.${newName}.${randomBytes(8).toString('hex')}.tmp`;
|
|
1042
|
+
tmpPath = resolve(dirname(targetPath), 'tmp', tmpFileName);
|
|
1043
|
+
await mkdir(dirname(tmpPath), { recursive: true });
|
|
1044
|
+
|
|
1045
|
+
await streamWebToFile(response.body, tmpPath);
|
|
1046
|
+
await rename(tmpPath, targetPath);
|
|
1047
|
+
|
|
1048
|
+
return graphResult({ path: targetPath });
|
|
1049
|
+
} catch (err) {
|
|
1050
|
+
if (tmpPath) {
|
|
1051
|
+
await unlink(tmpPath).catch(() => {});
|
|
1052
|
+
}
|
|
1053
|
+
if (err instanceof GraphApiError) {
|
|
1054
|
+
return graphError(err.message, err.code, err.status);
|
|
1055
|
+
}
|
|
1056
|
+
return graphError(err instanceof Error ? err.message : 'Failed to download converted file');
|
|
1057
|
+
}
|
|
1058
|
+
}
|