using System; using System.Collections.Generic; using System.Drawing; using System.IO; using System.Linq; using System.Net.Http; using System.Threading.Tasks; using Manager.DTOs; using Manager.Services; using ManagerService.Data; using ManagerService.Data.SubSection; using ManagerService.DTOs; using ManagerService.Helpers; using ManagerService.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using NSwag.Annotations; namespace ManagerService.Controllers { [Authorize(Policy = ManagerService.Service.Security.Policies.ContentEditor)] [ApiController, Route("api/[controller]")] [OpenApiTag("Resource", Description = "Resource management")] public class ResourceController : ControllerBase { private readonly MyInfoMateDbContext _myInfoMateDbContext; private ResourceDatabaseService _resourceService; private SectionDatabaseService _sectionService; private ConfigurationDatabaseService _configurationService; private readonly ILogger _logger; private readonly IHttpClientFactory _httpClientFactory; private readonly IResourceBlobService _blobService; IHexIdGeneratorService idService = new HexIdGeneratorService(); private static int MaxWidth = 1024; private static int MaxHeight = 1024; public ResourceController(ILogger logger, ResourceDatabaseService resourceService, SectionDatabaseService sectionService, ConfigurationDatabaseService configurationService, MyInfoMateDbContext myInfoMateDbContext, IHttpClientFactory httpClientFactory, IResourceBlobService blobService) { _logger = logger; _resourceService = resourceService; _sectionService = sectionService; _configurationService = configurationService; _myInfoMateDbContext = myInfoMateDbContext; _httpClientFactory = httpClientFactory; _blobService = blobService; } /// /// Get a list of all resources (summary) /// /// id instance /// types of resource [ProducesResponseType(typeof(List), 200)] [ProducesResponseType(typeof(string), 500)] [HttpGet] public ObjectResult Get([FromQuery] string instanceId, [FromQuery] List types) { try { if (instanceId == null) throw new ArgumentNullException("InstanceId needed"); List resources = new List(); if (types.Count > 0) { resources = _myInfoMateDbContext.Resources.Where(r => r.InstanceId == instanceId && types.Contains(r.Type)).ToList(); //resources = _resourceService.GetAllByType(instanceId, types); } else { resources = _myInfoMateDbContext.Resources.Where(r => r.InstanceId == instanceId).ToList(); //resources = _resourceService.GetAll(instanceId); } List resourceDTOs = new List(); foreach(var resource in resources) { ResourceDTO resourceDTO = new ResourceDTO(); resourceDTO = resource.ToDTO(); resourceDTOs.Add(resourceDTO); } return new OkObjectResult(resourceDTOs.OrderByDescending(r => r.dateCreation)); } catch (Exception ex) { return new ObjectResult(ex.Message) { StatusCode = 500 }; } } /// /// Get a specific resource /// /// id resource [AllowAnonymous] [ProducesResponseType(typeof(ResourceDTO), 200)] [ProducesResponseType(typeof(string), 404)] [ProducesResponseType(typeof(string), 500)] [HttpGet("{id}/detail")] public ObjectResult GetDetail(string id) { try { Resource resource = _myInfoMateDbContext.Resources.FirstOrDefault(r => r.Id == id); //Resource resource = _resourceService.GetById(id); if (resource == null) throw new KeyNotFoundException("This resource was not found"); ResourceDTO resourceDTO = new ResourceDTO(); resourceDTO = resource.ToDTO(); /*if (resource.Type == ResourceType.ImageUrl) { var resourceData = _resourceDataService.GetByResourceId(resource.Id); resourceDTO.data = resourceData != null ? resourceData.Data : null; }*/ // RESIZE IMAGE /*byte[] imageBytes = Convert.FromBase64String(resourceData.Data); using (MemoryStream originalImageMemoryStream = new MemoryStream(imageBytes)) { using (Image image = Image.FromStream(originalImageMemoryStream)) { var width = image.Width; var height = image.Height; if (image.Width > MaxWidth || image.Height > MaxHeight) { Size newSize = ImageResizer.ResizeKeepAspect(image.Size, MaxWidth, MaxHeight); byte[] resizedImage = ImageResizer.ResizeImage(image, newSize.Width, newSize.Height, image.Width, image.Height); resourceData.Data = Convert.ToBase64String(resizedImage); ResourceData resourceModified = _resourceDataService.Update(resourceData.Id, resourceData); } } }*/ return new OkObjectResult(resourceDTO); } catch (KeyNotFoundException ex) { return new NotFoundObjectResult(ex.Message) {}; } catch (Exception ex) { return new ObjectResult(ex.Message) { StatusCode = 500 }; } } /// /// Show a specific resource (as a picture or video stream) /// /// id resource [AllowAnonymous] [ProducesResponseType(typeof(FileResult), 200)] [ProducesResponseType(typeof(string), 404)] [ProducesResponseType(typeof(string), 500)] [HttpGet("{id}")] public ActionResult Show(string id) { try { Resource resource = _myInfoMateDbContext.Resources.FirstOrDefault(r => r.Id == id); //OldResource resource = _resourceService.GetById(id); if (resource == null) throw new KeyNotFoundException("This resource was not found"); //var file = Convert.FromBase64String(resourceData.Data); // RESIZE IMAGE /*using (MemoryStream originalImageMemoryStream = new MemoryStream(file)) { using (Image image = Image.FromStream(originalImageMemoryStream)) { var width = image.Width; var height = image.Height; if(image.Width > MaxWidth || image.Height > MaxHeight) { Size newSize = ImageResizer.ResizeKeepAspect(image.Size, MaxWidth, MaxHeight); byte[] resizedImage = ImageResizer.ResizeImage(image, newSize.Width, newSize.Height, image.Width, image.Height); resourceData.Data = Convert.ToBase64String(resizedImage); ResourceData resourceModified = _resourceDataService.Update(resourceData.Id, resourceData); } } }*/ /*if (resource.Type == ResourceType.Image) { return new FileContentResult(file, "image/png") { FileDownloadName = resource.Label + ".png" }; } if (resource.Type == ResourceType.Video || resource.Type == ResourceType.Audio) { return new FileContentResult(file, "application/octet-stream") { FileDownloadName = resource.Type == ResourceType.Audio ? resource.Label + ".mp3" : resource.Label + ".mp4", }; } return new FileContentResult(file, "image/png");*/ return new NotFoundObjectResult("No more supported") { }; } catch (KeyNotFoundException ex) { return new NotFoundObjectResult(ex.Message) { }; } catch (Exception ex) { return new ObjectResult(ex.Message) { StatusCode = 500 }; } } // OLD VERSION /// /// Upload a specific resource (picture or video) /// [ProducesResponseType(typeof(string), 200)] [ProducesResponseType(typeof(string), 404)] [ProducesResponseType(typeof(string), 500)] [HttpPost("upload"), DisableRequestSizeLimit] public IActionResult Upload([FromForm] string label, [FromForm] string type, [FromForm] string instanceId) // Create but with local //[FromBody] ResourceDetailDTO uploadResource { try { if (label == null || type == null || instanceId == null) throw new ArgumentNullException("One of resource params is null"); var resourceType = (ResourceType)Enum.Parse(typeof(ResourceType), type); List resources = new List(); var incomingBytes = Request.Form.Files.Sum(f => f.Length); if (ExceedsStorageQuota(instanceId, incomingBytes)) return new ObjectResult("Quota de stockage dépassé") { StatusCode = 413 }; foreach (var file in Request.Form.Files) { if (file.Length > 0) { var stringResult = ""; double fileSizeibMbs = (double) ((double)file.Length) / (1024*1024); if (fileSizeibMbs <= 1.5 || resourceType == ResourceType.Image) { using (var ms = new MemoryStream()) { file.CopyTo(ms); var fileBytes = ms.ToArray(); if (resourceType == ResourceType.Image && IsWatermarkEnabled(instanceId)) { fileBytes = ImageHelper.ResizeAndAddWatermark(fileBytes, true, MaxWidth, MaxHeight); } stringResult = Convert.ToBase64String(fileBytes); } } else { throw new FileLoadException(message: "Fichier inexistant ou trop volumineux (max 4Mb)"); } // Todo add some verification ? Resource resource = new Resource(); resource.Label = label; resource.Type = resourceType; resource.DateCreation = DateTime.Now.ToUniversalTime(); resource.InstanceId = instanceId; resource.Id = idService.GenerateHexId(); ResourceStorage.Apply(resource, file.Length); _myInfoMateDbContext.Add(resource); _myInfoMateDbContext.SaveChanges(); //Resource resourceCreated = _resourceService.Create(resource); resources.Add(resource); } } return Ok(resources.Select(r => r.ToDTO())); } catch (ArgumentNullException ex) { return new BadRequestObjectResult(ex.Message) { }; } catch (FileLoadException ex) { return new BadRequestObjectResult(ex.Message) { }; } catch (InvalidOperationException ex) { return new ConflictObjectResult(ex.Message) { }; } catch (Exception ex) { return new ObjectResult(ex.Message) { StatusCode = 500 }; } } /// /// Create a new resource /// /// New resource info [ProducesResponseType(typeof(ResourceDTO), 200)] [ProducesResponseType(typeof(string), 400)] [ProducesResponseType(typeof(string), 409)] [ProducesResponseType(typeof(string), 500)] [HttpPost] public ObjectResult Create([FromBody] ResourceDTO newResource) { try { if (newResource == null) throw new ArgumentNullException("Resource param is null"); // Le pré-vol manquait sur ce chemin : manager-app crée la ligne par JSON // en annonçant sizeBytes, PUIS téléverse le blob. Sans contrôle ici, tout // dépassement passait par la porte de service — seul le chemin multipart // était gardé. if (ResourceStorage.HasBlob(newResource.type) && ExceedsStorageQuota(newResource.instanceId, newResource.sizeBytes)) return new ObjectResult("Quota de stockage dépassé") { StatusCode = 413 }; // Todo add some verification ? Resource resource = new Resource(); resource.InstanceId = newResource.instanceId; resource.Label = newResource.label; resource.Type = newResource.type; resource.Url = newResource.url; resource.DateCreation = DateTime.Now.ToUniversalTime(); //resource.Data = newResource.data; resource.InstanceId = newResource.instanceId; resource.Id = idService.GenerateHexId(); ResourceStorage.Apply(resource, newResource.sizeBytes); _myInfoMateDbContext.Add(resource); _myInfoMateDbContext.SaveChanges(); //OldResource resourceCreated = _resourceService.Create(resource); return new OkObjectResult(resource.ToDTO()); // WITHOUT DATA } catch (ArgumentNullException ex) { return new BadRequestObjectResult(ex.Message) {}; } catch (InvalidOperationException ex) { return new ConflictObjectResult(ex.Message) {}; } catch (Exception ex) { return new ObjectResult(ex.Message) { StatusCode = 500 }; } } /// /// Update a resource /// /// Resource to update [ProducesResponseType(typeof(ResourceDTO), 200)] [ProducesResponseType(typeof(string), 400)] [ProducesResponseType(typeof(string), 404)] [ProducesResponseType(typeof(string), 500)] [HttpPut] public ObjectResult Update([FromBody] ResourceDTO updatedResource) { try { if (updatedResource == null) throw new ArgumentNullException("Resource param is null"); //OldResource resource = _resourceService.GetById(updatedResource.id); Resource resource = _myInfoMateDbContext.Resources.FirstOrDefault(r => r.Id == updatedResource.id); if (resource == null) throw new KeyNotFoundException("Resource does not exist"); // Todo add some verification ? resource.InstanceId = updatedResource.instanceId != null ? updatedResource.instanceId : resource.InstanceId; resource.Label = updatedResource.label != null ? updatedResource.label : resource.Label; resource.Type = updatedResource.type != null ? updatedResource.type : resource.Type; resource.Url = updatedResource.url != null ? updatedResource.url : resource.Url; if (updatedResource.sizeBytes > 0) resource.SizeBytes = updatedResource.sizeBytes; // Angle mort laissé ouvert par C1, tranché ici : un Update peut faire passer // Type d'un type URL à un type fichier, laissant StoragePath nul sur une ligne // qui a désormais un blob. La crainte d'alors — « recalculer pointerait vers un // objet qui n'a pas bougé » — ne tient pas : PathFor ne construit qu'un // pictures/{instanceId}/{resourceId}, le type n'entre pas dans le chemin, il // décide seulement s'il y en a un. Recalculer est donc sans risque. ResourceStorage.Apply(resource, resource.SizeBytes); if (!ResourceStorage.HasBlob(resource.Type)) { resource.StoragePath = null; resource.SizeBytes = 0; } _myInfoMateDbContext.SaveChanges(); //OldResource resourceModified = _resourceService.Update(updatedResource.id, resource); return new OkObjectResult(resource.ToDTO()); } catch (ArgumentNullException ex) { return new BadRequestObjectResult(ex.Message) {}; } catch (KeyNotFoundException ex) { return new NotFoundObjectResult(ex.Message) {}; } catch (Exception ex) { return new ObjectResult(ex.Message) { StatusCode = 500 }; } } /// /// Delete a resource /// /// Id of resource to delete [ProducesResponseType(typeof(string), 202)] [ProducesResponseType(typeof(string), 400)] [ProducesResponseType(typeof(string), 404)] [ProducesResponseType(typeof(string), 500)] [ProducesResponseType(typeof(string), 502)] [HttpDelete("{id}")] public async Task Delete(string id) { try { if (id == null) throw new ArgumentNullException("Resource param is null"); //var ressource = _resourceService.GetById(id); Resource resource = _myInfoMateDbContext.Resources.FirstOrDefault(r => r.Id == id); if (resource == null) throw new KeyNotFoundException("Resource does not exist"); // Le blob part AVANT la ligne. manager-app faisait l'inverse et avalait // l'échec : la ligne disparaissait, le blob restait, et n'ayant plus de // ligne il devenait invisible au quota tout en restant facturé. Ici un // échec interrompt : mieux vaut une ressource encore listée qu'un orphelin // que plus rien ne désigne. var storagePath = resource.StoragePath ?? ResourceStorage.PathFor(resource.Type, resource.InstanceId, resource.Id); var outcome = await _blobService.DeleteAsync(storagePath); if (outcome == BlobDeleteOutcome.Failed) return new ObjectResult("Le fichier n'a pas pu être supprimé du stockage — la ressource est conservée") { StatusCode = 502 }; List configurations = _myInfoMateDbContext.Configurations.Where(c => c.InstanceId == resource.InstanceId).ToList(); foreach (var configuration in configurations) { if (configuration.ImageId == id) { configuration.ImageId = null; configuration.ImageSource = null; } } List
sections = _myInfoMateDbContext.Sections.Where(s => s.InstanceId == resource.InstanceId).ToList(); // Delete all resource occurence foreach (var section in sections) { if (section.ImageId == id) { section.ImageId = null; section.ImageSource = null; } switch (section) { case SectionMap map: map.IconResourceId = map.IconResourceId == id ? null : map.IconResourceId; List geoPoints = _myInfoMateDbContext.GeoPoints.Where(s => s.SectionMapId == section.Id).ToList(); foreach (var point in geoPoints) { point.ImageResourceId = point.ImageResourceId == id ? null : point.ImageResourceId; foreach (var content in point.Contents) { if (content.resourceId == id) { content.resourceId = null; content.resource = null; _myInfoMateDbContext.Entry(point).Property(p => p.Contents).IsModified = true; } } } if (map.MapCategories != null) { foreach (var categorie in map.MapCategories) { if (categorie.resourceDTO.id == id) { categorie.resourceDTO = null; _myInfoMateDbContext.Entry(map).Property(p => p.MapCategories).IsModified = true; } } } break; case SectionSlider slider: foreach (var content in slider.SliderContents) { if (content.resourceId == id) { content.resource = null; content.resourceId = null; _myInfoMateDbContext.Entry(slider).Property(p => p.SliderContents).IsModified = true; } } break; case SectionQuiz quiz: //QuizDTO quizzDTO = JsonConvert.DeserializeObject(section.Data); List quizQuestions = _myInfoMateDbContext.QuizQuestions.Where(qq => qq.SectionQuizId == section.Id).ToList(); foreach (var question in quizQuestions) { if (question.Label != null) { foreach (var questionLabel in question.Label) { if (questionLabel.resourceId == id) { questionLabel.resource = null; questionLabel.resourceId = null; _myInfoMateDbContext.Entry(question).Property(p => p.Label).IsModified = true; } } } if (question.ResourceId == id) { question.ResourceId = null; question.Resource = null; } foreach (var response in question.Responses) { if (response.label != null) { foreach (var responseLabel in response.label) { if (responseLabel.resourceId == id) { responseLabel.resource = null; responseLabel.resourceId = null; _myInfoMateDbContext.Entry(response).Property(p => p.label).IsModified = true; } //responseLabel.resourceUrl = responseLabel.resourceId == id ? null : responseLabel.resourceUrl; //responseLabel.resourceId = responseLabel.resourceId == id ? null : responseLabel.resourceId; } } } } if (quiz.QuizBadLevel != null) { foreach (var quizBadLevel in quiz.QuizBadLevel) { if (quizBadLevel.resourceId == id) { quizBadLevel.resourceId = null; quizBadLevel.resource = null; _myInfoMateDbContext.Entry(quiz).Property(p => p.QuizBadLevel).IsModified = true; } } } if (quiz.QuizMediumLevel != null) { foreach (var quizMediumLevel in quiz.QuizMediumLevel) { if (quizMediumLevel.resourceId == id) { quizMediumLevel.resourceId = null; quizMediumLevel.resource = null; _myInfoMateDbContext.Entry(quiz).Property(p => p.QuizMediumLevel).IsModified = true; } } } if (quiz.QuizGoodLevel != null) { foreach (var quizGoodLevel in quiz.QuizGoodLevel) { if (quizGoodLevel.resourceId == id) { quizGoodLevel.resourceId = null; quizGoodLevel.resource = null; _myInfoMateDbContext.Entry(quiz).Property(p => p.QuizGoodLevel).IsModified = true; } } } if (quiz.QuizGreatLevel != null) { foreach (var quizGreatLevel in quiz.QuizGreatLevel) { if (quizGreatLevel.resourceId == id) { quizGreatLevel.resourceId = null; quizGreatLevel.resource = null; _myInfoMateDbContext.Entry(quiz).Property(p => p.QuizGreatLevel).IsModified = true; } } } break; case SectionArticle article: if (article.ArticleContents != null) { foreach (var content in article.ArticleContents) { if (content.resourceId == id) { content.resource = null; content.resourceId = null; _myInfoMateDbContext.Entry(article).Property(p => p.ArticleContents).IsModified = true; } } foreach (var audioId in article.ArticleAudioIds) { if (audioId.value == id) { audioId.value = null; _myInfoMateDbContext.Entry(article).Property(p => p.ArticleAudioIds).IsModified = true; } } } break; case SectionPdf pdf: if (pdf.PDFOrderedTranslationAndResources != null) { foreach (var orderedTranslationAndResource in pdf.PDFOrderedTranslationAndResources) { if (orderedTranslationAndResource.translationAndResourceDTOs != null) { foreach (var translationAndResource in orderedTranslationAndResource.translationAndResourceDTOs) { if (translationAndResource.value == id) { translationAndResource.value = null; _myInfoMateDbContext.Entry(pdf).Property(p => p.PDFOrderedTranslationAndResources).IsModified = true; } } } } } break; case SectionAgenda agenda: if (agenda.AgendaResourceIds != null) { foreach (var agendaResourceId in agenda.AgendaResourceIds) { if (agendaResourceId.value == id) { agendaResourceId.value = null; _myInfoMateDbContext.Entry(agenda).Property(p => p.AgendaResourceIds).IsModified = true; } } } break; case SectionGame puzzle: if (puzzle.GameMessageDebut != null) { foreach (var gameMessageDebut in puzzle.GameMessageDebut) { if (gameMessageDebut.resourceId == id) { gameMessageDebut.resourceId = null; gameMessageDebut.resource = null; _myInfoMateDbContext.Entry(puzzle).Property(p => p.GameMessageDebut).IsModified = true; } } } if (puzzle.GameMessageFin != null) { foreach (var gameMessageFin in puzzle.GameMessageFin) { if (gameMessageFin.resourceId == id) { gameMessageFin.resourceId = null; gameMessageFin.resource = null; _myInfoMateDbContext.Entry(puzzle).Property(p => p.GameMessageFin).IsModified = true; } } } if (puzzle.GamePuzzleImageId == id) { puzzle.GamePuzzleImageId = null; puzzle.GamePuzzleImage = null; } break; case SectionVideo video: // TO BE TESTED .. if (resource.Url == video.VideoSource) { video.VideoSource = null; } break; } _myInfoMateDbContext.SaveChanges(); } _myInfoMateDbContext.Remove(resource); _myInfoMateDbContext.SaveChanges(); return new ObjectResult("The resource has been deleted") { StatusCode = 202 }; } catch (ArgumentNullException ex) { return new BadRequestObjectResult(ex.Message) { }; } catch (KeyNotFoundException ex) { return new NotFoundObjectResult(ex.Message) { }; } catch (Exception ex) { return new ObjectResult(ex.Message) { StatusCode = 500 }; } } /// /// Renseigne StoragePath et SizeBytes sur les ressources déjà en base (C2). /// /// Ces colonnes sont nées après le contenu : les lignes créées avant portent un /// StoragePath nul, et un SizeBytes nul pour celles créées avant que le chemin /// multipart ne l'écrive. Un quota calculé là-dessus laisserait tout passer, /// d'où l'ordre imposé — ce backfill précède le contrôle autoritaire (lien L10). /// /// dryRun est à true par défaut, à l'inverse de la migration : celle-ci se joue /// sur une base vide, celui-ci sur des lignes de production. /// [Authorize(Policy = ManagerService.Service.Security.Policies.SuperAdmin)] [ProducesResponseType(typeof(ResourceBackfillReportDTO), 200)] [ProducesResponseType(typeof(string), 500)] [HttpPost("backfill-storage")] public async Task BackfillStorage( [FromQuery] bool dryRun = true, [FromQuery] string instanceId = null) { try { var report = new ResourceBackfillReportDTO { DryRun = dryRun }; var query = _myInfoMateDbContext.Resources.AsQueryable(); if (!string.IsNullOrEmpty(instanceId)) query = query.Where(r => r.InstanceId == instanceId); var resources = await query.ToListAsync(); report.Examined = resources.Count; // Un type URL ne doit porter ni chemin ni poids : il pointe hors du bucket. // Le cas ne devrait pas exister, mais une ligne mal renseignée gonflerait // le quota d'octets que personne ne stocke — on la remet dans le contrat // de ResourceStorage plutôt que de la laisser fausser le calcul de C3. foreach (var resource in resources.Where(r => !ResourceStorage.HasBlob(r.Type))) { if (resource.StoragePath == null && resource.SizeBytes == 0) continue; resource.StoragePath = null; resource.SizeBytes = 0; report.UrlTypesNormalised++; } var blobs = resources.Where(r => ResourceStorage.HasBlob(r.Type)).ToList(); // Seules les tailles inconnues sont sondées : une valeur déjà en base a été // écrite par la création, qui connaissait le fichier réel. La resonder // coûterait un HEAD par ressource pour un résultat identique. var toProbe = blobs.Where(r => r.SizeBytes == 0).ToList(); var httpClient = _httpClientFactory.CreateClient(); httpClient.Timeout = TimeSpan.FromSeconds(10); var probe = await ResourceSizeProbe.ProbeAsync( httpClient, toProbe.Select(r => (r.Id, r.Url))); foreach (var resource in blobs) { var hadPath = !string.IsNullOrEmpty(resource.StoragePath); var hadSize = resource.SizeBytes > 0; // Le chemin est déterministe, la taille ne l'est pas : on repasse par // le calculateur commun en lui redonnant la taille connue quand elle // l'est, plutôt que d'écrire les colonnes à la main ici (lien L5). var size = hadSize ? resource.SizeBytes : (probe.TryGetSize(resource.Id, out var probed) ? probed : 0); ResourceStorage.Apply(resource, size); if (!hadPath) report.StoragePathFilled++; if (!hadSize && size > 0) report.SizeBytesFilled++; if (hadPath && hadSize) report.AlreadyComplete++; if (!hadSize && size == 0) { // Deux causes distinctes, à ne pas confondre dans un même total : // une ligne sans URL n'est pas sondable du tout (le blob n'a // peut-être jamais été téléversé), une ligne sondée sans réponse // exploitable pointe vers un objet absent ou un bucket muet. if (string.IsNullOrEmpty(resource.Url)) report.Orphans.Add($"{resource.Id} ({resource.Label}) — type {resource.Type}, aucune URL : blob jamais téléversé ?"); else report.Unsized.Add($"{resource.Id} ({resource.Label}) — HEAD sans Content-Length sur {resource.Url}"); } } if (!dryRun) await _myInfoMateDbContext.SaveChangesAsync(); return new OkObjectResult(report); } catch (Exception ex) { return new ObjectResult(ex.Message) { StatusCode = 500 }; } } /// /// Pré-vol du quota, partagé par les deux chemins de création. Passe par /// pour que le chiffre opposé au client soit celui /// que son écran de quota lui annonce. /// private bool ExceedsStorageQuota(string instanceId, long incomingBytes) { if (string.IsNullOrEmpty(instanceId)) return false; var instance = _myInfoMateDbContext.Instances .Include(i => i.SubscriptionPlan) .FirstOrDefault(i => i.Id == instanceId); if (instance == null) return false; var quota = StorageQuota.Resolve( instance.StorageQuotaBytes, instance.SubscriptionPlan?.StorageQuotaBytes ?? 0); if (quota <= 0) return false; var used = _myInfoMateDbContext.Resources .Where(r => r.InstanceId == instanceId) .Sum(r => (long?)r.SizeBytes) ?? 0; return StorageQuota.Exceeds(used, incomingBytes, quota); } /// /// Le filigrane est un réglage d'instance depuis le 2026-08-11. Auparavant /// un `instanceId == "633ee379…"` en dur ne l'activait que pour Fort Saint-Héribert. /// private bool IsWatermarkEnabled(string instanceId) => _myInfoMateDbContext.Instances .Where(i => i.Id == instanceId) .Select(i => i.IsImageWatermark) .FirstOrDefault(); } /// /// Le backfill ne se contente pas de compter : les deux listes sont l'inventaire /// des lignes qu'il n'a pas pu réparer. C'est la seule sortie qui dise combien /// d'octets le quota de C3 ne verra jamais. /// public class ResourceBackfillReportDTO { public bool DryRun { get; set; } public int Examined { get; set; } public int StoragePathFilled { get; set; } public int SizeBytesFilled { get; set; } public int AlreadyComplete { get; set; } public int UrlTypesNormalised { get; set; } public List Orphans { get; set; } = new(); public List Unsized { get; set; } = new(); } }