Files
SIFINANCE/SIFinance.Api/SharePointUploadService.cs
2026-09-11 14:55:41 +02:00

81 lines
4.3 KiB
C#

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<SharePointOptions> 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<SharePointUploadResult> 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<string?> 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;
}
}
}