Thomas Fransolet b87e49d808 Cle d'API sur les routes visiteur, canal VR et contenu immersif
Securite
- RequireAppKey : filtre qui exige une cle d'API valide ou un utilisateur du
  manager. Pose sur les routes de contenu consommees par les apps visiteur
  (Configuration, Section, Resource, SectionMap, SectionEvent, SectionAgenda,
  SectionParcours, SectionQuiz, ApplicationInstance). Un [Authorize] d'action
  ne peut pas assouplir celui de la classe ; seul [AllowAnonymous] le
  court-circuite, et le filtre redevient le controle d'acces. Il ferme
  l'enumeration par identifiant, pas la confidentialite : la cle s'obtient
  par le slug ou le pincode.
- Instance/slug/{slug} rendait le pinCode, l'adresse de facturation, la TVA et
  les quotas a qui lit l'URL du site visiteur. StripCommercialFields est
  desormais applique sur slug et byPin ; isTrialActive reste expose pour le
  filigrane d'essai.
- Device.Create et Device/{id}/detail repondaient 403 a toute tablette depuis
  a452f4a (13/03) : la classe exige InstanceAdmin, une cle ne porte que
  AppRead. Ouverts a la cle, le cloisonnement par instance etait deja ecrit.

Canal VR
- Device.AppType (defaut Tablet, backfill a 1) ; Create resout
  l'ApplicationInstance sur ce type au lieu de Tablet en dur.
- Get filtre optionnellement par appType ; DeviceDetailDTO expose appVersion
  et lastSeen.
- PUT Device/{id}/heartbeat : batterie, version, connexion. Volontairement
  etroit, une app ne peut ni se renommer ni changer d'instance.
- ApiKeyAppType.VrApp en fin d'enum.

Contenu immersif
- ResourceType : Image360 (11), Video360 (12), Model3D (13), en fin d'enum.
- Section Scene3D : un modele GLB et ses points d'interet, en objet manipule
  ou en decor habite. Les points sont des GeoPoint, avec une LocalTransform
  en jsonb (convention glTF) ; CRUD dans SectionScene3DController.
- Instance.HasImmersiveContent : l'add-on ajoute 100 Go au quota de stockage,
  repose apres un changement de plan et retire a la desactivation.
- ImmersiveBackground (owned) sur Configuration et ApplicationInstance, avec
  une image de repli pour les canaux qui ne rendent pas l'immersif.

Export de configuration
- exportVersion (1) et generatedAt : le JSON devient un contrat, lu tel quel
  par l'app Unity.
- Section.ToDTO() n'etant pas virtuelle, l'export ne portait aucun champ
  specifique de sous-type. Passe par SectionFactory.ToDTO, et charge les
  points des Map et des Scene3D.
- Le fond immersif et son repli partent avec les ressources, URL resolues.

Migrations : AddAppTypeToDevice, AddScene3DSectionAndImmersiveAddon,
AddImmersiveBackground.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 16:54:10 +02:00

