/** * Microsoft Graph helper for uploading files to SharePoint * Uses client credentials flow (app-only authentication) */ interface SharePointConfig { tenantId: string; clientId: string; clientSecret: string; sharepointUrl: string; // Full SharePoint folder URL } interface UploadResult { success: boolean; webUrl?: string; error?: string; debugInfo?: string; } /** * Parse a SharePoint folder URL to extract site and drive path info * Supports URLs like: * https://tenant.sharepoint.com/sites/SiteName/Documents%20partag%C3%A9s/FolderPath * https://tenant.sharepoint.com/:f:/r/sites/SiteName/Documents%20partag%C3%A9s/FolderPath?... */ function parseSharePointUrl(url: string): { siteHostname: string; sitePath: string; folderPath: string } | null { try { const parsed = new URL(url); const hostname = parsed.hostname; // e.g. itinova.sharepoint.com // Remove query params and hash, keep only pathname let pathname = parsed.pathname; // Handle /:f:/r/sites/... format (sharing links) pathname = pathname.replace(/^\/:f:\/r/, ''); // Handle /:b:/r/ format pathname = pathname.replace(/^\/:b:\/r/, ''); // Decode URL encoding pathname = decodeURIComponent(pathname); // Extract site path: /sites/SiteName const siteMatch = pathname.match(/^(\/sites\/[^/]+)/i); if (!siteMatch) { console.warn('[SharePoint] Impossible de trouver /sites/ dans le chemin:', pathname); return null; } const sitePath = siteMatch[1]; // e.g. /sites/ITINOVA-27.Factures // Everything after the site path is the folder path within the drive const folderPath = pathname.slice(sitePath.length); // e.g. /Documents partages/27. Factures/test console.log('[SharePoint] URL parsée:', { hostname, sitePath, folderPath }); return { siteHostname: hostname, sitePath, folderPath }; } catch (e) { console.error('[SharePoint] Erreur parsing URL:', e); return null; } } /** * Get an access token using client credentials flow */ async function getAccessToken(tenantId: string, clientId: string, clientSecret: string): Promise { const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`; const body = new URLSearchParams({ grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret, scope: 'https://graph.microsoft.com/.default', }); const response = await fetch(tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), }); if (!response.ok) { const error = await response.text(); throw new Error(`Failed to get access token: ${error}`); } const data = await response.json() as { access_token: string; error?: string; error_description?: string }; if ((data as any).error) { throw new Error(`Token error: ${(data as any).error_description || (data as any).error}`); } return data.access_token; } /** * Find the correct drive (document library) that contains the given folder path * SharePoint sites can have multiple drives (Shared Documents, Documents partages, etc.) */ async function findDriveAndFolder( graphBase: string, headers: Record, siteId: string, folderPath: string ): Promise<{ driveId: string; cleanFolderPath: string } | null> { // Get all drives for the site const drivesResponse = await fetch(`${graphBase}/sites/${siteId}/drives`, { headers }); if (!drivesResponse.ok) { console.warn('[SharePoint] Impossible de lister les drives:', await drivesResponse.text()); return null; } const drivesData = await drivesResponse.json() as { value: Array<{ id: string; name: string; webUrl: string }> }; const drives = drivesData.value || []; console.log('[SharePoint] Drives disponibles:', drives.map(d => ({ id: d.id, name: d.name }))); // Le folderPath commence par le nom de la bibliothèque de documents // ex: "/Documents partages/27. Factures/test" → bibliothèque "Documents partages", sous-dossier "27. Factures/test" // ex: "/Documents partag\u00e9s/..." → même chose avec accent const pathParts = folderPath.replace(/^\//, '').split('/'); const libraryName = pathParts[0]; // premier segment = nom de la bibliothèque const subFolderPath = pathParts.slice(1).join('/'); // reste = sous-dossiers console.log('[SharePoint] Recherche bibliothèque:', libraryName, '| Sous-dossier:', subFolderPath); // Chercher le drive qui correspond au nom de la bibliothèque (insensible à la casse et aux accents) const normalize = (s: string) => s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, ''); const normalizedLibrary = normalize(libraryName); let matchedDrive = drives.find(d => normalize(d.name) === normalizedLibrary); // Si pas trouvé par nom exact, essayer "Documents" (bibliothèque par défaut) if (!matchedDrive) { matchedDrive = drives.find(d => normalize(d.name).includes('document') || normalize(d.name) === 'documents' ); console.log('[SharePoint] Bibliothèque non trouvée par nom exact, fallback sur:', matchedDrive?.name); } if (!matchedDrive) { // Utiliser le premier drive disponible matchedDrive = drives[0]; console.log('[SharePoint] Utilisation du premier drive:', matchedDrive?.name); } if (!matchedDrive) return null; return { driveId: matchedDrive.id, cleanFolderPath: subFolderPath, }; } /** * Upload a file to SharePoint using Microsoft Graph API */ export async function uploadToSharePoint( config: SharePointConfig, fileBuffer: Buffer, fileName: string ): Promise { const debugLines: string[] = []; try { const parsed = parseSharePointUrl(config.sharepointUrl); if (!parsed) { return { success: false, error: `URL SharePoint invalide ou non reconnue: ${config.sharepointUrl}` }; } const { siteHostname, sitePath, folderPath } = parsed; debugLines.push(`Site: ${siteHostname}${sitePath}`); debugLines.push(`Dossier: ${folderPath}`); // Get access token const accessToken = await getAccessToken(config.tenantId, config.clientId, config.clientSecret); debugLines.push('Token OAuth2: OK'); const graphBase = 'https://graph.microsoft.com/v1.0'; const headers = { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }; // 1. Get the site ID const siteUrl = `${graphBase}/sites/${siteHostname}:${sitePath}`; console.log('[SharePoint] Récupération site:', siteUrl); const siteResponse = await fetch(siteUrl, { headers }); if (!siteResponse.ok) { const err = await siteResponse.text(); debugLines.push(`Erreur site (${siteResponse.status}): ${err.slice(0, 200)}`); return { success: false, error: `Site SharePoint introuvable (${siteResponse.status}): ${err.slice(0, 300)}`, debugInfo: debugLines.join('\n') }; } const site = await siteResponse.json() as { id: string; displayName?: string }; const siteId = site.id; debugLines.push(`Site ID: ${siteId} (${site.displayName || ''})`); console.log('[SharePoint] Site trouvé:', siteId); // 2. Find the correct drive and folder path const driveInfo = await findDriveAndFolder(graphBase, headers, siteId, folderPath); let driveId: string; let cleanFolderPath: string; if (driveInfo) { driveId = driveInfo.driveId; cleanFolderPath = driveInfo.cleanFolderPath; debugLines.push(`Drive ID: ${driveId}`); debugLines.push(`Chemin dans le drive: ${cleanFolderPath || '(racine)'}`); } else { // Fallback: use default drive const driveResponse = await fetch(`${graphBase}/sites/${siteId}/drive`, { headers }); if (!driveResponse.ok) { const err = await driveResponse.text(); return { success: false, error: `Drive SharePoint introuvable: ${err.slice(0, 300)}`, debugInfo: debugLines.join('\n') }; } const drive = await driveResponse.json() as { id: string }; driveId = drive.id; // Use full folderPath without leading slash cleanFolderPath = folderPath.replace(/^\//, '').replace(/\/$/, ''); debugLines.push(`Drive ID (fallback): ${driveId}`); } // 3. Build upload URL const encodedFileName = encodeURIComponent(fileName); let uploadUrl: string; if (cleanFolderPath) { const encodedFolder = cleanFolderPath.split('/').map(encodeURIComponent).join('/'); uploadUrl = `${graphBase}/drives/${driveId}/root:/${encodedFolder}/${encodedFileName}:/content`; } else { // Upload to root of drive uploadUrl = `${graphBase}/drives/${driveId}/root:/${encodedFileName}:/content`; } console.log('[SharePoint] URL upload:', uploadUrl); debugLines.push(`URL upload: ${uploadUrl}`); // 4. Upload the file (simple upload for files < 4MB, resumable for larger) if (fileBuffer.length < 4 * 1024 * 1024) { // Simple upload const uploadResponse = await fetch(uploadUrl, { method: 'PUT', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/pdf', }, body: new Uint8Array(fileBuffer), }); if (!uploadResponse.ok) { const err = await uploadResponse.text(); debugLines.push(`Erreur upload (${uploadResponse.status}): ${err.slice(0, 300)}`); console.error('[SharePoint] Erreur upload:', uploadResponse.status, err.slice(0, 500)); return { success: false, error: `Échec de l'upload SharePoint (${uploadResponse.status}): ${err.slice(0, 300)}`, debugInfo: debugLines.join('\n') }; } const uploaded = await uploadResponse.json() as { webUrl?: string; name?: string }; console.log('[SharePoint] Upload réussi:', uploaded.webUrl); debugLines.push(`Fichier déposé: ${uploaded.webUrl}`); return { success: true, webUrl: uploaded.webUrl, debugInfo: debugLines.join('\n') }; } else { // Resumable upload for large files const createSessionUrl = uploadUrl.replace(':/content', ':/createUploadSession'); const sessionResponse = await fetch(createSessionUrl, { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ item: { '@microsoft.graph.conflictBehavior': 'replace' } }), }); if (!sessionResponse.ok) { const err = await sessionResponse.text(); return { success: false, error: `Échec de création de session d'upload: ${err.slice(0, 300)}`, debugInfo: debugLines.join('\n') }; } const session = await sessionResponse.json() as { uploadUrl: string }; const chunkSize = 320 * 1024 * 10; // 3.2MB chunks let start = 0; while (start < fileBuffer.length) { const end = Math.min(start + chunkSize, fileBuffer.length); const chunk = fileBuffer.slice(start, end); const chunkResponse = await fetch(session.uploadUrl, { method: 'PUT', headers: { 'Content-Range': `bytes ${start}-${end - 1}/${fileBuffer.length}`, 'Content-Length': chunk.length.toString(), }, body: new Uint8Array(chunk), }); if (!chunkResponse.ok && chunkResponse.status !== 202) { const err = await chunkResponse.text(); return { success: false, error: `Échec de l'upload du chunk: ${err.slice(0, 300)}`, debugInfo: debugLines.join('\n') }; } if (chunkResponse.status === 201 || chunkResponse.status === 200) { const uploaded = await chunkResponse.json() as { webUrl?: string }; return { success: true, webUrl: uploaded.webUrl, debugInfo: debugLines.join('\n') }; } start = end; } return { success: false, error: 'Upload incomplet', debugInfo: debugLines.join('\n') }; } } catch (error) { const msg = error instanceof Error ? error.message : String(error); console.error('[SharePoint] Exception:', msg); debugLines.push(`Exception: ${msg}`); return { success: false, error: `Erreur Microsoft Graph: ${msg}`, debugInfo: debugLines.join('\n') }; } }