manager-service/ManagerService/Controllers/ApplicationInstanceController.cs
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

440 lines
19 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using Manager.Services;
using ManagerService.Data;
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 NSwag.Annotations;
namespace ManagerService.Controllers
{
[Authorize(Policy = ManagerService.Service.Security.Policies.ContentEditor)]
[ApiController, Route("api/[controller]")]
[OpenApiTag("ApplicationInstance", Description = "Application instance management")]
public class ApplicationInstanceController : ControllerBase
{
private readonly MyInfoMateDbContext _myInfoMateDbContext;
private InstanceDatabaseService _instanceService;
private UserDatabaseService _userService;
private readonly ILogger<ApplicationInstanceController> _logger;
private readonly ProfileLogic _profileLogic;
IHexIdGeneratorService idService = new HexIdGeneratorService();
public ApplicationInstanceController(ILogger<ApplicationInstanceController> logger, InstanceDatabaseService instanceService, UserDatabaseService userService, ProfileLogic profileLogic, MyInfoMateDbContext myInfoMateDbContext)
{
_logger = logger;
_instanceService = instanceService;
_userService = userService;
_profileLogic = profileLogic;
_myInfoMateDbContext = myInfoMateDbContext;
}
private bool IsSuperAdmin() =>
User.HasClaim(ManagerService.Service.Security.ClaimTypes.Permission, ManagerService.Service.Security.Permissions.SuperAdmin);
/// <summary>
/// Get a list of all applicationInstance (summary)
/// </summary>
/// <param name="id">instance id</param>
[AllowAnonymous]
[ProducesResponseType(typeof(List<ApplicationInstanceDTO>), 200)]
[ProducesResponseType(typeof(string), 500)]
[HttpGet]
public ObjectResult Get([FromQuery] string instanceId)
{
try
{
List<ApplicationInstance> applicationInstances = _myInfoMateDbContext.ApplicationInstances.Include(s => s.SectionEvent).Where(ai => ai.InstanceId == instanceId).ToList();
var instance = _myInfoMateDbContext.Instances.FirstOrDefault(i => i.Id == instanceId);
return new OkObjectResult(applicationInstances.Select(ai => {
var dto = ai.ToDTO(_myInfoMateDbContext);
dto.hasStats = instance?.HasStats ?? false;
// Porté par l'Instance, pas par l'application. La page /download de
// visitapp-web n'a que l'instanceId d'un QR imprimé : sans ce slug, elle
// ne peut pas renvoyer vers la visite web d'une instance sans app mobile.
dto.webSlug = instance?.WebSlug;
dto.instanceName = instance?.Name;
return dto;
}).OrderBy(c => c.appType));
}
catch (Exception ex)
{
return new ObjectResult(ex.Message) { StatusCode = 500 };
}
}
/// <summary>
/// Create a new application instance
/// </summary>
/// <param name="newApplicationInstanceDTO">New application instance info</param>
[ProducesResponseType(typeof(ApplicationInstanceDTO), 200)]
[ProducesResponseType(typeof(string), 400)]
[ProducesResponseType(typeof(string), 409)]
[ProducesResponseType(typeof(string), 500)]
[HttpPost]
public ObjectResult Create([FromBody] ApplicationInstanceDTO newApplicationInstanceDTO)
{
try
{
if (newApplicationInstanceDTO == null)
throw new ArgumentNullException("Application instance param is null");
// Tout le code (manager-app, AiController) résout une application par
// `(InstanceId, AppType)` via un FirstOrDefault : un doublon rend le canal
// ambigu — l'assistant s'affiche activé alors que l'API répond Forbid.
bool alreadyExists = _myInfoMateDbContext.ApplicationInstances.Any(
ai => ai.InstanceId == newApplicationInstanceDTO.instanceId
&& ai.AppType == newApplicationInstanceDTO.appType);
if (alreadyExists)
throw new InvalidOperationException($"An application instance of type {newApplicationInstanceDTO.appType} already exists for this instance");
ApplicationInstance applicationInstance = new ApplicationInstance().FromDTO(newApplicationInstanceDTO);
applicationInstance.Id = idService.GenerateHexId();
if (!IsSuperAdmin())
{
applicationInstance.AppStoreUrl = null;
applicationInstance.PlayStoreUrl = null;
}
_myInfoMateDbContext.ApplicationInstances.Add(applicationInstance);
_myInfoMateDbContext.SaveChanges();
return new OkObjectResult(applicationInstance.ToDTO(_myInfoMateDbContext));
}
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 an application instance
/// </summary>
/// <param name="updatedApplicationInstanceDTO">application instance to update</param>
[ProducesResponseType(typeof(ApplicationInstanceDTO), 200)]
[ProducesResponseType(typeof(string), 400)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
[HttpPut]
public ObjectResult Update([FromBody] ApplicationInstanceDTO updatedApplicationInstanceDTO)
{
try
{
if (updatedApplicationInstanceDTO == null)
throw new ArgumentNullException("application instance param is null");
ApplicationInstance applicationInstance = _myInfoMateDbContext.ApplicationInstances.Include(s => s.SectionEvent).FirstOrDefault(ai => ai.Id == updatedApplicationInstanceDTO.id);
if (applicationInstance == null)
throw new KeyNotFoundException("application instance does not exist");
var appStoreUrl = applicationInstance.AppStoreUrl;
var playStoreUrl = applicationInstance.PlayStoreUrl;
applicationInstance = applicationInstance.FromDTO(updatedApplicationInstanceDTO);
if (!IsSuperAdmin())
{
applicationInstance.AppStoreUrl = appStoreUrl;
applicationInstance.PlayStoreUrl = playStoreUrl;
}
_myInfoMateDbContext.SaveChanges();
return new OkObjectResult(applicationInstance.ToDTO(_myInfoMateDbContext));
}
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 an application instance
/// </summary>
/// <param name="id">Id of application instance 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("application instance param is null");
ApplicationInstance applicationInstance = _myInfoMateDbContext.ApplicationInstances.FirstOrDefault(ai => ai.Id == id);
if (applicationInstance == null)
throw new KeyNotFoundException("application instance does not exist");
//_instanceService.Remove(id);
_myInfoMateDbContext.ApplicationInstances.Remove(applicationInstance);
_myInfoMateDbContext.SaveChanges();
return new ObjectResult("The application instance 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>
/// Get all application Link
/// </summary>
/// <param name="applicationInstanceId">application instance id</param>
[AllowAnonymous]
[RequireAppKey]
[ProducesResponseType(typeof(List<AppConfigurationLinkDTO>), 200)]
[ProducesResponseType(typeof(string), 500)]
[HttpGet("{applicationInstanceId}/application-link")]
public ObjectResult GetAllApplicationLinkFromApplicationInstance(string applicationInstanceId)
{
try
{
List<AppConfigurationLink> appConfigurationLinks = _myInfoMateDbContext.AppConfigurationLinks
.Include(acl => acl.Configuration)
.Include(acl => acl.Device)
.Where(acl => acl.ApplicationInstanceId == applicationInstanceId)
.ToList();
return new OkObjectResult(appConfigurationLinks.Select(acl => acl.ToDTO()).OrderBy(acl => acl.order).ToList());
}
catch (Exception ex)
{
return new ObjectResult(ex.Message) { StatusCode = 500 };
}
}
/// <summary>
/// Create a new application Link to specified application instance
/// </summary>
/// <param name="applicationInstanceId">Application instance id</param>
/// <param name="appConfigurationLinkDTO">Info to link config and application instance</param>
[ProducesResponseType(typeof(AppConfigurationLinkDTO), 200)]
[ProducesResponseType(typeof(string), 400)]
[ProducesResponseType(typeof(string), 409)]
[ProducesResponseType(typeof(string), 500)]
[HttpPost("{applicationInstanceId}/application-link")]
public ObjectResult AddConfigurationToApplicationInstance(string applicationInstanceId, [FromBody] AppConfigurationLinkDTO appConfigurationLinkDTO)
{
try
{
if (applicationInstanceId == null)
throw new ArgumentNullException("Application instance param is null");
if (appConfigurationLinkDTO == null)
throw new ArgumentNullException("Configuration param is null");
ApplicationInstance applicationInstance = _myInfoMateDbContext.ApplicationInstances.Include(ai => ai.Configurations).FirstOrDefault(ai => ai.Id == applicationInstanceId);
if (applicationInstance == null)
throw new KeyNotFoundException("This application instance was not found");
Configuration configuration = _myInfoMateDbContext.Configurations.FirstOrDefault(c => c.Id == appConfigurationLinkDTO.configurationId);
if (configuration == null)
throw new KeyNotFoundException("This configuration was not found");
// Check if already linked
if (_myInfoMateDbContext.AppConfigurationLinks.Any(acl => acl.ConfigurationId == appConfigurationLinkDTO.configurationId && acl.ApplicationInstanceId == applicationInstanceId))
return new ConflictObjectResult("This configuration is already linked to this applicationInstance");
// Todo add some verification ?
AppConfigurationLink appConfigurationLink = new AppConfigurationLink().FromDTO(appConfigurationLinkDTO);
appConfigurationLink.Id = idService.GenerateHexId();
appConfigurationLink.Order = _myInfoMateDbContext.AppConfigurationLinks.Count(acl => acl.ApplicationInstanceId == applicationInstanceId);
_myInfoMateDbContext.AppConfigurationLinks.Add(appConfigurationLink);
_myInfoMateDbContext.SaveChanges();
return new OkObjectResult(appConfigurationLink.ToDTO());
}
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 an app configuration link
/// </summary>
/// <param name="appConfigurationLinkDTO">App configuration link to update</param>
[ProducesResponseType(typeof(AppConfigurationLinkDTO), 200)]
[ProducesResponseType(typeof(string), 400)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
[HttpPut("application-link")]
public ObjectResult UpdateApplicationLink([FromBody] AppConfigurationLinkDTO appConfigurationLinkDTO)
{
try
{
if (appConfigurationLinkDTO == null)
throw new ArgumentNullException("appConfigurationLink param is null");
AppConfigurationLink appConfigurationLink = _myInfoMateDbContext.AppConfigurationLinks.Include(acl => acl.Configuration).Include(acl => acl.Device).FirstOrDefault(acl => acl.Id == appConfigurationLinkDTO.id);
if (appConfigurationLink == null)
throw new KeyNotFoundException("appConfigurationLink does not exist");
appConfigurationLink = appConfigurationLink.FromDTO(appConfigurationLinkDTO);
_myInfoMateDbContext.SaveChanges();
return new OkObjectResult(appConfigurationLink.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>
/// Update app configuration links order
/// </summary>
/// <param name="appConfigurationLinkDTO">App configuration link to update</param>
[ProducesResponseType(typeof(List<AppConfigurationLinkDTO>), 200)]
[ProducesResponseType(typeof(string), 400)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
[HttpPut("application-link/order")]
public ObjectResult UpdateApplicationLinkOrder([FromBody] List<AppConfigurationLinkDTO> appConfigurationLinkDTOs)
{
try
{
if (appConfigurationLinkDTOs == null)
throw new ArgumentNullException("appConfigurationLink param is null");
List<AppConfigurationLink> appConfigurationLinks = _myInfoMateDbContext.AppConfigurationLinks.Include(acl => acl.Configuration).Include(acl => acl.Device).Where(acl => appConfigurationLinkDTOs.Select(acld => acld.id).Contains(acl.Id)).ToList();
if (appConfigurationLinks.Count != appConfigurationLinkDTOs.Count)
throw new KeyNotFoundException("Length does not match");
foreach (var appConfigurationLinkDTO in appConfigurationLinkDTOs) {
var appConf = appConfigurationLinks.FirstOrDefault(acl => acl.Id == appConfigurationLinkDTO.id);
appConf.Order = appConfigurationLinkDTO.order;
}
_myInfoMateDbContext.SaveChanges();
return new OkObjectResult(appConfigurationLinks.Select(acl => acl.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>
/// Remove configuration from instance
/// </summary>
/// <param name="applicationInstanceId">Application instance id</param>
/// <param name="appConfigurationLinkId">AppConfiguration id</param>
[ProducesResponseType(typeof(string), 202)]
[ProducesResponseType(typeof(string), 400)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
[HttpDelete("{applicationInstanceId}/application-link/{appConfigurationLinkId}")]
public ObjectResult DeleteAppConfigurationLink(string applicationInstanceId, string appConfigurationLinkId)
{
try
{
if (applicationInstanceId == null)
throw new ArgumentNullException("application instance param is null");
if (appConfigurationLinkId == null)
throw new ArgumentNullException("Configuration param is null");
ApplicationInstance applicationInstance = _myInfoMateDbContext.ApplicationInstances.FirstOrDefault(ai => ai.Id == applicationInstanceId);
if (applicationInstance == null)
throw new KeyNotFoundException("application instance does not exist");
AppConfigurationLink appConfigurationLink = _myInfoMateDbContext.AppConfigurationLinks.FirstOrDefault(acl => acl.Id == appConfigurationLinkId);
if (appConfigurationLink == null)
throw new KeyNotFoundException("This app configuration link was not found");
_myInfoMateDbContext.AppConfigurationLinks.Remove(appConfigurationLink);
_myInfoMateDbContext.SaveChanges();
return new ObjectResult("The app configuration link 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 };
}
}
}
}