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

102 lines
4.7 KiB
C#

using Azure.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Graph;
using SIFinance.Shared;
using SIFinance.Shared.Entities;
namespace SIFinance.Api;
public record SharePointSiteDto(string Id, string Name, string? WebUrl);
public record SharePointUploadDto(string WebUrl, string ItemId, long Size, string Nom);
// Endpoints de découverte SharePoint + upload de documents dans les dossiers modules
// (Achats / Sous-Traitants / Contrat Etudiant) du drive configuré (SharePoint:DriveId).
public static class SharePointEndpoints
{
// Whitelist stricte plutôt que d'accepter n'importe quel nom de dossier envoyé par le
// client — évite qu'un chemin arbitraire soit construit dans l'URL Graph.
private static readonly HashSet<string> DossiersAutorises =
new(StringComparer.OrdinalIgnoreCase) { "Achats", "Sous-Traitants", "Contrat Etudiant" };
public static IEndpointRouteBuilder MapSharePointEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/sharepoint").WithTags("SharePoint").RequireAuthorization();
group.MapGet("/sites", async (SharePointGraphClientFactory graphFactory, CancellationToken ct) =>
{
try
{
var graph = graphFactory.Create();
var result = await graph.Sites.GetAsync(cfg => cfg.QueryParameters.Search = "*", ct);
var sites = (result?.Value ?? [])
.Select(s => new SharePointSiteDto(s.Id ?? "", s.Name ?? s.DisplayName ?? "(sans nom)", s.WebUrl))
.ToList();
return Results.Ok(sites);
}
catch (Exception ex)
{
// Cas fréquents : secret pas encore renseigné, permission Sites.Read.All
// absente, ou admin consent pas encore accordé (403).
return Results.BadRequest(new
{
error = "Impossible de récupérer la liste des sites SharePoint — vérifie les identifiants et les permissions (Sites.Read.All ou Sites.ReadWrite.All, avec admin consent).",
detail = ex.Message,
});
}
});
group.MapPost("/upload", async (
HttpRequest request, SharePointUploadService uploadService,
SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) =>
{
if (!request.HasFormContentType)
return Results.BadRequest(new { error = "Contenu multipart attendu." });
var form = await request.ReadFormAsync(ct);
var file = form.Files.GetFile("file");
var dossier = form["dossier"].ToString();
var service = form["service"].ToString();
if (file is null || file.Length == 0)
return Results.BadRequest(new { error = "Fichier manquant." });
if (!DossiersAutorises.Contains(dossier))
return Results.BadRequest(new { error = "Dossier de destination invalide." });
// Le sous-dossier "collaborateur" est résolu depuis l'utilisateur authentifié,
// jamais depuis une valeur envoyée par le client — sinon n'importe qui pourrait
// faire apparaître son upload dans le dossier d'un collègue.
var userId = await currentUser.GetOrCreateCurrentUserIdAsync(ct);
var user = await db.Utilisateurs.AsNoTracking().FirstOrDefaultAsync(u => u.Id == userId, ct);
var collaborateur = user is null ? "Autre" : $"{user.Prenom} {user.Nom}";
var segments = new List<string> { dossier };
if (!string.IsNullOrWhiteSpace(service)) segments.Add(SanitizeSegment(service));
segments.Add(SanitizeSegment(collaborateur));
var cheminDossier = string.Join('/', segments);
try
{
await using var stream = file.OpenReadStream();
var result = await uploadService.UploadAsync(cheminDossier, file.FileName, stream, file.ContentType, ct);
return Results.Ok(new SharePointUploadDto(result.WebUrl, result.ItemId, result.Size, file.FileName));
}
catch (Exception ex)
{
return Results.BadRequest(new { error = "Échec de l'upload vers SharePoint.", detail = ex.Message });
}
});
return app;
}
private static string SanitizeSegment(string value)
{
var trimmed = value.Trim();
if (trimmed.Length == 0) return "Autre";
var invalides = new[] { '"', '*', ':', '<', '>', '?', '/', '\\', '|' };
var nettoye = new string(trimmed.Select(c => invalides.Contains(c) ? '-' : c).ToArray());
return nettoye.Length == 0 ? "Autre" : nettoye;
}
}