Thomas Fransolet 7625487806 Lot B : geler le schéma, plus sécurité rapide et calculateur de stockage
LOT B — une seule migration EF (LotB_FreezeSchema) :
- SectionMap.MapResourceId → IconResourceId. L'écart (g) de la bascule tombe
  avec. Il fallait renommer aussi la propriété de navigation MapResource :
  la convention EF l'appariait au FK, la laisser aurait fabriqué un FK
  fantôme. Elle n'était utilisée nulle part ailleurs.
- SectionEvent.ParcoursIds supprimé (champ, DTO, SectionFactory, et une
  initialisation dans un montage de test).
- Instance.IsImageWatermark remplace le `instanceId == "633ee379…"` en dur
  de ResourceController.

EF a généré un RenameColumn, pas un drop+add : les icônes déjà configurées
survivent. L'avertissement de perte de données ne porte que sur le DropColumn
de ParcoursIds, ce qui est l'intention.

Non fait, et c'était une erreur de doc : « supprimer SectionEvent.IconResourceId ».
Ce champ n'existe pas — la ligne visée appartient à la classe imbriquée
MapAnnotation, partagée par SectionEvent, SectionAgenda et SectionMap, lue par
cinq contrôleurs et par GetReferencedResourceIds. La supprimer aurait cassé
les icônes d'annotation des trois types et la collecte offline.

SÉCURITÉ (lot A, même repo) :
- AuthenticationController.Authenticate : un bloc #if DEBUG écrasait l'email
  et le mot de passe reçus par un compte de test, donc toute compilation en
  Debug authentifiait n'importe quelle saisie. Retiré.
- EnableSensitiveDataLogging (qui écrit les valeurs des paramètres dans les
  logs) passe sous #if DEBUG, l'idiome déjà employé dans Startup.cs pour le
  CORS et Hangfire. Le Dockerfile publiant en -c Release, c'est un verrou réel.

LOT C1 :
- Calculateur StoragePath/SizeBytes extrait dans Helpers/ResourceStorage.cs,
  avec 13 tests fixant l'invariant des types URL. Il ferme le lien L5 : le
  backfill (C2) et l'écart (e) de la migration appelleront le même code.
- L'extraction a révélé la divergence qu'elle devait empêcher : des deux
  chemins de création de ResourceController, le chemin multipart écrivait
  SizeBytes mais laissait StoragePath nul.

dotnet build Debug et Release verts, dotnet test 143/143 (130 + 13).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:32:19 +02:00

718 lines
33 KiB
C#

using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Linq;
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<ResourceController> _logger;
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)
{
_logger = logger;
_resourceService = resourceService;
_sectionService = sectionService;
_configurationService = configurationService;
_myInfoMateDbContext = myInfoMateDbContext;
}
/// <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]
[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]
[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 instance = _myInfoMateDbContext.Instances
.Include(i => i.SubscriptionPlan)
.FirstOrDefault(i => i.Id == instanceId);
var storageQuota = instance?.SubscriptionPlan?.StorageQuotaBytes ?? 0;
if (storageQuota > 0)
{
var storageUsed = _myInfoMateDbContext.Resources
.Where(r => r.InstanceId == instanceId)
.Sum(r => (long?)r.SizeBytes) ?? 0;
var incomingBytes = Request.Form.Files.Sum(f => f.Length);
if (storageUsed + incomingBytes > storageQuota)
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 };
}
}
/// <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");
// 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 };
}
}
/// <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;
if (updatedResource.sizeBytes > 0)
resource.SizeBytes = updatedResource.sizeBytes;
_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>
/// 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)]
[HttpDelete("{id}")]
public 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");
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)
{
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>
/// 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();
}
}