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>
475 lines
20 KiB
C#
475 lines
20 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using Manager.Services;
|
|
using ManagerService.Data;
|
|
using ManagerService.DTOs;
|
|
using ManagerService.Services;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
using Mqtt.Client.AspNetCore.Services;
|
|
using Newtonsoft.Json;
|
|
using NSwag.Annotations;
|
|
|
|
namespace ManagerService.Controllers
|
|
{
|
|
[Authorize(Policy = ManagerService.Service.Security.Policies.InstanceAdmin)]
|
|
[ApiController, Route("api/[controller]")]
|
|
[OpenApiTag("Device", Description = "Device management")]
|
|
public class DeviceController : ControllerBase
|
|
{
|
|
private DeviceDatabaseService _deviceService;
|
|
private ConfigurationDatabaseService _configurationService;
|
|
private readonly ILogger<DeviceController> _logger;
|
|
private readonly MyInfoMateDbContext _myInfoMateDbContext;
|
|
IHexIdGeneratorService idService = new HexIdGeneratorService();
|
|
|
|
public DeviceController(ILogger<DeviceController> logger, DeviceDatabaseService deviceService, ConfigurationDatabaseService configurationService, MyInfoMateDbContext myInfoMateDbContext)
|
|
{
|
|
_logger = logger;
|
|
_deviceService = deviceService;
|
|
_configurationService = configurationService;
|
|
_myInfoMateDbContext = myInfoMateDbContext;
|
|
}
|
|
|
|
private string? GetCallerInstanceId() =>
|
|
User.FindFirst(ManagerService.Service.Security.ClaimTypes.InstanceId)?.Value;
|
|
|
|
private bool IsSuperAdmin() =>
|
|
User.HasClaim(ManagerService.Service.Security.ClaimTypes.Permission, ManagerService.Service.Security.Permissions.SuperAdmin);
|
|
|
|
/// <summary>
|
|
/// Get a list of all devices
|
|
/// </summary>
|
|
/// <param name="instanceId">id instance</param>
|
|
/// <param name="appType">Canal à filtrer. Absent = tous les appareils, comportement historique.</param>
|
|
[ProducesResponseType(typeof(List<DeviceDTO>), 200)]
|
|
[ProducesResponseType(typeof(string), 500)]
|
|
[HttpGet]
|
|
public ObjectResult Get([FromQuery] string instanceId, [FromQuery] AppType? appType = null)
|
|
{
|
|
try
|
|
{
|
|
var scopedInstanceId = IsSuperAdmin() ? instanceId : GetCallerInstanceId();
|
|
|
|
var query = _myInfoMateDbContext.Devices.Include(d => d.Configuration).AsQueryable();
|
|
if (scopedInstanceId != null)
|
|
query = query.Where(d => d.InstanceId == scopedInstanceId);
|
|
if (appType != null)
|
|
query = query.Where(d => d.AppType == appType.Value);
|
|
|
|
return new OkObjectResult(query.ToList().Select(d => d.ToDTO()));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new ObjectResult(ex.Message) { StatusCode = 500 };
|
|
}
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Get a specific device
|
|
/// </summary>
|
|
/// <param name="id">id device</param>
|
|
/// <remarks>
|
|
/// ⚠️ <b>C'est le premier appel d'une tablette qui démarre</b> :
|
|
/// <c>tablet-app/lib/main.dart:38</c> reconstruit son client avec l'hôte mémorisé,
|
|
/// puis demande son propre détail pour savoir quelle configuration afficher. Sans
|
|
/// exception à la policy <c>InstanceAdmin</c> de la classe, cet appel répondait
|
|
/// 403 et <b>la tablette ne retrouvait plus son contenu au démarrage</b> — même
|
|
/// cause que l'appairage cassé depuis le 13/03/2026 (voir <see cref="Create"/>).
|
|
///
|
|
/// Le cloisonnement est déjà écrit plus bas : une clé ne voit que les appareils
|
|
/// de son instance, et un appareil d'ailleurs ressort en 404.
|
|
/// </remarks>
|
|
[AllowAnonymous]
|
|
[Security.RequireAppKey]
|
|
[ProducesResponseType(typeof(DeviceDetailDTO), 200)]
|
|
[ProducesResponseType(typeof(string), 404)]
|
|
[ProducesResponseType(typeof(string), 500)]
|
|
[HttpGet("{id}/detail")]
|
|
public ObjectResult GetDetail(string id)
|
|
{
|
|
try
|
|
{
|
|
//OldDevice device = _deviceService.GetById(id);
|
|
Device device = _myInfoMateDbContext.Devices.Include(d => d.Configuration).FirstOrDefault(i => i.Id == id);
|
|
|
|
if (device == null || (!IsSuperAdmin() && device.InstanceId != GetCallerInstanceId()))
|
|
throw new KeyNotFoundException("This device was not found");
|
|
|
|
return new OkObjectResult(device.ToDetailDTO());
|
|
}
|
|
catch (KeyNotFoundException ex)
|
|
{
|
|
return new NotFoundObjectResult(ex.Message) { };
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new ObjectResult(ex.Message) { StatusCode = 500 };
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create a new device
|
|
/// </summary>
|
|
/// <param name="newDevice">New device info</param>
|
|
/// <remarks>
|
|
/// ⚠️ <b>C'est une app qui appelle cette route, pas un humain</b> : une tablette
|
|
/// qui s'appaire avec un code PIN, et bientôt un casque. Or le contrôleur exige
|
|
/// <c>InstanceAdmin</c>, qu'une clé d'API n'a pas — elle ne porte que
|
|
/// <c>AppRead</c> et <c>Viewer</c> — et <c>tablet-app</c> ne s'authentifie
|
|
/// jamais autrement. Depuis que la policy a été posée sur la classe (commit
|
|
/// <c>a452f4a</c>, 13/03/2026, « need to be tested »), <b>l'appairage d'une
|
|
/// nouvelle tablette répondait 403</b> ; les tablettes déjà appairées ne
|
|
/// rappellent pas cette route, donc rien ne le signalait.
|
|
///
|
|
/// Le cloisonnement, lui, était déjà écrit juste en dessous : une clé ne peut
|
|
/// créer un appareil que dans <b>son</b> instance.
|
|
/// </remarks>
|
|
[AllowAnonymous]
|
|
[Security.RequireAppKey]
|
|
[ProducesResponseType(typeof(DeviceDetailDTO), 200)]
|
|
[ProducesResponseType(typeof(string), 400)]
|
|
[ProducesResponseType(typeof(string), 404)]
|
|
[ProducesResponseType(typeof(string), 409)]
|
|
[ProducesResponseType(typeof(string), 500)]
|
|
[HttpPost]
|
|
public ObjectResult Create([FromBody] DeviceDetailDTO newDevice)
|
|
{
|
|
try
|
|
{
|
|
if (newDevice == null)
|
|
throw new ArgumentNullException("Device param is null");
|
|
|
|
if (!IsSuperAdmin() && newDevice.instanceId != GetCallerInstanceId())
|
|
throw new UnauthorizedAccessException("Cannot create a device for another instance");
|
|
|
|
//var configuration = _configurationService.GetById(newDevice.configurationId);
|
|
var configuration = _myInfoMateDbContext.Configurations.FirstOrDefault(c => c.Id == newDevice.configurationId);
|
|
|
|
if (configuration == null)
|
|
throw new KeyNotFoundException("Configuration does not exist");
|
|
|
|
//OldDevice device = new OldDevice();
|
|
Device device = new Device().FromDTO(newDevice);
|
|
device.Id = idService.GenerateHexId();
|
|
|
|
var deviceDB = _myInfoMateDbContext.Devices.FirstOrDefault(d => d.Identifier == newDevice.identifier);
|
|
|
|
if (deviceDB != null)
|
|
{
|
|
// Update info
|
|
device = deviceDB;
|
|
//device = _deviceService.GetByIdentifier(newDevice.identifier);
|
|
device.DateUpdate = DateTime.Now.ToUniversalTime();
|
|
}
|
|
else {
|
|
// Creation
|
|
device.Identifier = newDevice.identifier;
|
|
device.DateCreation = DateTime.Now.ToUniversalTime();
|
|
}
|
|
|
|
device.InstanceId = newDevice.instanceId;
|
|
device.Name = newDevice.name;
|
|
device.ConfigurationId = newDevice.configurationId; // OLD WAY -> AppConfigurationLink
|
|
device.IpAddressETH = newDevice.ipAddressETH;
|
|
device.IpAddressWLAN = newDevice.ipAddressWLAN;
|
|
device.Connected = newDevice.connected;
|
|
device.ConnectionLevel = newDevice.connectionLevel;
|
|
device.LastConnectionLevel = newDevice.lastConnectionLevel;
|
|
device.BatteryLevel = newDevice.batteryLevel;
|
|
device.LastBatteryLevel = newDevice.lastBatteryLevel;
|
|
device.AppType = newDevice.appType;
|
|
|
|
// Était hardcodé sur AppType.Tablet : un casque enregistré par ce chemin était
|
|
// rattaché à l'instance kiosk et apparaissait dans l'onglet Kiosk.
|
|
ApplicationInstance applicationInstance = _myInfoMateDbContext.ApplicationInstances.FirstOrDefault(ai => ai.InstanceId == newDevice.instanceId && ai.AppType == newDevice.appType);
|
|
|
|
if (applicationInstance == null)
|
|
throw new KeyNotFoundException($"Application instance does not exist for app type {newDevice.appType}");
|
|
|
|
//OldDevice deviceCreated = _deviceService.IsExistIdentifier(newDevice.identifier) ? _deviceService.Update(device.Id, device) : _deviceService.Create(device);
|
|
if (deviceDB != null)
|
|
{
|
|
_myInfoMateDbContext.Update(device);
|
|
} else {
|
|
_myInfoMateDbContext.Add(device);
|
|
}
|
|
_myInfoMateDbContext.SaveChanges();
|
|
|
|
if (deviceDB == null)
|
|
{
|
|
// Create AppConfigurationLink
|
|
AppConfigurationLink link = new AppConfigurationLink();
|
|
link.ConfigurationId = newDevice.configurationId;
|
|
link.ApplicationInstanceId = applicationInstance.Id;
|
|
link.DeviceId = device.Id;
|
|
link.Id = idService.GenerateHexId();
|
|
_myInfoMateDbContext.AppConfigurationLinks.Add(link);
|
|
}
|
|
|
|
_myInfoMateDbContext.SaveChanges();
|
|
|
|
return new OkObjectResult(device.ToDTO());
|
|
}
|
|
catch (ArgumentNullException ex)
|
|
{
|
|
return new BadRequestObjectResult(ex.Message) { };
|
|
}
|
|
catch (UnauthorizedAccessException ex)
|
|
{
|
|
return new ObjectResult(ex.Message) { StatusCode = 403 };
|
|
}
|
|
catch (KeyNotFoundException ex)
|
|
{
|
|
return new NotFoundObjectResult(ex.Message) { };
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return new ConflictObjectResult(ex.Message) { };
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new ObjectResult(ex.Message) { StatusCode = 500 };
|
|
}
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Update a device
|
|
/// </summary>
|
|
/// <param name="updatedDevice">Device to update</param>
|
|
[ProducesResponseType(typeof(DeviceDetailDTO), 200)]
|
|
[ProducesResponseType(typeof(string), 400)]
|
|
[ProducesResponseType(typeof(string), 404)]
|
|
[ProducesResponseType(typeof(string), 500)]
|
|
[HttpPut]
|
|
public ObjectResult Update([FromBody] DeviceDetailDTO updatedDevice)
|
|
{
|
|
try
|
|
{
|
|
if (updatedDevice == null)
|
|
throw new ArgumentNullException("Device param is null");
|
|
|
|
//OldDevice device = _deviceService.GetById(updatedDevice.id);
|
|
Device device = _myInfoMateDbContext.Devices.FirstOrDefault(d => d.Id == updatedDevice.id);
|
|
|
|
if (device == null || (!IsSuperAdmin() && device.InstanceId != GetCallerInstanceId()))
|
|
throw new KeyNotFoundException("Device does not exist");
|
|
|
|
if (!IsSuperAdmin() && updatedDevice.instanceId != device.InstanceId)
|
|
throw new UnauthorizedAccessException("Cannot move a device to another instance");
|
|
|
|
device.Name = updatedDevice.name;
|
|
device.InstanceId = updatedDevice.instanceId;
|
|
device.Identifier = updatedDevice.identifier;
|
|
device.IpAddressWLAN = updatedDevice.ipAddressWLAN;
|
|
device.IpAddressETH = updatedDevice.ipAddressETH;
|
|
device.Connected = updatedDevice.connected;
|
|
device.ConnectionLevel = updatedDevice.connectionLevel;
|
|
device.LastConnectionLevel = updatedDevice.lastConnectionLevel;
|
|
device.BatteryLevel = updatedDevice.batteryLevel;
|
|
device.LastBatteryLevel = updatedDevice.lastBatteryLevel;
|
|
|
|
//OldDevice deviceModified = _deviceService.Update(updatedDevice.id, device);
|
|
_myInfoMateDbContext.SaveChanges();
|
|
|
|
return new OkObjectResult(device.ToDTO());
|
|
}
|
|
catch (ArgumentNullException ex)
|
|
{
|
|
return new BadRequestObjectResult(ex.Message) { };
|
|
}
|
|
catch (UnauthorizedAccessException ex)
|
|
{
|
|
return new ObjectResult(ex.Message) { StatusCode = 403 };
|
|
}
|
|
catch (KeyNotFoundException ex)
|
|
{
|
|
return new NotFoundObjectResult(ex.Message) { };
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new ObjectResult(ex.Message) { StatusCode = 500 };
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Heartbeat sent by a running app: battery, app version, last seen.
|
|
/// </summary>
|
|
/// <param name="id">Device id</param>
|
|
/// <param name="beat">What the app knows about itself</param>
|
|
/// <remarks>
|
|
/// Lot <c>XR-5</c>. L'onglet XR affiche batterie, version et dernier vu depuis
|
|
/// le 12/09 — mais <b>rien ne les alimentait</b> : <c>Update</c> n'écrit ni
|
|
/// <c>AppVersion</c> ni <c>LastSeen</c>, et exige de toute façon un compte admin.
|
|
///
|
|
/// Volontairement étroit : une app en fonctionnement ne doit pas pouvoir se
|
|
/// renommer, changer d'instance ni se réassigner une configuration. Elle dit
|
|
/// seulement comment elle va.
|
|
/// </remarks>
|
|
[AllowAnonymous]
|
|
[Security.RequireAppKey]
|
|
[ProducesResponseType(typeof(DeviceDTO), 200)]
|
|
[ProducesResponseType(typeof(string), 403)]
|
|
[ProducesResponseType(typeof(string), 404)]
|
|
[ProducesResponseType(typeof(string), 500)]
|
|
[HttpPut("{id}/heartbeat")]
|
|
public ObjectResult Heartbeat(string id, [FromBody] DeviceHeartbeatDTO beat)
|
|
{
|
|
try
|
|
{
|
|
Device device = _myInfoMateDbContext.Devices.FirstOrDefault(d => d.Id == id);
|
|
|
|
if (device == null)
|
|
throw new KeyNotFoundException("Device does not exist");
|
|
|
|
// Une clé ne parle que des appareils de son instance. Sans ce contrôle,
|
|
// n'importe quelle app pourrait écrire l'état des casques d'un autre lieu.
|
|
if (!IsSuperAdmin() && device.InstanceId != GetCallerInstanceId())
|
|
throw new UnauthorizedAccessException("This key does not grant access to this device");
|
|
|
|
var now = DateTime.Now.ToUniversalTime();
|
|
|
|
if (beat?.batteryLevel != null)
|
|
{
|
|
device.BatteryLevel = beat.batteryLevel;
|
|
device.LastBatteryLevel = now;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(beat?.appVersion))
|
|
device.AppVersion = beat.appVersion;
|
|
|
|
if (beat?.connectionLevel != null)
|
|
{
|
|
device.ConnectionLevel = beat.connectionLevel;
|
|
device.LastConnectionLevel = now;
|
|
}
|
|
|
|
// Recevoir un battement **est** la preuve que l'appareil est en ligne :
|
|
// on ne demande pas à l'app de nous dire qu'elle est connectée.
|
|
device.Connected = true;
|
|
device.LastSeen = now;
|
|
device.DateUpdate = now;
|
|
|
|
_myInfoMateDbContext.SaveChanges();
|
|
|
|
return new OkObjectResult(device.ToDTO());
|
|
}
|
|
catch (UnauthorizedAccessException ex)
|
|
{
|
|
return new ObjectResult(ex.Message) { StatusCode = 403 };
|
|
}
|
|
catch (KeyNotFoundException ex)
|
|
{
|
|
return new NotFoundObjectResult(ex.Message) { };
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new ObjectResult(ex.Message) { StatusCode = 500 };
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Update device main info
|
|
/// </summary>
|
|
/// <param name="updatedDevice">Device to update</param>
|
|
[ProducesResponseType(typeof(DeviceDTO), 200)]
|
|
[ProducesResponseType(typeof(string), 400)]
|
|
[ProducesResponseType(typeof(string), 404)]
|
|
[ProducesResponseType(typeof(string), 500)]
|
|
[HttpPut("mainInfos")]
|
|
public ObjectResult UpdateMainInfos([FromBody] DeviceDTO deviceIn)
|
|
{
|
|
try
|
|
{
|
|
if (deviceIn == null)
|
|
throw new ArgumentNullException("Device param is null");
|
|
|
|
//OldDevice device = _deviceService.GetById(deviceIn.id);
|
|
Device device = _myInfoMateDbContext.Devices.FirstOrDefault(d => d.Id == deviceIn.id);
|
|
|
|
if (device == null || (!IsSuperAdmin() && device.InstanceId != GetCallerInstanceId()))
|
|
throw new KeyNotFoundException("Device does not exist");
|
|
|
|
//var configuration = _configurationService.GetById(deviceIn.configurationId);
|
|
var configuration = _myInfoMateDbContext.Configurations.FirstOrDefault(c => c.Id == deviceIn.configurationId);
|
|
|
|
if (configuration == null)
|
|
throw new KeyNotFoundException("Configuration does not exist");
|
|
|
|
// Todo add some verification ?
|
|
device.Name = deviceIn.name;
|
|
device.Connected = deviceIn.connected;
|
|
//device.Configuration = configuration.Label;
|
|
device.ConfigurationId = deviceIn.configurationId;
|
|
|
|
//OldDevice deviceModified = _deviceService.Update(device.Id, device);
|
|
_myInfoMateDbContext.SaveChanges();
|
|
|
|
MqttClientService.PublishMessage($"player/{device.Id}", JsonConvert.SerializeObject(new PlayerMessageDTO() { configChanged = true }));
|
|
|
|
return new OkObjectResult(device.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 device
|
|
/// </summary>
|
|
/// <param name="id">Id of device 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("Device param is null");
|
|
|
|
Device device = _myInfoMateDbContext.Devices.FirstOrDefault(d => d.Id == id);
|
|
|
|
if (device == null || (!IsSuperAdmin() && device.InstanceId != GetCallerInstanceId()))
|
|
throw new KeyNotFoundException("Device does not exist");
|
|
|
|
_myInfoMateDbContext.Remove(device);
|
|
_myInfoMateDbContext.SaveChanges();
|
|
//_deviceService.Remove(id);
|
|
|
|
return new ObjectResult("The device 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 };
|
|
}
|
|
}
|
|
}
|
|
}
|