209 lines
7.0 KiB
TypeScript
209 lines
7.0 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* Parse a SharePoint folder URL to extract site and drive path info
|
|
* Supports URLs like:
|
|
* https://tenant.sharepoint.com/sites/SiteName/Documents%20partages/FolderPath
|
|
* https://tenant.sharepoint.com/:f:/r/sites/SiteName/Documents%20partages/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
|
|
let pathname = parsed.pathname;
|
|
|
|
// Handle /:f:/r/sites/... format (sharing links)
|
|
pathname = pathname.replace(/^\/:f:\/r/, '');
|
|
|
|
// Decode URL encoding
|
|
pathname = decodeURIComponent(pathname);
|
|
|
|
// Extract site path: /sites/SiteName
|
|
const siteMatch = pathname.match(/^(\/sites\/[^/]+)/i);
|
|
if (!siteMatch) 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
|
|
|
|
return { siteHostname: hostname, sitePath, folderPath };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get an access token using client credentials flow
|
|
*/
|
|
async function getAccessToken(tenantId: string, clientId: string, clientSecret: string): Promise<string> {
|
|
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 };
|
|
return data.access_token;
|
|
}
|
|
|
|
/**
|
|
* Upload a file to SharePoint using Microsoft Graph API
|
|
*/
|
|
export async function uploadToSharePoint(
|
|
config: SharePointConfig,
|
|
fileBuffer: Buffer,
|
|
fileName: string
|
|
): Promise<UploadResult> {
|
|
try {
|
|
const parsed = parseSharePointUrl(config.sharepointUrl);
|
|
if (!parsed) {
|
|
return { success: false, error: `URL SharePoint invalide: ${config.sharepointUrl}` };
|
|
}
|
|
|
|
const { siteHostname, sitePath, folderPath } = parsed;
|
|
|
|
// Get access token
|
|
const accessToken = await getAccessToken(config.tenantId, config.clientId, config.clientSecret);
|
|
|
|
const graphBase = 'https://graph.microsoft.com/v1.0';
|
|
const headers = {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
'Content-Type': 'application/json',
|
|
};
|
|
|
|
// 1. Get the site ID
|
|
const siteResponse = await fetch(
|
|
`${graphBase}/sites/${siteHostname}:${sitePath}`,
|
|
{ headers }
|
|
);
|
|
if (!siteResponse.ok) {
|
|
const err = await siteResponse.text();
|
|
return { success: false, error: `Site SharePoint introuvable (${siteResponse.status}): ${err}` };
|
|
}
|
|
const site = await siteResponse.json() as { id: string };
|
|
const siteId = site.id;
|
|
|
|
// 2. Get the 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}` };
|
|
}
|
|
const drive = await driveResponse.json() as { id: string };
|
|
const driveId = drive.id;
|
|
|
|
// 3. Build the folder path for the upload URL
|
|
// folderPath is like "/Documents partages/27. Factures/test"
|
|
// We need to encode it properly for Graph API
|
|
const cleanFolder = folderPath.replace(/^\//, '').replace(/\/$/, '');
|
|
const encodedFolder = cleanFolder.split('/').map(encodeURIComponent).join('/');
|
|
const encodedFileName = encodeURIComponent(fileName);
|
|
|
|
// 4. Upload the file (simple upload for files < 4MB, resumable for larger)
|
|
const uploadUrl = fileBuffer.length < 4 * 1024 * 1024
|
|
? `${graphBase}/drives/${driveId}/root:/${encodedFolder}/${encodedFileName}:/content`
|
|
: null;
|
|
|
|
if (uploadUrl) {
|
|
// 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();
|
|
return { success: false, error: `Échec de l'upload (${uploadResponse.status}): ${err}` };
|
|
}
|
|
|
|
const uploaded = await uploadResponse.json() as { webUrl?: string };
|
|
return { success: true, webUrl: uploaded.webUrl };
|
|
} else {
|
|
// Resumable upload for large files
|
|
const createSessionUrl = `${graphBase}/drives/${driveId}/root:/${encodedFolder}/${encodedFileName}:/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}` };
|
|
}
|
|
|
|
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}` };
|
|
}
|
|
|
|
if (chunkResponse.status === 201 || chunkResponse.status === 200) {
|
|
const uploaded = await chunkResponse.json() as { webUrl?: string };
|
|
return { success: true, webUrl: uploaded.webUrl };
|
|
}
|
|
|
|
start = end;
|
|
}
|
|
|
|
return { success: false, error: 'Upload incomplet' };
|
|
}
|
|
} catch (error) {
|
|
return { success: false, error: `Erreur Microsoft Graph: ${error instanceof Error ? error.message : String(error)}` };
|
|
}
|
|
}
|