using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using Microsoft.Extensions.Options; using SIFinance.Shared; namespace SIFinance.Api; public record SharePointUploadResult(string WebUrl, string ItemId, long Size); // Dépose un fichier dans un sous-dossier du drive SharePoint configuré (SharePoint:DriveId) // — un dossier par module (Achats / Sous-Traitants / Contrat Etudiant), créés lors du // provisioning du site. Réutilise le jeton Graph "app-only" déjà mis en cache par // EntraGraphTokenProvider plutôt que ClientSecretCredential/GraphServiceClient : plus // simple pour un simple PUT de contenu, et le jeton couvre déjà Sites.ReadWrite.All. public class SharePointUploadService( IHttpClientFactory httpClientFactory, EntraGraphTokenProvider tokenProvider, IOptions options) { // Upload "simple" (PUT direct) : suffisant jusqu'à 4 Mo, ce qui couvre largement les // justificatifs (devis, factures, bons de commande scannés). Au-delà, Graph exige une // session d'upload par blocs — pas implémenté ici, hors scope du premier jet. // cheminDossier peut contenir plusieurs segments (ex: "Achats/Informatique/Jean Dupont") — // Graph crée automatiquement les dossiers intermédiaires manquants lors d'un upload // vers un chemin qui n'existe pas encore, pas besoin de les créer explicitement. public async Task UploadAsync(string cheminDossier, string fileName, Stream content, string? contentType, CancellationToken ct) { var opts = options.Value; if (string.IsNullOrWhiteSpace(opts.DriveId)) throw new InvalidOperationException("Configuration SharePoint incomplète (DriveId) — voir appsettings.Development.json."); var token = await tokenProvider.GetTokenAsync(ct); var http = httpClientFactory.CreateClient(); http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); var safeName = Uri.EscapeDataString(fileName); var url = $"https://graph.microsoft.com/v1.0/drives/{opts.DriveId}/root:/{cheminDossier}/{safeName}:/content?@microsoft.graph.conflictBehavior=rename"; using var streamContent = new StreamContent(content); streamContent.Headers.ContentType = new MediaTypeHeaderValue(string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType); var response = await http.PutAsync(url, streamContent, ct); var body = await response.Content.ReadAsStringAsync(ct); if (!response.IsSuccessStatusCode) throw new InvalidOperationException($"Échec de l'upload SharePoint ({response.StatusCode}) : {body}"); var json = JsonDocument.Parse(body).RootElement; var itemWebUrl = json.GetProperty("webUrl").GetString()!; var itemId = json.GetProperty("id").GetString()!; var size = json.TryGetProperty("size", out var sizeProp) ? sizeProp.GetInt64() : content.Length; // L'URL brute de l'item n'est accessible qu'aux membres du groupe SharePoint // sous-jacent — un lien de partage "organisation" permet à n'importe quel // utilisateur du tenant, connecté avec son compte Microsoft, de l'ouvrir sans // avoir besoin d'être membre du groupe. On le préfère donc à l'URL brute quand // Graph accepte de le créer (si ça échoue, on retombe sur l'URL brute plutôt // que de faire échouer tout l'upload). var lienOrganisation = await CreerLienOrganisationAsync(opts.DriveId, itemId, http, ct); return new SharePointUploadResult(lienOrganisation ?? itemWebUrl, itemId, size); } private static async Task CreerLienOrganisationAsync(string driveId, string itemId, HttpClient http, CancellationToken ct) { try { var response = await http.PostAsJsonAsync( $"https://graph.microsoft.com/v1.0/drives/{driveId}/items/{itemId}/createLink", new { type = "view", scope = "organization" }, ct); if (!response.IsSuccessStatusCode) return null; var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)).RootElement; return json.GetProperty("link").GetProperty("webUrl").GetString(); } catch { return null; } } }