1017 lines
47 KiB
C#

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 ManagerService.Security;
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<ResourceController> _logger;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IResourceBlobService _blobService;
private readonly ResourceUsageService _usageService;
IHexIdGeneratorService idService = new HexIdGeneratorService();
private static int MaxWidth = 1024;
private static int MaxHeight = 1024;
public ResourceController(ILogger<ResourceController> logger, ResourceDatabaseService resourceService, SectionDatabaseService sectionService, ConfigurationDatabaseService configurationService, MyInfoMateDbContext myInfoMateDbContext, IHttpClientFactory httpClientFactory, IResourceBlobService blobService, ResourceUsageService usageService)
{
_usageService = usageService;
_logger = logger;
_resourceService = resourceService;
_sectionService = sectionService;
_configurationService = configurationService;
_myInfoMateDbContext = myInfoMateDbContext;
_httpClientFactory = httpClientFactory;
_blobService = blobService;
}
/// <summary>
/// Get a list of all resources (summary)
/// </summary>
/// <param name="id">id instance</param>
/// <param name="types">types of resource</param>
[ProducesResponseType(typeof(List<ResourceDTO>), 200)]
[ProducesResponseType(typeof(string), 500)]
[HttpGet]
public ObjectResult Get([FromQuery] string instanceId, [FromQuery] List<ResourceType> types)
{
try
{
if (instanceId == null)
throw new ArgumentNullException("InstanceId needed");
List<Resource> resources = new List<Resource>();
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<ResourceDTO> resourceDTOs = new List<ResourceDTO>();
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 };
}
}
/// <summary>
/// Get a specific resource
/// </summary>
/// <param name="id">id resource</param>
[AllowAnonymous]
[RequireAppKey]
[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 };
}
}
/// <summary>
/// Show a specific resource (as a picture or video stream)
/// </summary>
/// <param name="id">id resource</param>
[AllowAnonymous]
[RequireAppKey]
[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
/// <summary>
/// Upload a specific resource (picture or video)
/// </summary>
[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<Resource> resources = new List<Resource>();
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.FileName = file.FileName;
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 };
}
}
/// <summary>
/// Create a new resource
/// </summary>
/// <param name="newResource">New resource info</param>
[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.FileName = newResource.fileName;
resource.Width = newResource.width;
resource.Height = newResource.height;
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 };
}
}
/// <summary>
/// Update a resource
/// </summary>
/// <param name="updatedResource">Resource to update</param>
[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;
resource.FileName = updatedResource.fileName != null ? updatedResource.fileName : resource.FileName;
resource.Width = updatedResource.width ?? resource.Width;
resource.Height = updatedResource.height ?? resource.Height;
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 };
}
}
/// <summary>
/// Contenus qui utilisent une ressource.
/// </summary>
/// <param name="id">id de la ressource</param>
[ProducesResponseType(typeof(List<ResourceUsageDTO>), 200)]
[ProducesResponseType(typeof(string), 500)]
[HttpGet("{id}/usages")]
public ObjectResult GetUsages(string id)
{
try
{
return new OkObjectResult(_usageService.GetUsages(id));
}
catch (Exception ex)
{
return new ObjectResult(ex.Message) { StatusCode = 500 };
}
}
/// <summary>
/// Nombre d'usages par ressource pour une instance, zéro compris.
/// </summary>
/// <param name="instanceId">id de l'instance</param>
[ProducesResponseType(typeof(ResourceUsageMapDTO), 200)]
[ProducesResponseType(typeof(string), 400)]
[ProducesResponseType(typeof(string), 500)]
[HttpGet("usage-map")]
public ObjectResult GetUsageMap([FromQuery] string instanceId)
{
try
{
if (instanceId == null)
throw new ArgumentNullException("InstanceId needed");
return new OkObjectResult(_usageService.GetUsageSummary(instanceId));
}
catch (ArgumentNullException ex)
{
return new BadRequestObjectResult(ex.Message) { };
}
catch (Exception ex)
{
return new ObjectResult(ex.Message) { StatusCode = 500 };
}
}
/// <summary>
/// Suppression en lot. Même règle que la suppression unitaire, appliquée par élément :
/// une ressource utilisée est refusée, les autres partent, et le rapport dit qui est qui.
/// </summary>
[ProducesResponseType(typeof(ResourceBulkDeleteReportDTO), 200)]
[ProducesResponseType(typeof(string), 400)]
[ProducesResponseType(typeof(string), 500)]
[HttpDelete("bulk")]
public async Task<ObjectResult> DeleteBulk([FromBody] ResourceBulkDeleteRequestDTO request)
{
try
{
if (request?.ids == null)
throw new ArgumentNullException("Ids param is null");
var report = new ResourceBulkDeleteReportDTO();
foreach (var id in request.ids.Distinct())
{
var resource = _myInfoMateDbContext.Resources.FirstOrDefault(r => r.Id == id);
if (resource == null)
{
report.refused.Add(new ResourceBulkDeleteRefusalDTO { id = id, reason = "Ressource introuvable" });
continue;
}
var usages = _usageService.GetUsages(id);
if (usages.Count > 0)
{
report.refused.Add(new ResourceBulkDeleteRefusalDTO
{
id = id,
label = resource.Label,
usageCount = usages.Count,
reason = "Ressource utilisée"
});
continue;
}
var outcome = await Delete(id);
if (outcome.StatusCode == 202)
report.deleted.Add(id);
else
report.refused.Add(new ResourceBulkDeleteRefusalDTO
{
id = id,
label = resource.Label,
reason = outcome.Value?.ToString()
});
}
return new OkObjectResult(report);
}
catch (ArgumentNullException ex)
{
return new BadRequestObjectResult(ex.Message) { };
}
catch (Exception ex)
{
return new ObjectResult(ex.Message) { StatusCode = 500 };
}
}
/// <summary>
/// Delete a resource
/// </summary>
/// <param name="id">Id of resource to delete</param>
[ProducesResponseType(typeof(string), 202)]
[ProducesResponseType(typeof(string), 400)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
[ProducesResponseType(typeof(List<ResourceUsageDTO>), 409)]
[ProducesResponseType(typeof(string), 502)]
[HttpDelete("{id}")]
public async Task<ObjectResult> 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 front désactive déjà le bouton, mais la règle doit vivre ici : la
// suite de cette méthode déréférence la ressource partout où elle sert,
// donc un appel venu d'ailleurs viderait des contenus en silence.
var usages = _usageService.GetUsages(id);
if (usages.Count > 0)
return new ConflictObjectResult(usages) { };
// 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<Configuration> 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<Section> 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<GeoPoint> 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)
{
// Une catégorie sans icône a un `resourceDTO` nul :
// le déréférencer faisait échouer toute suppression
// de ressource dès qu'une carte avait des catégories.
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<QuizDTO>(section.Data);
List<QuizQuestion> 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 };
}
}
/// <summary>
/// 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.
/// </summary>
[Authorize(Policy = ManagerService.Service.Security.Policies.SuperAdmin)]
[ProducesResponseType(typeof(ResourceBackfillReportDTO), 200)]
[ProducesResponseType(typeof(string), 500)]
[HttpPost("backfill-storage")]
public async Task<ObjectResult> 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 };
}
}
/// <summary>
/// Pré-vol du quota, partagé par les deux chemins de création. Passe par
/// <see cref="StorageQuota"/> pour que le chiffre opposé au client soit celui
/// que son écran de quota lui annonce.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
private bool IsWatermarkEnabled(string instanceId) =>
_myInfoMateDbContext.Instances
.Where(i => i.Id == instanceId)
.Select(i => i.IsImageWatermark)
.FirstOrDefault();
}
/// <summary>
/// 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.
/// </summary>
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<string> Orphans { get; set; } = new();
public List<string> Unsized { get; set; } = new();
}
}