onedrive-file-picker.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438
  1. import { PublicClientApplication } from '@azure/msal-browser';
  2. import type { PopupRequest } from '@azure/msal-browser';
  3. import { v4 as uuidv4 } from 'uuid';
  4. class OneDriveConfig {
  5. private static instance: OneDriveConfig;
  6. private clientId: string = '';
  7. private sharepointUrl: string = '';
  8. private sharepointTenantId: string = '';
  9. private msalInstance: PublicClientApplication | null = null;
  10. private currentAuthorityType: 'personal' | 'organizations' = 'personal';
  11. private constructor() {}
  12. public static getInstance(): OneDriveConfig {
  13. if (!OneDriveConfig.instance) {
  14. OneDriveConfig.instance = new OneDriveConfig();
  15. }
  16. return OneDriveConfig.instance;
  17. }
  18. public async initialize(authorityType?: 'personal' | 'organizations'): Promise<void> {
  19. if (authorityType && this.currentAuthorityType !== authorityType) {
  20. this.currentAuthorityType = authorityType;
  21. this.msalInstance = null;
  22. }
  23. await this.getCredentials();
  24. }
  25. public async ensureInitialized(authorityType?: 'personal' | 'organizations'): Promise<void> {
  26. await this.initialize(authorityType);
  27. }
  28. private async getCredentials(): Promise<void> {
  29. const headers: HeadersInit = {
  30. 'Content-Type': 'application/json'
  31. };
  32. const response = await fetch('/api/config', {
  33. headers,
  34. credentials: 'include'
  35. });
  36. if (!response.ok) {
  37. throw new Error('Failed to fetch OneDrive credentials');
  38. }
  39. const config = await response.json();
  40. const newClientId = config.onedrive?.client_id;
  41. const newSharepointUrl = config.onedrive?.sharepoint_url;
  42. const newSharepointTenantId = config.onedrive?.sharepoint_tenant_id;
  43. if (!newClientId) {
  44. throw new Error('OneDrive configuration is incomplete');
  45. }
  46. this.clientId = newClientId;
  47. this.sharepointUrl = newSharepointUrl;
  48. this.sharepointTenantId = newSharepointTenantId;
  49. }
  50. public async getMsalInstance(
  51. authorityType?: 'personal' | 'organizations'
  52. ): Promise<PublicClientApplication> {
  53. await this.ensureInitialized(authorityType);
  54. if (!this.msalInstance) {
  55. const authorityEndpoint =
  56. this.currentAuthorityType === 'organizations'
  57. ? this.sharepointTenantId || 'common'
  58. : 'consumers';
  59. const msalParams = {
  60. auth: {
  61. authority: `https://login.microsoftonline.com/${authorityEndpoint}`,
  62. clientId: this.clientId
  63. }
  64. };
  65. this.msalInstance = new PublicClientApplication(msalParams);
  66. if (this.msalInstance.initialize) {
  67. await this.msalInstance.initialize();
  68. }
  69. }
  70. return this.msalInstance;
  71. }
  72. public getAuthorityType(): 'personal' | 'organizations' {
  73. return this.currentAuthorityType;
  74. }
  75. public getSharepointUrl(): string {
  76. return this.sharepointUrl;
  77. }
  78. public getSharepointTenantId(): string {
  79. return this.sharepointTenantId;
  80. }
  81. public getBaseUrl(): string {
  82. if (this.currentAuthorityType === 'organizations') {
  83. if (!this.sharepointUrl || this.sharepointUrl === '') {
  84. throw new Error('Sharepoint URL not configured');
  85. }
  86. let sharePointBaseUrl = this.sharepointUrl.replace(/^https?:\/\//, '');
  87. sharePointBaseUrl = sharePointBaseUrl.replace(/\/$/, '');
  88. return `https://${sharePointBaseUrl}`;
  89. } else {
  90. return 'https://onedrive.live.com/picker';
  91. }
  92. }
  93. }
  94. // Retrieve OneDrive access token
  95. async function getToken(
  96. resource?: string,
  97. authorityType?: 'personal' | 'organizations'
  98. ): Promise<string> {
  99. const config = OneDriveConfig.getInstance();
  100. await config.ensureInitialized(authorityType);
  101. const currentAuthorityType = config.getAuthorityType();
  102. const scopes =
  103. currentAuthorityType === 'organizations'
  104. ? [`${resource || config.getBaseUrl()}/.default`]
  105. : ['OneDrive.ReadWrite'];
  106. const authParams: PopupRequest = { scopes };
  107. let accessToken = '';
  108. try {
  109. const msalInstance = await config.getMsalInstance(authorityType);
  110. const resp = await msalInstance.acquireTokenSilent(authParams);
  111. accessToken = resp.accessToken;
  112. } catch (err) {
  113. const msalInstance = await config.getMsalInstance(authorityType);
  114. try {
  115. const resp = await msalInstance.loginPopup(authParams);
  116. msalInstance.setActiveAccount(resp.account);
  117. if (resp.idToken) {
  118. const resp2 = await msalInstance.acquireTokenSilent(authParams);
  119. accessToken = resp2.accessToken;
  120. }
  121. } catch (popupError) {
  122. throw new Error(
  123. 'Failed to login: ' +
  124. (popupError instanceof Error ? popupError.message : String(popupError))
  125. );
  126. }
  127. }
  128. if (!accessToken) {
  129. throw new Error('Failed to acquire access token');
  130. }
  131. return accessToken;
  132. }
  133. interface PickerParams {
  134. sdk: string;
  135. entry: {
  136. oneDrive: Record<string, unknown>;
  137. };
  138. authentication: Record<string, unknown>;
  139. messaging: {
  140. origin: string;
  141. channelId: string;
  142. };
  143. typesAndSources: {
  144. mode: string;
  145. pivots: Record<string, boolean>;
  146. };
  147. }
  148. interface PickerResult {
  149. command?: string;
  150. items?: OneDriveFileInfo[];
  151. [key: string]: any;
  152. }
  153. // Get picker parameters based on account type
  154. function getPickerParams(): PickerParams {
  155. const channelId = uuidv4();
  156. const config = OneDriveConfig.getInstance();
  157. const params: PickerParams = {
  158. sdk: '8.0',
  159. entry: {
  160. oneDrive: {}
  161. },
  162. authentication: {},
  163. messaging: {
  164. origin: window?.location?.origin || '',
  165. channelId
  166. },
  167. typesAndSources: {
  168. mode: 'files',
  169. pivots: {
  170. oneDrive: true,
  171. recent: true
  172. }
  173. }
  174. };
  175. // For personal accounts, set files object in oneDrive
  176. if (config.getAuthorityType() !== 'organizations') {
  177. params.entry.oneDrive = { files: {} };
  178. }
  179. return params;
  180. }
  181. interface OneDriveFileInfo {
  182. id: string;
  183. name: string;
  184. parentReference: {
  185. driveId: string;
  186. };
  187. '@sharePoint.endpoint': string;
  188. [key: string]: any;
  189. }
  190. // Download file from OneDrive
  191. async function downloadOneDriveFile(
  192. fileInfo: OneDriveFileInfo,
  193. authorityType?: 'personal' | 'organizations'
  194. ): Promise<Blob> {
  195. const accessToken = await getToken(undefined, authorityType);
  196. if (!accessToken) {
  197. throw new Error('Unable to retrieve OneDrive access token.');
  198. }
  199. // The endpoint URL is provided in the file info
  200. const fileInfoUrl = `${fileInfo['@sharePoint.endpoint']}/drives/${fileInfo.parentReference.driveId}/items/${fileInfo.id}`;
  201. const response = await fetch(fileInfoUrl, {
  202. headers: {
  203. Authorization: `Bearer ${accessToken}`
  204. }
  205. });
  206. if (!response.ok) {
  207. throw new Error(`Failed to fetch file information: ${response.status} ${response.statusText}`);
  208. }
  209. const fileData = await response.json();
  210. const downloadUrl = fileData['@content.downloadUrl'];
  211. if (!downloadUrl) {
  212. throw new Error('Download URL not found in file data');
  213. }
  214. const downloadResponse = await fetch(downloadUrl);
  215. if (!downloadResponse.ok) {
  216. throw new Error(
  217. `Failed to download file: ${downloadResponse.status} ${downloadResponse.statusText}`
  218. );
  219. }
  220. return await downloadResponse.blob();
  221. }
  222. // Open OneDrive file picker and return selected file metadata
  223. export async function openOneDrivePicker(
  224. authorityType?: 'personal' | 'organizations'
  225. ): Promise<PickerResult | null> {
  226. if (typeof window === 'undefined') {
  227. throw new Error('Not in browser environment');
  228. }
  229. // Initialize OneDrive config with the specified authority type
  230. const config = OneDriveConfig.getInstance();
  231. await config.initialize(authorityType);
  232. return new Promise((resolve, reject) => {
  233. let pickerWindow: Window | null = null;
  234. let channelPort: MessagePort | null = null;
  235. const params = getPickerParams();
  236. const baseUrl = config.getBaseUrl();
  237. const handleWindowMessage = (event: MessageEvent) => {
  238. if (event.source !== pickerWindow) return;
  239. const message = event.data;
  240. if (message?.type === 'initialize' && message?.channelId === params.messaging.channelId) {
  241. channelPort = event.ports?.[0];
  242. if (!channelPort) return;
  243. channelPort.addEventListener('message', handlePortMessage);
  244. channelPort.start();
  245. channelPort.postMessage({ type: 'activate' });
  246. }
  247. };
  248. const handlePortMessage = async (portEvent: MessageEvent) => {
  249. const portData = portEvent.data;
  250. switch (portData.type) {
  251. case 'notification':
  252. break;
  253. case 'command': {
  254. channelPort?.postMessage({ type: 'acknowledge', id: portData.id });
  255. const command = portData.data;
  256. switch (command.command) {
  257. case 'authenticate': {
  258. try {
  259. // Pass the resource from the command for org accounts
  260. const resource =
  261. config.getAuthorityType() === 'organizations' ? command.resource : undefined;
  262. const newToken = await getToken(resource, authorityType);
  263. if (newToken) {
  264. channelPort?.postMessage({
  265. type: 'result',
  266. id: portData.id,
  267. data: { result: 'token', token: newToken }
  268. });
  269. } else {
  270. throw new Error('Could not retrieve auth token');
  271. }
  272. } catch (err) {
  273. channelPort?.postMessage({
  274. type: 'result',
  275. id: portData.id,
  276. data: {
  277. result: 'error',
  278. error: { code: 'tokenError', message: 'Failed to get token' }
  279. }
  280. });
  281. }
  282. break;
  283. }
  284. case 'close': {
  285. cleanup();
  286. resolve(null);
  287. break;
  288. }
  289. case 'pick': {
  290. channelPort?.postMessage({
  291. type: 'result',
  292. id: portData.id,
  293. data: { result: 'success' }
  294. });
  295. cleanup();
  296. resolve(command);
  297. break;
  298. }
  299. default: {
  300. channelPort?.postMessage({
  301. result: 'error',
  302. error: { code: 'unsupportedCommand', message: command.command },
  303. isExpected: true
  304. });
  305. break;
  306. }
  307. }
  308. break;
  309. }
  310. }
  311. };
  312. function cleanup() {
  313. window.removeEventListener('message', handleWindowMessage);
  314. if (channelPort) {
  315. channelPort.removeEventListener('message', handlePortMessage);
  316. }
  317. if (pickerWindow) {
  318. pickerWindow.close();
  319. pickerWindow = null;
  320. }
  321. }
  322. const initializePicker = async () => {
  323. try {
  324. const authToken = await getToken(undefined, authorityType);
  325. if (!authToken) {
  326. return reject(new Error('Failed to acquire access token'));
  327. }
  328. pickerWindow = window.open('', 'OneDrivePicker', 'width=800,height=600');
  329. if (!pickerWindow) {
  330. return reject(new Error('Failed to open OneDrive picker window'));
  331. }
  332. const queryString = new URLSearchParams({
  333. filePicker: JSON.stringify(params)
  334. });
  335. let url = '';
  336. if (config.getAuthorityType() === 'organizations') {
  337. url = baseUrl + `/_layouts/15/FilePicker.aspx?${queryString}`;
  338. } else {
  339. url = baseUrl + `?${queryString}`;
  340. }
  341. const form = pickerWindow.document.createElement('form');
  342. form.setAttribute('action', url);
  343. form.setAttribute('method', 'POST');
  344. const input = pickerWindow.document.createElement('input');
  345. input.setAttribute('type', 'hidden');
  346. input.setAttribute('name', 'access_token');
  347. input.setAttribute('value', authToken);
  348. form.appendChild(input);
  349. pickerWindow.document.body.appendChild(form);
  350. form.submit();
  351. window.addEventListener('message', handleWindowMessage);
  352. } catch (err) {
  353. if (pickerWindow) {
  354. pickerWindow.close();
  355. }
  356. reject(err);
  357. }
  358. };
  359. initializePicker();
  360. });
  361. }
  362. // Pick and download file from OneDrive
  363. export async function pickAndDownloadFile(
  364. authorityType?: 'personal' | 'organizations'
  365. ): Promise<{ blob: Blob; name: string } | null> {
  366. const pickerResult = await openOneDrivePicker(authorityType);
  367. if (!pickerResult || !pickerResult.items || pickerResult.items.length === 0) {
  368. return null;
  369. }
  370. const selectedFile = pickerResult.items[0];
  371. const blob = await downloadOneDriveFile(selectedFile, authorityType);
  372. return { blob, name: selectedFile.name };
  373. }
  374. export { downloadOneDriveFile };