From b68bc7b32868e98da8e2532fc793f6073488ebb4 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 18 Oct 2026 22:04:19 +0100 Subject: [PATCH 01/18] Fix blog save button highlight Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 3 ++ .../PostIt.Tests/PostItViewModelTests.cs | 3 ++ src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml | 7 --- .../PostIt/Views/Blogs/BlogsPage.axaml.cs | 48 ++++++++++++++++++- 4 files changed, 52 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86afc7514..b2219e41b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,9 @@ * [PostIt] Le changement de disposition de la page des blogs est différé sur le thread UI, évitant l'exception « Visual was invalidated during the render pass » au redimensionnement. +* [PostIt] Le bouton d'enregistrement des billets reflète les modifications + non sauvegardées et réinitialise son surlignage après sauvegarde, sans + fermer l'éditeur ni perdre la sélection. ## [1.0.8-rc26] - unstable diff --git a/src/PostIt/PostIt.Tests/PostItViewModelTests.cs b/src/PostIt/PostIt.Tests/PostItViewModelTests.cs index 9a1247657..5f23d2a61 100644 --- a/src/PostIt/PostIt.Tests/PostItViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/PostItViewModelTests.cs @@ -58,6 +58,7 @@ public class PostItViewModelTests Dispatcher.UIThread.RunJobs(); Assert.Same(selected, page.PostsListBox.SelectedItem); Assert.Same(selected, vm.SelectedPost); + vm.IsEditing = true; Assert.DoesNotContain("modified", page.SaveButton.Classes); vm.DraftTitle = "Titre modifié"; Dispatcher.UIThread.RunJobs(); @@ -67,11 +68,13 @@ public class PostItViewModelTests await vm.SaveAsync(); Dispatcher.UIThread.RunJobs(); Assert.Same(page, navigation.NavigationStack.Last()); + Assert.True(vm.IsEditing); Assert.Same(selected, vm.SelectedPost); Assert.Same(selected, page.PostsListBox.SelectedItem); Assert.Equal("Titre modifié", vm.DraftTitle); Assert.Equal("Corps", vm.DraftArticle); Assert.DoesNotContain("modified", page.SaveButton.Classes); + Assert.Equal(Avalonia.Media.FontWeight.Normal, page.SaveButton.FontWeight); } finally { diff --git a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml index 1d387ad3a..527eaa1b0 100644 --- a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml +++ b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml @@ -17,13 +17,6 @@ - - - DisabledLinkCommand = new(static _ => { }, static _ => false); private bool? _isWideLayout; + private ViewModels.BlogsViewModel? _observedViewModel; public BlogsPage() { @@ -26,6 +29,13 @@ public partial class BlogsPage : ContentPage UpdateResponsiveLayout(); }); DataContextChanged += (_, _) => UpdateHyperlinkCommand(); + AttachedToVisualTree += (_, _) => UpdateHyperlinkCommand(); + DetachedFromVisualTree += (_, _) => + { + if (_observedViewModel is not null) + _observedViewModel.PropertyChanged -= ViewModel_PropertyChanged; + _observedViewModel = null; + }; UpdateHyperlinkCommand(); } @@ -68,13 +78,47 @@ public partial class BlogsPage : ContentPage private void UpdateHyperlinkCommand() { + if (_observedViewModel is not null) + _observedViewModel.PropertyChanged -= ViewModel_PropertyChanged; + + _observedViewModel = DataContext as ViewModels.BlogsViewModel; + if (_observedViewModel is not null) + _observedViewModel.PropertyChanged += ViewModel_PropertyChanged; + if (ArticleReader.Engine is IMarkdownEngine2 engine) { - if (DataContext is ViewModels.BlogsViewModel vm) - engine.HyperlinkCommand = vm.OpenResourceCommand; + if (_observedViewModel is not null) + engine.HyperlinkCommand = _observedViewModel.OpenResourceCommand; else engine.HyperlinkCommand = DisabledLinkCommand; } + + UpdateSaveButtonHighlight(); + } + + private void ViewModel_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(ViewModels.BlogsViewModel.IsDraftModified)) + UpdateSaveButtonHighlight(); + } + + private void UpdateSaveButtonHighlight() + { + if (SaveButton is null) + return; + + if (_observedViewModel?.IsDraftModified == true) + { + SaveButton.FontWeight = FontWeight.Bold; + SaveButton.Background = new SolidColorBrush(Color.Parse("#FFF0C2")); + SaveButton.Foreground = new SolidColorBrush(Color.Parse("#663C00")); + } + else + { + SaveButton.FontWeight = FontWeight.Normal; + SaveButton.ClearValue(TemplatedControl.BackgroundProperty); + SaveButton.ClearValue(TemplatedControl.ForegroundProperty); + } } protected override async void OnApplyTemplate(TemplateAppliedEventArgs e) From f63b04d25b6477ae1aa3a9d594462bdbe1f9c78d Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 18 Oct 2026 22:29:03 +0100 Subject: [PATCH 02/18] feat(postit): add per-user Windows NSIS installer Package the self-contained Windows x64 publication, register the postit URI scheme, and provide a Start menu shortcut and uninstaller. Document generation and update the changelog. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 1 + CHANGELOG.md | 5 ++ INSTALL.md | 47 +++++++++++++++ contrib/Windows/postit.nsi | 114 +++++++++++++++++++++++++++++++++++++ 4 files changed, 167 insertions(+) create mode 100644 contrib/Windows/postit.nsi diff --git a/.gitignore b/.gitignore index 57f13079f..4b32182a0 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ appsettings-*.*.json !src/Yavsc.Org/appsettings-org.Testing.json generated/ +/contrib/Windows/PostIt-*-Setup.exe *.tmp tmp/ DataDir/ diff --git a/CHANGELOG.md b/CHANGELOG.md index b2219e41b..8567c5899 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ ### Added +* [PostIt.Desktop] Installeur NSIS Windows x64 par utilisateur, sans + elevation : publication autonome, raccourci du menu Demarrer, + protocole `postit://` pour la connexion et desinstallation via Windows. + Instructions de generation ajoutees dans `INSTALL.md`. + * [PostIt, Yavsc.Api] Portage des outils des contrôleurs web d’administration : tableau de bord, rôles et affectations, clients OIDC (paramètres, collections et secrets), scopes API et modèles de courriels. diff --git a/INSTALL.md b/INSTALL.md index d7315074a..a836c6611 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -29,6 +29,53 @@ dotnet publish -c Release and then, install the app, from the `src/PostIt/PostIt.Android/bin/Release/net10.0/PostIt.apk` +## PostIt.Desktop on Windows (per-user installer) + +Build prerequisites: the .NET 10 SDK and NSIS 3 (with its standard +Modern UI 2 includes). From the repository root, publish a self-contained +Windows x64 application, then package the **entire** publish directory: + +```powershell +dotnet publish src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=false +makensis /DAPP_VERSION=1.1.0-estimate.1 contrib/Windows/postit.nsi +``` + +Set `APP_VERSION` to the version of the published application (the +`Version` property in `PostIt.Desktop.csproj`). NSIS requires this value; +it is used in the installer filename and Windows' installed-apps list. +On Linux, use `makensis -DAPP_VERSION=1.1.0-estimate.1 contrib/Windows/postit.nsi` +instead. Cross-publishing uses the same `dotnet publish` command. +If `makensis` is not on `PATH` on Windows, invoke the executable from +the NSIS installation directory. + +The result is `contrib/Windows/PostIt--win-x64-Setup.exe`. +NSIS also accepts `PUBLISH_DIR` (an absolute path to a different +**win-x64** publish directory) and `OUTPUT_FILE` (the output executable +path), with the same `/DNAME=value` or `-DNAME=value` syntax. Quote an +entire define argument when its value contains spaces. + +The installer does not request elevation or install a shared .NET runtime. +It installs in `%LOCALAPPDATA%\Yavsc\PostIt.Desktop`, creates a Start menu +shortcut for the current user and registers `postit://` in +`HKCU\Software\Classes` so browser-based sign-in returns to PostIt. +Windows' installed-apps list provides the uninstaller. Close PostIt before +installing an update or uninstalling it. + +The `app` subdirectory is reserved for published application files and is +removed recursively on uninstall. User settings and tokens stored outside +this directory are preserved. Reinstalling uses the recorded installation +directory and overwrites the packaged files. + +The self-contained publication includes .NET, but does **not** add native +LibVLC libraries: video playback still requires a compatible Windows x64 +LibVLC installation. PostIt reports an unavailable video player and offers +the system browser as an alternative. + +Before distributing a release, test installation, startup, the `postit://` +login callback, updating and uninstallation on Windows with a standard +(non-administrator) account. Installer compilation alone does not validate +these Windows runtime behaviours. + ## Resources System resources needed to install. diff --git a/contrib/Windows/postit.nsi b/contrib/Windows/postit.nsi new file mode 100644 index 000000000..b6399a611 --- /dev/null +++ b/contrib/Windows/postit.nsi @@ -0,0 +1,114 @@ +Unicode true +RequestExecutionLevel user +SetCompressor /SOLID lzma + +!include "MUI2.nsh" +!include "LogicLib.nsh" +!include "x64.nsh" + +!define APPNAME "PostIt.Desktop" +!define APP_EXE "PostIt.Desktop.exe" +!define PUBLISHER "Yavsc" +!define UNINSTALL_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" +!define PROTOCOL_KEY "Software\Classes\postit" + +!ifndef APP_VERSION + !error "Define APP_VERSION with /DAPP_VERSION=... (Windows) or -DAPP_VERSION=... (Linux)." +!endif +!ifndef PUBLISH_DIR + !define PUBLISH_DIR "..\..\src\PostIt\PostIt.Desktop\bin\Release\net10.0\win-x64\publish" +!endif +!ifndef OUTPUT_FILE + !define OUTPUT_FILE "PostIt-${APP_VERSION}-win-x64-Setup.exe" +!endif +!if ! /FileExists "${PUBLISH_DIR}\${APP_EXE}" + !error "Publish PostIt.Desktop for win-x64 before compiling the installer (see INSTALL.md)." +!endif + +Name "${APPNAME}" +OutFile "${OUTPUT_FILE}" +InstallDir "$LOCALAPPDATA\${PUBLISHER}\${APPNAME}" +InstallDirRegKey HKCU "${UNINSTALL_KEY}" "InstallLocation" +ShowInstDetails show +ShowUninstDetails show + +!define MUI_ABORTWARNING +!define MUI_ICON "..\..\src\PostIt\PostIt\Assets\yavsc-logo.ico" +!insertmacro MUI_PAGE_WELCOME +!insertmacro MUI_PAGE_INSTFILES +!insertmacro MUI_PAGE_FINISH +!insertmacro MUI_UNPAGE_CONFIRM +!insertmacro MUI_UNPAGE_INSTFILES +!insertmacro MUI_LANGUAGE "French" + +Function .onInit + SetShellVarContext current + ${IfNot} ${RunningX64} + MessageBox MB_OK|MB_ICONSTOP "Cette version de PostIt.Desktop requiert Windows 64 bits." /SD IDOK + Abort + ${EndIf} +FunctionEnd + +Function un.onInit + SetShellVarContext current +FunctionEnd + +Section "PostIt.Desktop" + ; Keep the recursive payload separate from user data and the uninstaller. + ClearErrors + SetOutPath "$INSTDIR\app" + ${If} ${Errors} + MessageBox MB_OK|MB_ICONSTOP "Impossible de creer le dossier d'installation. Verifiez les droits d'ecriture, puis relancez l'installation." /SD IDOK + Abort + ${EndIf} + File /r "${PUBLISH_DIR}\*" + WriteUninstaller "$INSTDIR\uninstall.exe" + ${If} ${Errors} + MessageBox MB_OK|MB_ICONSTOP "Installation impossible. Fermez PostIt.Desktop et verifiez les droits d'ecriture et l'espace disque, puis relancez l'installation." /SD IDOK + Abort + ${EndIf} + + ClearErrors + CreateDirectory "$SMPROGRAMS\${PUBLISHER}" + CreateShortcut "$SMPROGRAMS\${PUBLISHER}\${APPNAME}.lnk" "$INSTDIR\app\${APP_EXE}" + + WriteRegStr HKCU "${PROTOCOL_KEY}" "" "URL:PostIt" + WriteRegStr HKCU "${PROTOCOL_KEY}" "URL Protocol" "" + WriteRegStr HKCU "${PROTOCOL_KEY}\DefaultIcon" "" '"$INSTDIR\app\${APP_EXE}",0' + WriteRegStr HKCU "${PROTOCOL_KEY}\shell\open\command" "" '"$INSTDIR\app\${APP_EXE}" "%1"' + + WriteRegStr HKCU "${UNINSTALL_KEY}" "DisplayName" "${APPNAME}" + WriteRegStr HKCU "${UNINSTALL_KEY}" "DisplayVersion" "${APP_VERSION}" + WriteRegStr HKCU "${UNINSTALL_KEY}" "Publisher" "${PUBLISHER}" + WriteRegStr HKCU "${UNINSTALL_KEY}" "DisplayIcon" '"$INSTDIR\app\${APP_EXE}",0' + WriteRegStr HKCU "${UNINSTALL_KEY}" "UninstallString" '"$INSTDIR\uninstall.exe"' + WriteRegStr HKCU "${UNINSTALL_KEY}" "QuietUninstallString" '"$INSTDIR\uninstall.exe" /S' + WriteRegStr HKCU "${UNINSTALL_KEY}" "InstallLocation" "$INSTDIR" + WriteRegDWORD HKCU "${UNINSTALL_KEY}" "NoModify" 1 + WriteRegDWORD HKCU "${UNINSTALL_KEY}" "NoRepair" 1 + ${If} ${Errors} + MessageBox MB_OK|MB_ICONSTOP "Impossible de creer les raccourcis ou les entrees du registre utilisateur. Relancez l'installation." /SD IDOK + Abort + ${EndIf} +SectionEnd + +Section "Uninstall" + SetOutPath "$TEMP" + ClearErrors + RMDir /r "$INSTDIR\app" + ${If} ${Errors} + MessageBox MB_OK|MB_ICONSTOP "Des fichiers n'ont pas pu etre supprimes. Fermez PostIt.Desktop, puis relancez la desinstallation." /SD IDOK + Abort + ${EndIf} + + ; Do not remove a protocol handler subsequently registered by another install. + ReadRegStr $0 HKCU "${PROTOCOL_KEY}\shell\open\command" "" + ${If} $0 == '"$INSTDIR\app\${APP_EXE}" "%1"' + DeleteRegKey HKCU "${PROTOCOL_KEY}" + ${EndIf} + Delete "$SMPROGRAMS\${PUBLISHER}\${APPNAME}.lnk" + RMDir "$SMPROGRAMS\${PUBLISHER}" + DeleteRegKey HKCU "${UNINSTALL_KEY}" + Delete "$INSTDIR\uninstall.exe" + RMDir "$INSTDIR" +SectionEnd From d9b183c500cf222b94e0e73e66d05fcefe2b3ba2 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 19 Oct 2026 12:02:27 +0100 Subject: [PATCH 03/18] Fix Linux LibVLC playback fallback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 16 +++ src/PostIt/PostIt/Services/CameraStreamer.cs | 131 +++++++++++++++++- .../PostIt/Views/Blogs/VideoPage.axaml.cs | 87 +++++++++++- 3 files changed, 220 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8567c5899..970efdda5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ ### Added +* [PostIt.Desktop, Yavsc.Api] Podcasts en direct avec replay HLS conserve : + capture camera et microphone par LibVLC (H.264/AAC, segments MPEG-TS), + envoi ordonne et idempotent, catalogue et lecture authentifiee dans PostIt. + L'auteur peut terminer ou supprimer ses emissions ; stockage prive sur + disque, quotas dedies et conservation des segments recus apres interruption. + * [PostIt.Desktop] Installeur NSIS Windows x64 par utilisateur, sans elevation : publication autonome, raccourci du menu Demarrer, protocole `postit://` pour la connexion et desinstallation via Windows. @@ -46,6 +52,16 @@ ### Fixed +* [PostIt] Le playback Linux revient sur un chemin logiciel sûr quand + VAAPI/VDPAU n'est pas validé, sans bloquer la lecture si le flux reste + lisible. Le fallback navigateur reste actif pour les médias impossibles + à lire dans le contrôle VLC, sans se baser sur l'absence de `vainfo` + ou de pilotes VAAPI. + +* [Yavsc.Server] Reception WebSocket historique : correction du tampon + reutilise, suppression de la file non synchronisee et liberation des + connexions. Les podcasts utilisent desormais une ingestion HTTP bornee. + * [PostIt.Android] Restauration de la pile de navigation lors de la recréation de l'activité après suspension : nouvelles pages liées aux mêmes modèles de vue, sans plus de procès. diff --git a/src/PostIt/PostIt/Services/CameraStreamer.cs b/src/PostIt/PostIt/Services/CameraStreamer.cs index 10607f204..d78a84349 100644 --- a/src/PostIt/PostIt/Services/CameraStreamer.cs +++ b/src/PostIt/PostIt/Services/CameraStreamer.cs @@ -1,18 +1,105 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; +using System.Linq; using System.Runtime.InteropServices; using LibVLCSharp.Shared; -// Note : Si vous compilez sous Linux, DirectShowLib n'est pas disponible. -// Pour un projet multiplateforme propre, vous pouvez utiliser des directives de précompilation (#if WINDOWS) -// ou charger la détection DirectShow dans une assembly séparée. using DirectShowLib; public class CameraStreamer { private readonly LibVLC _libVLC; + public static LibVLC CreateHeadlessLibVlc() + => new( + "--no-xlib", + "--avcodec-hw=none", + "--vout=dummy", + "--aout=dummy", + "--intf=dummy"); + + public static LibVLC CreatePlaybackLibVlc() + { + if (!OperatingSystem.IsLinux()) + return new LibVLC(); + + var preferHardware = string.Equals(Environment.GetEnvironmentVariable("POSTIT_LIBVLC_ENABLE_HW"), "1", + StringComparison.OrdinalIgnoreCase); + + if (preferHardware && IsVaapiUsable()) + return new LibVLC(); + + // On Linux, a non-hardware display path is still valid for in-app playback as long as + // the platform is not explicitly forced into a broken VAAPI/VDPAU stack. + return new LibVLC("--avcodec-hw=none"); + } + + public static LibVLC CreatePreferredLibVlc() + { + if (!OperatingSystem.IsLinux()) + return new LibVLC(); + + var preferHardware = string.Equals(Environment.GetEnvironmentVariable("POSTIT_LIBVLC_ENABLE_HW"), "1", + StringComparison.OrdinalIgnoreCase); + + if (preferHardware && IsVaapiUsable()) + return new LibVLC(); + + return CreateHeadlessLibVlc(); + } + + private static bool IsVaapiUsable() + { + var vaapiDrivers = new[] + { + "/usr/lib/x86_64-linux-gnu/dri/nvidia_drv_video.so", + "/usr/lib/x86_64-linux-gnu/dri/i965_drv_video.so", + "/usr/lib/x86_64-linux-gnu/dri/i915_drv_video.so", + "/usr/lib/x86_64-linux-gnu/dri/radeonsi_dri.so", + "/usr/lib/x86_64-linux-gnu/dri/iris_dri.so", + "/usr/lib64/dri/nvidia_drv_video.so", + "/usr/lib64/dri/i965_drv_video.so", + "/usr/lib64/dri/i915_drv_video.so", + "/usr/lib64/dri/radeonsi_dri.so", + "/usr/lib64/dri/iris_dri.so" + }; + + if (!vaapiDrivers.Any(File.Exists)) + return false; + + try + { + var processInfo = new ProcessStartInfo("vainfo") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + + using var process = Process.Start(processInfo); + if (process is null) + return false; + + var output = process.StandardOutput.ReadToEnd(); + var error = process.StandardError.ReadToEnd(); + process.WaitForExit(2000); + + var combined = (output + Environment.NewLine + error).Trim(); + return process.ExitCode == 0 + && !string.IsNullOrWhiteSpace(combined) + && combined.Contains("VA-API version", StringComparison.OrdinalIgnoreCase) + && !combined.Contains("failed", StringComparison.OrdinalIgnoreCase) + && !combined.Contains("unknown libva error", StringComparison.OrdinalIgnoreCase) + && !combined.Contains("va_openDriver() returns -1", StringComparison.OrdinalIgnoreCase); + } + catch + { + return false; + } + } + public CameraStreamer(LibVLC libVLC) { _libVLC = libVLC ?? throw new ArgumentNullException(nameof(libVLC)); @@ -32,8 +119,8 @@ public class CameraStreamer DsDevice[] capDevices = DsDevice.GetDevicesOfCat(FilterCategory.VideoInputDevice); foreach (var device in capDevices) { - // Pour Windows, VLC utilise le nom de l'appareil - devices.Add(device.Name, device.Name); + using (device) + devices.TryAdd(device.Name, device.Name); } } else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) @@ -57,17 +144,41 @@ public class CameraStreamer return devices; } + public Dictionary GetAudioCaptureDevices() + { + var devices = new Dictionary(); + if (OperatingSystem.IsWindows()) + { + foreach (var device in DsDevice.GetDevicesOfCat(FilterCategory.AudioInputDevice)) + { + using (device) + devices.TryAdd(device.Name, device.Name); + } + } + else if (OperatingSystem.IsLinux()) + devices.Add("Microphone par defaut (PulseAudio)", "default"); + else + throw new PlatformNotSupportedException("La capture est disponible sous Windows et Linux."); + return devices; + } + /// /// Crée un objet Media configuré pour le périphérique sélectionné. /// /// Le nom de la caméra (Windows) ou le chemin /dev/videoX (Linux) - public Media CreateCaptureMedia(string deviceValue) + public Media CreateCaptureMedia(string deviceValue, string? audioDevice = null) { + ArgumentException.ThrowIfNullOrWhiteSpace(deviceValue); if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { // Configuration pour DirectShow (Windows) var media = new Media(_libVLC, "dshow://", FromType.FromLocation); media.AddOption($":dshow-vdev={deviceValue}"); + media.AddOption($":dshow-adev={audioDevice ?? "none"}"); + media.AddOption(":live-caching=200"); + media.AddOption(":thread-cout=0"); + media.AddOption(":avcodec-hw=none"); + media.AddOption(":vcodec=h264"); // Options optionnelles (Exemple : Forcer la résolution ou le framerate) // media.AddOption(":dshow-size=1280x720"); @@ -79,7 +190,13 @@ public class CameraStreamer { // Configuration pour V4L2 (Linux) var media = new Media(_libVLC, "v4l2://", FromType.FromLocation); - media.AddOption($":v4l2-vdev={deviceValue}"); + media.AddOption($":v4l2-dev={deviceValue}"); + media.AddOption(":live-caching=200"); + media.AddOption(":thread-cout=0"); + media.AddOption(":avcodec-hw=none"); + media.AddOption(":vcodec=h264"); + if (!string.IsNullOrWhiteSpace(audioDevice)) + media.AddOption($":input-slave=pulse://{audioDevice}"); // Options optionnelles pour Linux // media.AddOption(":v4l2-width=1280"); diff --git a/src/PostIt/PostIt/Views/Blogs/VideoPage.axaml.cs b/src/PostIt/PostIt/Views/Blogs/VideoPage.axaml.cs index a87cd2c6c..c2f0a6a91 100644 --- a/src/PostIt/PostIt/Views/Blogs/VideoPage.axaml.cs +++ b/src/PostIt/PostIt/Views/Blogs/VideoPage.axaml.cs @@ -1,10 +1,13 @@ using System; +using System.Threading.Tasks; using Avalonia; using Avalonia.Controls; using Avalonia.Interactivity; using Avalonia.Threading; using LibVLCSharp.Shared; using PostIt.ViewModels; +using PostIt.Services; +using Yavsc.Abstract.Streaming; namespace PostIt.Views.Blogs; @@ -13,6 +16,8 @@ public partial class VideoPage : ContentPage private LibVLC? _libVlc; private MediaPlayer? _player; private Media? _media; + private PodcastMediaInput? _podcastInput; + private bool _externalFallbackTriggered; public VideoPage() => InitializeComponent(); @@ -25,21 +30,33 @@ public partial class VideoPage : ContentPage try { Core.Initialize(); - _libVlc = new LibVLC(); + _libVlc = CameraStreamer.CreatePlaybackLibVlc(); _player = new MediaPlayer(_libVlc); _player.EncounteredError += Player_EncounteredError; - _media = new Media(_libVlc, vm.Source); + if (vm.Podcast is { } podcast && vm.PodcastClient is { } client) + { + _podcastInput = new PodcastMediaInput(client, podcast.Id, podcast.State == PodcastState.Live); + _podcastInput.Failed += PodcastFailed; + _media = new Media(_libVlc, _podcastInput); + _media.AddOption(":demux=ts"); + } + else + _media = new Media(_libVlc, vm.Source); Video.MediaPlayer = _player; PlayButton.IsEnabled = true; PlaybackStatus.Text = vm.MimeType; if (!_player.Play(_media)) - PlaybackStatus.Text = "Impossible de démarrer la vidéo. Vous pouvez l'ouvrir dans le navigateur."; + PlaybackStatus.Text = vm.Podcast is null + ? "Impossible de démarrer la vidéo. Vous pouvez l'ouvrir dans le navigateur." + : "Impossible de demarrer la lecture du podcast."; } catch (Exception ex) when (ex is VLCException or DllNotFoundException or EntryPointNotFoundException - or BadImageFormatException or PlatformNotSupportedException) + or BadImageFormatException) { ReleasePlayer(); - PlaybackStatus.Text = $"Lecture vidéo indisponible : {ex.Message}. Vérifiez l'installation de LibVLC."; + _externalFallbackTriggered = true; + PlaybackStatus.Text = $"Lecture vidéo indisponible : {ex.Message}. Ouverture du navigateur pour afficher le média."; + _ = OpenExternalFallbackAsync(vm.Source); } } @@ -59,6 +76,7 @@ public partial class VideoPage : ContentPage { PlayButton.IsEnabled = false; Video.MediaPlayer = null; + _podcastInput?.Cancel(); if (_player is not null) { _player.EncounteredError -= Player_EncounteredError; @@ -68,15 +86,64 @@ public partial class VideoPage : ContentPage } _media?.Dispose(); _media = null; + if (_podcastInput is not null) + { + _podcastInput.Failed -= PodcastFailed; + _podcastInput.Dispose(); + _podcastInput = null; + } _libVlc?.Dispose(); _libVlc = null; } + private void PodcastFailed(string message) => Dispatcher.UIThread.Post(() => PlaybackStatus.Text = message); + + private static bool CanLaunchExternally(Uri source) + => source is { IsAbsoluteUri: true } + && !string.Equals(source.Scheme, "about", StringComparison.OrdinalIgnoreCase) + && !string.Equals(source.Host, "localhost", StringComparison.OrdinalIgnoreCase) + && !string.Equals(source.AbsolutePath, "/", StringComparison.OrdinalIgnoreCase); + + private async Task OpenExternalFallbackAsync(Uri source) + { + try + { + if (!CanLaunchExternally(source)) + { + PlaybackStatus.Text = "Lecture intégrée indisponible ; ce flux ne peut pas être ouvert dans le navigateur."; + return; + } + + var topLevel = TopLevel.GetTopLevel(this); + if (topLevel is null) + { + PlaybackStatus.Text = "Lecture intégrée indisponible ; ouvrez le média dans le navigateur."; + return; + } + + if (await topLevel.Launcher.LaunchUriAsync(source)) + PlaybackStatus.Text = "Lecture intégrée indisponible ; le média a été ouvert dans le navigateur."; + else + PlaybackStatus.Text = "Lecture intégrée indisponible ; ouvrez le média dans le navigateur."; + } + catch (Exception ex) + { + PlaybackStatus.Text = $"Lecture intégrée indisponible : {ex.Message}. Ouvrez le média dans le navigateur."; + } + } + private void Player_EncounteredError(object? sender, EventArgs e) => Dispatcher.UIThread.Post(() => { - if (ReferenceEquals(sender, _player)) - PlaybackStatus.Text = "Impossible de lire cette vidéo. Vous pouvez l'ouvrir dans le navigateur."; + if (ReferenceEquals(sender, _player) && !_externalFallbackTriggered) + { + _externalFallbackTriggered = true; + if (DataContext is VideoPageViewModel vm) + _ = OpenExternalFallbackAsync(vm.Source); + PlaybackStatus.Text = _podcastInput is null + ? "Impossible de lire cette vidéo. Ouverture du navigateur…" + : "Impossible de lire le podcast. Ouverture du navigateur…"; + } }); private void PlayPause_Click(object? sender, RoutedEventArgs e) @@ -95,6 +162,12 @@ public partial class VideoPage : ContentPage return; try { + if (!CanLaunchExternally(vm.Source)) + { + PlaybackStatus.Text = "Ce flux interne ne peut pas être ouvert dans le navigateur."; + return; + } + var topLevel = TopLevel.GetTopLevel(this) ?? throw new InvalidOperationException("La fenêtre de lecture est indisponible."); if (!await topLevel.Launcher.LaunchUriAsync(vm.Source)) From 4fc91cec3e0de9b2673b81cb572f4ed7b7da8f41 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 19 Oct 2026 15:22:34 +0100 Subject: [PATCH 04/18] feat: add authenticated live podcasts and simplify native video playback Include podcast capture, ingestion and replay, dependency and installer updates, and native video positioning after navigation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 17 +- Directory.Packages.props | 58 +- INSTALL.md | 64 +++ NuGet.config | 1 + ROADMAP.md | 12 +- contrib/blog-edit.png | Bin 61533 -> 0 bytes contrib/blog-view.png | Bin 65768 -> 0 bytes contrib/{Windows => mswin}/postit.nsi | 0 doc/architecture/postit.md | 63 ++- doc/testing.md | 24 + src/PostIt/Directory.Packages.props | 10 +- .../PostIt.Tests/PodcastNativeCaptureTests.cs | 166 ++++++ src/PostIt/PostIt.Tests/PodcastTests.cs | 163 ++++++ .../Helpers/ServiceCollectionHelpers.cs | 4 + src/PostIt/PostIt/PostIt.csproj | 1 + src/PostIt/PostIt/Services/CameraStreamer.cs | 23 +- .../PostIt/Services/HlsCapturePlaylist.cs | 43 ++ .../PostIt/Services/PodcastBroadcaster.cs | 231 ++++++++ .../PostIt/Services/PodcastMediaInput.cs | 78 +++ src/PostIt/PostIt/Services/YavscApiClient.cs | 7 +- src/PostIt/PostIt/ViewLocator.cs | 1 + .../ViewModels/Blogs/VideoPageViewModel.cs | 7 +- .../ViewModels/Layout/HomePageViewModel.cs | 9 + .../ViewModels/PodcastsPageViewModel.cs | 276 ++++++++++ src/PostIt/PostIt/Views/Blogs/VideoPage.axaml | 4 +- .../PostIt/Views/Blogs/VideoPage.axaml.cs | 87 +-- src/PostIt/PostIt/Views/Layout/HomePage.axaml | 5 + src/PostIt/PostIt/Views/PodcastsPage.axaml | 55 ++ src/PostIt/PostIt/Views/PodcastsPage.axaml.cs | 25 + src/Yavsc.Abstract/Streaming/Podcast.cs | 28 + src/Yavsc.Api.Client/PodcastApiClient.cs | 51 ++ .../Controllers/Musical/PodcastController.cs | 106 +++- .../Middleware/PodcastExceptionFilter.cs | 21 + src/Yavsc.Api/Podcast.http | 49 ++ src/Yavsc.Api/Program.cs | 1 + .../PodcastServiceCollectionExtensions.cs | 14 + .../Controllers/FileSystemStreamController.cs | 3 +- src/Yavsc.Server/Interfaces/ILiveProcessor.cs | 9 + src/Yavsc.Server/Services/LiveProcessor.cs | 510 ++++++++++++------ src/Yavsc.Server/Services/PodcastException.cs | 6 + src/Yavsc.Server/Settings/PodcastOptions.cs | 11 + .../ViewModels/LiveCastHandler.cs | 110 +--- .../Fixtures/ApiWebServerFixture.cs | 11 +- .../Yavsc.Api.Tests/LiveProcessorTests.cs | 267 +++++++++ .../Yavsc.Api.Tests/PodcastControllerTests.cs | 101 ++++ 45 files changed, 2342 insertions(+), 390 deletions(-) delete mode 100644 contrib/blog-edit.png delete mode 100644 contrib/blog-view.png rename contrib/{Windows => mswin}/postit.nsi (100%) create mode 100644 src/PostIt/PostIt.Tests/PodcastNativeCaptureTests.cs create mode 100644 src/PostIt/PostIt.Tests/PodcastTests.cs create mode 100644 src/PostIt/PostIt/Services/HlsCapturePlaylist.cs create mode 100644 src/PostIt/PostIt/Services/PodcastBroadcaster.cs create mode 100644 src/PostIt/PostIt/Services/PodcastMediaInput.cs create mode 100644 src/PostIt/PostIt/ViewModels/PodcastsPageViewModel.cs create mode 100644 src/PostIt/PostIt/Views/PodcastsPage.axaml create mode 100644 src/PostIt/PostIt/Views/PodcastsPage.axaml.cs create mode 100644 src/Yavsc.Abstract/Streaming/Podcast.cs create mode 100644 src/Yavsc.Api.Client/PodcastApiClient.cs create mode 100644 src/Yavsc.Api/Middleware/PodcastExceptionFilter.cs create mode 100644 src/Yavsc.Api/Podcast.http create mode 100644 src/Yavsc.Api/Services/PodcastServiceCollectionExtensions.cs create mode 100644 src/Yavsc.Server/Services/PodcastException.cs create mode 100644 src/Yavsc.Server/Settings/PodcastOptions.cs create mode 100644 src/test/Yavsc.Api.Tests/LiveProcessorTests.cs create mode 100644 src/test/Yavsc.Api.Tests/PodcastControllerTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 970efdda5..7fd31746e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog -## [1.0.8-rc27] - unstable +## [1.0.8-rc28] - unstable + ### Added @@ -10,6 +11,14 @@ L'auteur peut terminer ou supprimer ses emissions ; stockage prive sur disque, quotas dedies et conservation des segments recus apres interruption. +### Changed + +### Fixed + +## [1.0.8-rc27] - unstable + +### Added + * [PostIt.Desktop] Installeur NSIS Windows x64 par utilisateur, sans elevation : publication autonome, raccourci du menu Demarrer, protocole `postit://` pour la connexion et desinstallation via Windows. @@ -52,12 +61,6 @@ ### Fixed -* [PostIt] Le playback Linux revient sur un chemin logiciel sûr quand - VAAPI/VDPAU n'est pas validé, sans bloquer la lecture si le flux reste - lisible. Le fallback navigateur reste actif pour les médias impossibles - à lire dans le contrôle VLC, sans se baser sur l'absence de `vainfo` - ou de pilotes VAAPI. - * [Yavsc.Server] Reception WebSocket historique : correction du tampon reutilise, suppression de la file non synchronisee et liberation des connexions. Les podcasts utilisent desormais une ingestion HTTP bornee. diff --git a/Directory.Packages.props b/Directory.Packages.props index 1e3577c72..12028b161 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,8 +5,8 @@ - - + + @@ -15,35 +15,35 @@ - - - - + + + + - + - - - + + + - - - - - + + + + + - - + + - - - + + + - - - - + + + + @@ -53,15 +53,15 @@ - - - + + + - + - + diff --git a/INSTALL.md b/INSTALL.md index a836c6611..62b7c15b6 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -76,6 +76,70 @@ login callback, updating and uninstallation on Windows with a standard (non-administrator) account. Installer compilation alone does not validate these Windows runtime behaviours. +## Podcast live broadcasts and replays + +PostIt Desktop's home page provides **Podcasts : directs et replays**. +Install LibVLC 3 with the `livehttp`, MPEG-TS, x264 and AAC plugins on the +Desktop machine. Windows capture uses DirectShow; Linux uses V4L2 and the +default PulseAudio microphone. FFmpeg is **not** a runtime dependency. +Select a camera and optionally a microphone, enter a title, then start. +Stopping publishes the retained replay; navigating away or logging out +stops capture too. A second signed-in PostIt instance can watch the live +broadcast while it is being captured. + +Both upload and playback require a bearer token with the `api` scope. +All authenticated API users can watch the catalogue, live broadcasts and +replays; only the broadcaster can upload, complete or delete their podcast. +Media is not anonymous and no access token is included in a playback URL. + +Configure Yavsc.Api's `Podcast` section (or environment variables such as +`Podcast__RootPath`). No database migration or server-side encoder is needed: + +```json +{ + "Podcast": { + "RootPath": "/srv/yavsc-private/podcasts", + "MaxSegmentBytes": 8388608, + "MaxOwnerBytes": 1073741824, + "MaxSegments": 7200, + "MaxOwnerPodcasts": 100, + "IdleTimeoutSeconds": 60 + } +} +``` + +`RootPath` must be absolute, writable by Yavsc.Api and **outside any static +file directory**. The default is `DataDir/podcasts` under the API application +base directory. Use a persistent volume and back up this entire directory +(indexes and segments together). This implementation supports **one API +process per storage root**, not multiple writers or replicas. + +Defaults allow 8 MiB per segment (also the HTTP endpoint's hard limit), +1 GiB retained per owner, 7,200 segments per episode and 100 episodes per +owner. At the target two-second segment duration, this is roughly four hours +per episode; the byte quota can be reached earlier. Podcast quotas are +separate from the legacy user-files `DiskQuota`. Deleting an ended podcast +releases its podcast quota. Reverse proxies must allow an 8 MiB PUT body +and forward Authorization on every API request. + +A broadcast without a new committed segment for 60 seconds, or present +when the API restarts, becomes an interrupted replay on the next catalogue, +manifest or upload request. Already committed media remains playable. +The broadcaster retries transient upload errors twice using the same +sequence and content; it stops if capture fails or the pending local media +exceeds 64 MiB. Unacknowledged media is retained under +`%LOCALAPPDATA%\PostIt\podcast-capture` (Linux: the corresponding local +application data directory), with `podcast.json` and the capture playlist +for manual recovery. There is no automatic resumption after an application +restart; these private local recordings may be removed manually when no +longer needed. + +The replay is **HLS/MPEG-TS**, not an MP4 download. PostIt's authenticated +player joins a live broadcast up to three segments behind the edge and +reads a replay from its beginning; seeking is not implemented. Native +camera/microphone permissions, drivers and codecs must still be verified +on each target Windows/Linux machine. + ## Resources System resources needed to install. diff --git a/NuGet.config b/NuGet.config index c601d09b5..3f45ad414 100644 --- a/NuGet.config +++ b/NuGet.config @@ -19,5 +19,6 @@ + diff --git a/ROADMAP.md b/ROADMAP.md index 364b98bda..0a46fff5a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -109,14 +109,14 @@ Sous-jalons du `TODO.fr.md.archive` repris ici : Repris du `TODO.fr.md.archive`, jalon 1 (sélection) + jalon 2 (interface) : -- ☐ **Gestion du profil utilisateur** Simple & Prestataire, sur **Android** (Web ✔) -- ☐ **Gestion de l'espace de stockage personnel** (Web, API web, Mobile) -- ☐ **Création d'une annonce** +- ✔ **Gestion du profil utilisateur** Simple & Prestataire, sur **Android** (Web ✔) +- ✔ **Gestion de l'espace de stockage personnel** (Web, API web, Mobile) +- ◐ **Création d'une annonce** - ☐ **Transformation annonce ↔ demande nominative** -- ☐ **Sélection d'une star** depuis le **mobile** (Web ✔) +- ◐ **Sélection d'une star** depuis le **mobile** (Web ✔) - ☐ **Sélection DJ / chanteur / formation** (recherche par critères : date, lieu, tendance, taille, répertoire) depuis le **mobile** - ☐ **Géocodeur de lieu** côté mobile -- ☐ **Géocodeur d'adresse postale** (Web + Mobile) +- ◐ **Géocodeur d'adresse postale** (Web + Mobile) - ☐ **Évaluation des trajets** - ☐ **Interface de gestion des collaborateurs** (sous-traitance — déjà amorcée dans `doc/Architecture.md`) - ☐ **Migration de l'identifiant utilisateur** : `string` GUID → `long` auto-incrémenté (Npgsql) @@ -163,7 +163,7 @@ Repris du `TODO.fr.md.archive`, jalon 1 (sélection) + jalon 2 (interface) : - ☐ **Aide à la rencontre physique** : carte interactive, geofencing, alertes - ☐ **Activités secondaires** : spécialisation d'une activité, activités filles, *activité principale* (code APE) - ☐ **Streaming vidéo pair-à-pair** -- ☐ **Streaming vidéo public** +- ✔ **Streaming vidéo public** **Critère de sortie** : un événement géolocalisé notifie ses participants confirmés à l'approche du point de rendez-vous. diff --git a/contrib/blog-edit.png b/contrib/blog-edit.png deleted file mode 100644 index 4175f51532e105ded21befef169015b1bcc9e0b1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 61533 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 diff --git a/contrib/blog-view.png b/contrib/blog-view.png deleted file mode 100644 index 04625dbc9d405bcd1a40207d53c49442a381a72b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 65768 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 sv)0f7E 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⪼IlaI5Fr}OdJ04oJJ1+<45L6q3 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 diff --git a/contrib/Windows/postit.nsi b/contrib/mswin/postit.nsi similarity index 100% rename from contrib/Windows/postit.nsi rename to contrib/mswin/postit.nsi diff --git a/doc/architecture/postit.md b/doc/architecture/postit.md index 2ba6cb812..625cc1875 100644 --- a/doc/architecture/postit.md +++ b/doc/architecture/postit.md @@ -175,10 +175,19 @@ affichées dans la barre de statut, sans les masquer par une déduction MIME. Les types `video/*` ouvrent `VideoPage` via `App.PushPageAsync` sur desktop. Cette page contient un `LibVLCSharp.Avalonia.VideoView`, lecture/pause et une action d'ouverture externe. Le lecteur et ses ressources natives sont -arrêtés et libérés à la sortie de la page. **LibVLC doit être installé** +arrêtés et libérés à la sortie de la page. La lecture utilise directement +`new LibVLC()`, `new MediaPlayer(libVlc)` et `Play(media)`, sans options +spécifiques à Linux ni détection VAAPI/VDPAU. Le média est libéré après +`Play` comme dans l'exemple officiel ; le lecteur conserve sa référence native. +**LibVLC doit être installé** sur le poste (par exemple `libvlc5` et les plugins VLC sur Linux ; les bibliothèques natives adaptées sont nécessaires sous Windows/macOS). Une absence de LibVLC ou une erreur de décodage est signalée dans la page. +Le navigateur ne s'ouvre que sur demande explicite, jamais en repli automatique. +Après la navigation, `TryUpdateNativeControlPosition()` synchronise la surface +native à la priorité `Loaded`, une fois la mise en page effectuée : +les transformations de transition ne déclenchent pas de changement de `Bounds`. +Le rappel est ignoré si le lecteur a été libéré ou remplacé entre-temps. Sur Android et Browser, où ce contrôle natif desktop n'est pas disponible, les vidéos sont ouvertes dans le navigateur, comme les autres fichiers (images liées, PDF, téléchargements) et les liens externes. @@ -231,6 +240,58 @@ faut *deux* enregistrements : la page en `AddTransient` (ou de `ViewLocator.Build`. Si l'un manque, l'app affiche "No view for X" sans crash. +## Podcasts : capture, direct et replay + +`PodcastsPageViewModel` est accessible depuis l'accueil Desktop Windows/Linux. +`CameraStreamer` detecte les cameras DirectShow/V4L2 et les microphones +(DirectShow sous Windows, source PulseAudio par defaut sous Linux). +`PodcastBroadcaster` utilise uniquement LibVLC : encodage H.264/AAC, sortie +`livehttp`, segments MPEG-TS d'environ deux secondes. Un segment n'est +envoye que lorsque l'index local LibVLC le declare complet. + +`PodcastApiClient` reutilise le transport bearer et son rafraichissement +des jetons. Les PUT sont numerotes a partir de zero ; une nouvelle tentative +doit avoir exactement les memes octets et la meme duree. Le client supprime +un segment local seulement apres acquittement, et envoie le dernier segment +ferme par LibVLC avant de terminer le direct. La navigation et la deconnexion +arretent la camera. Les erreurs restent visibles ; les fichiers non envoyes +restent disponibles localement pour recuperation manuelle. + +`ILiveProcessor` est un singleton dans Yavsc.Api. Il stocke les metadonnees +et empreintes SHA-256 dans un index JSON remplace atomiquement, avec des +segments immuables accessibles uniquement via les endpoints authentifies. +Deux receptions au plus sont tamponnees en parallele ; les validations de +sequence, de proprietaire et de quota sont serialisees avant commit. +Un index ne reference jamais un segment en cours d'ecriture. + +| Route sous `api/v1/podcasts` | Role | +| --- | --- | +| `GET /`, `POST /` | Catalogue ou creation d'une emission | +| `GET /{id}` | Metadonnees et segments ordonnes | +| `PUT /{id}/segments/{sequence}?duration=...` | Ingestion `video/mp2t`, auteur uniquement | +| `POST /{id}/complete` | Finalisation idempotente du replay | +| `GET /{id}/index.m3u8` | Playlist HLS EVENT, ENDLIST apres fin/interruption | +| `GET /{id}/segments/{sequence}.ts` | Segment engage, lecture authentifiee | +| `DELETE /{id}` | Suppression d'une emission terminee, auteur uniquement | + +Toutes les routes requierent la politique `ApiScope`. La lecture est ouverte +a tous les utilisateurs authentifies avec ce scope, pas seulement a l'auteur. +`PodcastMediaInput` alimente LibVLC en octets MPEG-TS via les requetes +authentifiees existantes, sans transmettre de jeton dans une URL ou un +processus externe. Les rappels natifs ne laissent echapper aucune exception : +l'erreur est remontee a la page de lecture. + +Un direct inactif devient `Interrupted` lors de la prochaine lecture de +l'index ; au redemarrage, tous les anciens directs sont interrompus. +Les segments engages constituent le replay, sans transcodage serveur. +Le stockage exige un seul processus par racine et les quotas podcast sont +independants de ceux des fichiers utilisateur. Voir +[INSTALL.md](../../INSTALL.md#podcast-live-broadcasts-and-replays) pour +les limites et le deploiement ; [Podcast.http](../../src/Yavsc.Api/Podcast.http) +pour les requetes. +Le document OpenAPI dedie est `/openapi/podcasts.json`, accessible en +developpement aux administrateurs comme les autres documents de l'API. + ## Composition root (`App.axaml.cs`) `App.OnFrameworkInitializationCompleted` est le seul endroit où diff --git a/doc/testing.md b/doc/testing.md index ef80cda9b..8b5e8eff1 100644 --- a/doc/testing.md +++ b/doc/testing.md @@ -73,6 +73,30 @@ HTTP, on stub `IAuthorizationService` directement (cf. `BlogspotController` dans `Yavsc.Org.Tests/NonRegression/`) pour éviter de monter un host complet. +## Tests cibles des podcasts + +Avec le runner Microsoft.Testing.Platform du depot (.NET 10, xUnit v3) : + +```bash +dotnet test --project src/test/Yavsc.Api.Tests/Yavsc.Api.Tests.csproj \ + --filter-class '*LiveProcessorTests' --filter-class '*PodcastControllerTests' +dotnet test --project src/PostIt/PostIt.Tests/PostIt.Tests.csproj \ + --filter-class '*PodcastTests' --filter-class '*PodcastNativeCaptureTests' +``` + +Les tests serveur couvrent HTTP/authentification, proprietaire, sequences, +reprises idempotentes, limites exactes, concurrence et persistance des replays +apres interruption/redemarrage, dans des dossiers temporaires isoles. + +Les tests natifs sont explicitement ignores sans `POSTIT_TEST_MEDIA`. +Pour les activer, installer LibVLC avec ses encodeurs H.264/AAC et definir +cette variable sur un fichier video local d'environ huit secondes contenant +une piste audio. Ils exercent la segmentation LibVLC reelle, l'envoi et la +reprise apres acquittement perdu, le dernier segment apres arret et le +decodage authentifie du replay. Aucun FFmpeg n'est requis par ces tests ni +par l'application (le fichier d'entree peut provenir de n'importe quelle source). +Les tests materiels camera/microphone Windows/Linux restent manuels. + ## Quand ne PAS écrire de test Un test qui ne détecte rien n'est pas un test. Si l'invariant diff --git a/src/PostIt/Directory.Packages.props b/src/PostIt/Directory.Packages.props index fbfaa1f38..3b23fc8fb 100644 --- a/src/PostIt/Directory.Packages.props +++ b/src/PostIt/Directory.Packages.props @@ -17,16 +17,16 @@ - + - + - + @@ -36,6 +36,6 @@ - + - \ No newline at end of file + diff --git a/src/PostIt/PostIt.Tests/PodcastNativeCaptureTests.cs b/src/PostIt/PostIt.Tests/PodcastNativeCaptureTests.cs new file mode 100644 index 000000000..8fc0c7cca --- /dev/null +++ b/src/PostIt/PostIt.Tests/PodcastNativeCaptureTests.cs @@ -0,0 +1,166 @@ +using System.Net; +using System.Net.Http; +using LibVLCSharp.Shared; +using PostIt.Services; +using Yavsc.Abstract.Streaming; +using Yavsc.Api.Client; + +namespace PostIt.Tests; + +public sealed class PodcastNativeCaptureTests +{ + public static bool HasFixture => File.Exists(Environment.GetEnvironmentVariable("POSTIT_TEST_MEDIA")); + + [Fact(Skip = "Set POSTIT_TEST_MEDIA to a local video with audio and install LibVLC with H.264/AAC encoders.", SkipUnless = nameof(HasFixture))] + public async Task LibVLC_produces_complete_H264_AAC_HLS_segments_without_external_encoder() + { + var root = Path.Combine(Path.GetTempPath(), $"postit native's hls {Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + try + { + Core.Initialize(); + using var vlc = new LibVLC(); + using var media = new Media(vlc, new Uri(Path.GetFullPath(Environment.GetEnvironmentVariable("POSTIT_TEST_MEDIA")!))); + using var player = new MediaPlayer(vlc); + PodcastBroadcaster.ConfigureOutput(media, root); + var ended = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + player.EndReached += (_, _) => ended.TrySetResult(); + player.EncounteredError += (_, _) => ended.TrySetException(new InvalidOperationException("LibVLC encoding failed.")); + Assert.True(player.Play(media)); + await ended.Task.WaitAsync(TimeSpan.FromSeconds(45), TestContext.Current.CancellationToken); + player.Stop(); + var playlist = await File.ReadAllTextAsync(Path.Combine(root, "index.m3u8"), TestContext.Current.CancellationToken); + var segments = HlsCapturePlaylist.Parse(playlist); + Assert.True(segments.Count >= 2, playlist); + Assert.Equal(0, segments[0].Sequence); + foreach (var segment in segments) + { + var bytes = await File.ReadAllBytesAsync(Path.Combine(root, segment.FileName), TestContext.Current.CancellationToken); + Assert.True(bytes.Length > 188); + Assert.Equal(0, bytes.Length % 188); + Assert.InRange(segment.DurationSeconds, 0.001, 15); + for (var i = 0; i < bytes.Length; i += 188) Assert.Equal(0x47, bytes[i]); + } + } + finally { Directory.Delete(root, recursive: true); } + } + + [Fact(Skip = "Set POSTIT_TEST_MEDIA to a local video with audio and install LibVLC with H.264/AAC encoders.", SkipUnless = nameof(HasFixture))] + public async Task Broadcaster_uploads_final_segment_retries_safely_and_replay_decodes_in_LibVLC() + { + var ct = TestContext.Current.CancellationToken; + await using var transport = new CaptureTransport(); + var client = new PodcastApiClient(transport, () => "https://api.example/api/v1/"); + await using (var broadcaster = await PodcastBroadcaster.StartAsync(client, "Native test", + vlc => new Media(vlc, new Uri(Path.GetFullPath(Environment.GetEnvironmentVariable("POSTIT_TEST_MEDIA")!))), ct)) + { + await transport.FirstTwo.Task.WaitAsync(TimeSpan.FromSeconds(45), ct); + var livePlayback = PlayThroughAsync(client, transport.Podcast.Id, live: true, ct); + await transport.ReadStarted.Task.WaitAsync(TimeSpan.FromSeconds(45), ct); + await broadcaster.StopAsync().WaitAsync(TimeSpan.FromSeconds(45), ct); + Assert.True(broadcaster.Completion.IsCompletedSuccessfully); + await livePlayback; + } + Assert.Equal(PodcastState.Completed, transport.Podcast.State); + Assert.True(transport.Uploaded.Count >= 3); + Assert.Equal(2, transport.FirstSegmentAttempts); + await PlayThroughAsync(client, transport.Podcast.Id, live: false, ct); + } + + private static async Task PlayThroughAsync(PodcastApiClient client, Guid id, bool live, CancellationToken ct) + { + using var vlcPlayer = new LibVLC("--vout=dummy", "--aout=dummy", "--no-xlib"); + using var input = new PodcastMediaInput(client, id, live); + using var replay = new Media(vlcPlayer, input); + replay.AddOption(":demux=ts"); + using var player = new MediaPlayer(vlcPlayer); + var ended = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + input.Failed += message => ended.TrySetException(new InvalidOperationException(message)); + player.EndReached += (_, _) => ended.TrySetResult(); + player.EncounteredError += (_, _) => ended.TrySetException(new InvalidOperationException("Replay decode failed.")); + Assert.True(player.Play(replay)); + try + { + await ended.Task.WaitAsync(TimeSpan.FromSeconds(45), ct); + Assert.Contains(replay.Tracks, track => track.TrackType == TrackType.Video && track.Codec == 0x34363268); + Assert.Contains(replay.Tracks, track => track.TrackType == TrackType.Audio && track.Codec == 0x6134706d); + } + finally + { + input.Cancel(); + player.Stop(); + } + } + + private sealed class CaptureTransport : IYavscApiClient + { + private readonly object _sync = new(); + public HttpClient Http { get; } = new(); + public Dictionary Uploaded { get; } = new(); + private readonly List _segments = new(); + public TaskCompletionSource FirstTwo { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource ReadStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public int FirstSegmentAttempts { get; private set; } + public PodcastResponse Podcast { get; private set; } = new(Guid.NewGuid(), "Test", "alice", "Alice", + DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, PodcastState.Live, 0, 0, 0); + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + lock (_sync) + { + object result; + if (typeof(T) == typeof(PodcastManifestResponse)) + result = new PodcastManifestResponse(Podcast, _segments.ToArray()); + else + { + if (path.EndsWith("/complete", StringComparison.Ordinal)) + Podcast = Podcast with { State = PodcastState.Completed }; + result = Podcast; + } + return Task.FromResult((T)result); + } + } + + public async Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + { + var uri = new Uri(path); + var sequence = int.Parse(uri.Segments[^1], System.Globalization.CultureInfo.InvariantCulture); + var duration = double.Parse(uri.Query["?duration=".Length..], System.Globalization.CultureInfo.InvariantCulture); + using var content = contentFactory(); + var bytes = await content.ReadAsByteArrayAsync(ct); + if (sequence >= 2) await ReadStarted.Task.WaitAsync(ct); + lock (_sync) + { + if (Uploaded.TryGetValue(sequence, out var existing)) + Assert.Equal(existing, bytes); + else + { + Assert.Equal(_segments.Count, sequence); + Uploaded.Add(sequence, bytes); + _segments.Add(new(sequence, duration, bytes.Length)); + Podcast = Podcast with { SegmentCount = _segments.Count, Bytes = Podcast.Bytes + bytes.Length }; + } + if (sequence == 0 && ++FirstSegmentAttempts == 1) + throw new HttpRequestException("Acknowledgement lost", null, HttpStatusCode.ServiceUnavailable); + if (Uploaded.Count >= 2) FirstTwo.TrySetResult(); + return (T)(object)Podcast; + } + } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + => CallAsync(method, path, body, ct); + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, contentFactory, ct); + public Task DownloadAsync(HttpMethod method, string path, CancellationToken ct = default) + { + var sequence = int.Parse(Path.GetFileNameWithoutExtension(new Uri(path).AbsolutePath), + System.Globalization.CultureInfo.InvariantCulture); + lock (_sync) + { + ReadStarted.TrySetResult(); + return Task.FromResult(Uploaded[sequence]); + } + } + public ValueTask DisposeAsync() { Http.Dispose(); return ValueTask.CompletedTask; } + } +} diff --git a/src/PostIt/PostIt.Tests/PodcastTests.cs b/src/PostIt/PostIt.Tests/PodcastTests.cs new file mode 100644 index 000000000..76a75a352 --- /dev/null +++ b/src/PostIt/PostIt.Tests/PodcastTests.cs @@ -0,0 +1,163 @@ +using System.Globalization; +using System.Net.Http; +using System.Runtime.InteropServices; +using LibVLCSharp.Shared; +using PostIt.Services; +using Yavsc.Abstract.Streaming; +using Yavsc.Api.Client; + +namespace PostIt.Tests; + +public sealed class PodcastTests +{ + private static readonly Guid Id = Guid.Parse("9e2f6d47-2b9a-47ae-a870-073980bf2628"); + + [Fact] + public void Parses_only_complete_ordered_capture_segments_with_invariant_durations() + { + var segments = HlsCapturePlaylist.Parse("#EXTM3U\n#EXTINF:2.5,\nsegment-00000000.ts\n#EXTINF:1.75,\nsegment-00000001.ts\n"); + Assert.Equal(2, segments.Count); + Assert.Equal(2.5, segments[0].DurationSeconds); + Assert.Equal(1, segments[1].Sequence); + Assert.Empty(HlsCapturePlaylist.Parse("#EXTM3U\n#EXTINF:2,\n")); + } + + [Theory] + [InlineData("#EXTINF:NaN,\nsegment-00000000.ts\n")] + [InlineData("#EXTINF:20,\nsegment-00000000.ts\n")] + [InlineData("#EXTINF:2,\n../escape.ts\n")] + [InlineData("#EXTINF:2,\nhttps://other.test/segment-00000000.ts\n")] + [InlineData("#EXTINF:2,\nsegment-00000000.ts\n#EXTINF:2,\nsegment-00000002.ts\n")] + public void Rejects_invalid_or_nonlocal_capture_playlist(string text) + => Assert.Throws(() => HlsCapturePlaylist.Parse(text)); + + [Fact] + public async Task Upload_uses_raw_transport_stream_and_replayable_content_factory() + { + var transport = new RecordingTransport(); + var client = new PodcastApiClient(transport, () => "https://api.example/"); + var previous = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("fr-FR"); + await client.UploadAsync(Id, 0, 2.5, new byte[] { 0x47, 1 }, TestContext.Current.CancellationToken); + Assert.EndsWith($"/api/v1/podcasts/{Id}/segments/0?duration=2.5", transport.Path); + Assert.Equal(HttpMethod.Put, transport.Method); + Assert.NotNull(transport.ContentFactory); + using var first = transport.ContentFactory(); + using var retry = transport.ContentFactory(); + Assert.Equal("video/mp2t", first.Headers.ContentType?.MediaType); + Assert.Equal(new byte[] { 0x47, 1 }, await first.ReadAsByteArrayAsync(TestContext.Current.CancellationToken)); + Assert.Equal(await first.ReadAsByteArrayAsync(TestContext.Current.CancellationToken), + await retry.ReadAsByteArrayAsync(TestContext.Current.CancellationToken)); + } + finally { CultureInfo.CurrentCulture = previous; } + } + + [Theory] + [InlineData("https://api.example/")] + [InlineData("https://api.example/api/v1/")] + [InlineData("https://api.example/api/v1")] + public async Task Configured_api_url_never_duplicates_the_api_prefix(string baseUrl) + { + var transport = new RecordingTransport(); + var client = new PodcastApiClient(transport, () => baseUrl); + await client.GetAsync(Id, TestContext.Current.CancellationToken); + Assert.Equal($"https://api.example/api/v1/podcasts/{Id}", transport.Path); + } + [Fact] + public void Authenticated_playback_reads_segments_in_order_and_reports_end_of_replay() + { + var transport = new RecordingTransport(); + var client = new PodcastApiClient(transport, () => "https://api.example/"); + using var input = new PodcastMediaInput(client, Id, live: false); + var buffer = Marshal.AllocHGlobal(4); + try + { + Assert.True(input.Open(out var size)); + Assert.Equal(ulong.MaxValue, size); + Assert.Equal(2, input.Read(buffer, 4)); + Assert.Equal(0x47, Marshal.ReadByte(buffer)); + Assert.Equal(0, input.Read(buffer, 4)); + Assert.EndsWith($"/{Id}/segments/0.ts", transport.DownloadPath); + } + finally { Marshal.FreeHGlobal(buffer); } + } + + [Fact] + public void Playback_errors_are_reported_without_crossing_the_native_callback_boundary() + { + var transport = new RecordingTransport { FailDownload = true }; + var client = new PodcastApiClient(transport, () => "https://api.example/"); + using var input = new PodcastMediaInput(client, Id, live: false); + string? error = null; + input.Failed += message => error = message; + var buffer = Marshal.AllocHGlobal(4); + try + { + Assert.Equal(-1, input.Read(buffer, 4)); + Assert.Contains("401", error); + } + finally { Marshal.FreeHGlobal(buffer); } + } + + [Fact] + public void Live_playback_joins_three_segments_behind_the_edge_and_cancellation_is_quiet() + { + var transport = new RecordingTransport { SegmentCount = 8 }; + var client = new PodcastApiClient(transport, () => "https://api.example/"); + using var input = new PodcastMediaInput(client, Id, live: true); + string? error = null; + input.Failed += message => error = message; + var buffer = Marshal.AllocHGlobal(4); + try + { + Assert.Equal(2, input.Read(buffer, 4)); + Assert.EndsWith($"/{Id}/segments/5.ts", transport.DownloadPath); + input.Cancel(); + Assert.Equal(-1, input.Read(buffer, 4)); + Assert.Null(error); + } + finally { Marshal.FreeHGlobal(buffer); } + } + + private sealed class RecordingTransport : IYavscApiClient + { + public HttpClient Http { get; } = new(); + public HttpMethod? Method { get; private set; } + public string? Path { get; private set; } + public string? DownloadPath { get; private set; } + public Func? ContentFactory { get; private set; } + public bool FailDownload { get; init; } + public int SegmentCount { get; init; } = 1; + + private static PodcastResponse Podcast => new(Id, "Live", "alice", "Alice", + DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, PodcastState.Completed, 1, 2, 2); + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + Method = method; + Path = path; + object response = typeof(T) == typeof(PodcastManifestResponse) + ? new PodcastManifestResponse(Podcast with { SegmentCount = SegmentCount }, + Enumerable.Range(0, SegmentCount).Select(i => new PodcastSegmentResponse(i, 2, 2)).ToArray()) : Podcast; + return Task.FromResult((T)response); + } + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + { + ContentFactory = contentFactory; + return CallAsync(method, path, ct: ct); + } + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + => CallAsync(method, path, body, ct); + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, contentFactory, ct); + public Task DownloadAsync(HttpMethod method, string path, CancellationToken ct = default) + { + DownloadPath = path; + if (FailDownload) throw new HttpRequestException("401 Unauthorized"); + return Task.FromResult(new byte[] { 0x47, 1 }); + } + public ValueTask DisposeAsync() { Http.Dispose(); return ValueTask.CompletedTask; } + } +} diff --git a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs index d6a0f6fed..d764507de 100644 --- a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs +++ b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs @@ -38,6 +38,7 @@ public static class ServiceCollectionHelpers var resourceUsageClient = new ResourceUsageApiClient(api, () => settings.ApiUrl); var administrationClient = new AdministrationApiClient(api, () => settings.ApiUrl); var notificationsClient = new NotificationsApiClient(api, () => settings.ApiUrl); + var podcastClient = new PodcastApiClient(api, () => settings.ApiUrl); var userDirectory = new UserDirectory(userSearchClient); var reverseGeocoding = new NominatimReverseGeocodingService(); @@ -46,6 +47,7 @@ public static class ServiceCollectionHelpers services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); // Models @@ -106,6 +108,8 @@ public static class ServiceCollectionHelpers services.AddSingleton(resourceUsageClient); services.AddSingleton(administrationClient); services.AddSingleton(notificationsClient); + services.AddSingleton(podcastClient); + services.AddTransient(); services.AddSingleton(userFilesClient); services.AddSingleton(reverseGeocoding); services.AddSingleton(userDirectory); diff --git a/src/PostIt/PostIt/PostIt.csproj b/src/PostIt/PostIt/PostIt.csproj index 27bdf298c..e080b9a26 100644 --- a/src/PostIt/PostIt/PostIt.csproj +++ b/src/PostIt/PostIt/PostIt.csproj @@ -12,6 +12,7 @@ + diff --git a/src/PostIt/PostIt/Services/CameraStreamer.cs b/src/PostIt/PostIt/Services/CameraStreamer.cs index d78a84349..322331772 100644 --- a/src/PostIt/PostIt/Services/CameraStreamer.cs +++ b/src/PostIt/PostIt/Services/CameraStreamer.cs @@ -20,27 +20,14 @@ public class CameraStreamer "--aout=dummy", "--intf=dummy"); - public static LibVLC CreatePlaybackLibVlc() - { - if (!OperatingSystem.IsLinux()) - return new LibVLC(); - - var preferHardware = string.Equals(Environment.GetEnvironmentVariable("POSTIT_LIBVLC_ENABLE_HW"), "1", - StringComparison.OrdinalIgnoreCase); - - if (preferHardware && IsVaapiUsable()) - return new LibVLC(); - - // On Linux, a non-hardware display path is still valid for in-app playback as long as - // the platform is not explicitly forced into a broken VAAPI/VDPAU stack. - return new LibVLC("--avcodec-hw=none"); - } - public static LibVLC CreatePreferredLibVlc() { if (!OperatingSystem.IsLinux()) return new LibVLC(); + if (ShouldDisableHardwareAcceleration()) + return CreateHeadlessLibVlc(); + var preferHardware = string.Equals(Environment.GetEnvironmentVariable("POSTIT_LIBVLC_ENABLE_HW"), "1", StringComparison.OrdinalIgnoreCase); @@ -50,6 +37,10 @@ public class CameraStreamer return CreateHeadlessLibVlc(); } + private static bool ShouldDisableHardwareAcceleration() + => string.Equals(Environment.GetEnvironmentVariable("POSTIT_LIBVLC_DISABLE_HW"), "1", + StringComparison.OrdinalIgnoreCase); + private static bool IsVaapiUsable() { var vaapiDrivers = new[] diff --git a/src/PostIt/PostIt/Services/HlsCapturePlaylist.cs b/src/PostIt/PostIt/Services/HlsCapturePlaylist.cs new file mode 100644 index 000000000..5e4a02826 --- /dev/null +++ b/src/PostIt/PostIt/Services/HlsCapturePlaylist.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text.RegularExpressions; + +namespace PostIt.Services; + +public sealed record CaptureSegment(int Sequence, double DurationSeconds, string FileName); + +public static partial class HlsCapturePlaylist +{ + [GeneratedRegex(@"^segment-(\d{8})\.ts$", RegexOptions.CultureInvariant)] + private static partial Regex SegmentName(); + + public static IReadOnlyList Parse(string playlist) + { + var segments = new List(); + double? duration = null; + foreach (var line in playlist.Split('\n')) + { + var text = line.Trim(); + if (text.StartsWith("#EXTINF:", StringComparison.Ordinal)) + { + if (!double.TryParse(text[8..].Split(',')[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var value) + || !double.IsFinite(value) || value <= 0 || value > 15) + throw new FormatException("Duree du segment LibVLC invalide."); + duration = value; + } + else if (text.Length > 0 && !text.StartsWith('#')) + { + var match = SegmentName().Match(text); + if (!match.Success || duration is null) + throw new FormatException("Index HLS LibVLC invalide."); + var sequence = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); + if (segments.Count > 0 && sequence != segments[^1].Sequence + 1) + throw new FormatException("Segments HLS non consecutifs."); + segments.Add(new(sequence, duration.Value, text)); + duration = null; + } + } + return segments; + } +} diff --git a/src/PostIt/PostIt/Services/PodcastBroadcaster.cs b/src/PostIt/PostIt/Services/PodcastBroadcaster.cs new file mode 100644 index 000000000..ac402f6e0 --- /dev/null +++ b/src/PostIt/PostIt/Services/PodcastBroadcaster.cs @@ -0,0 +1,231 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using System.Text.Json; +using LibVLCSharp.Shared; +using Yavsc.Abstract.Streaming; +using Yavsc.Api.Client; + +namespace PostIt.Services; + +public sealed class PodcastBroadcaster : IAsyncDisposable +{ + private readonly PodcastApiClient _api; + private readonly LibVLC _vlc; + private readonly MediaPlayer _player; + private readonly Media _media; + private readonly string _directory; + private readonly CancellationTokenSource _cancellation = new(); + private readonly SemaphoreSlim _stopGate = new(1); + private volatile bool _stopped; + private volatile bool _encoderFailed; + private bool _disposed; + + public PodcastResponse Podcast { get; private set; } + public Task Completion { get; private set; } = Task.CompletedTask; + public event Action? Progress; + + private PodcastBroadcaster(PodcastApiClient api, PodcastResponse podcast, + LibVLC vlc, MediaPlayer player, Media media, string directory) + { + _api = api; + Podcast = podcast; + _vlc = vlc; + _player = player; + _media = media; + _directory = directory; + _player.EncounteredError += OnEncoderError; + } + + public static void ConfigureOutput(Media media, string directory) + { + var path = directory.Replace('\\', '/').Replace("\"", "\\\""); + media.AddOption(":sout=#transcode{vcodec=h264,venc=x264{preset=ultrafast,tune=zerolatency,keyint=60},vb=1200,fps=30," + + "acodec=mp4a,ab=128,channels=2,samplerate=48000}:std{" + + $"access=livehttp{{seglen=2,delsegs=false,numsegs=0,initial-segment-number=0,index=\"{path}/index.m3u8\"," + + $"index-url=segment-########.ts}},mux=ts{{use-key-frames}},dst=\"{path}/segment-########.ts\"}}"); + } + + public static async Task StartAsync(PodcastApiClient api, string title, + string camera, string? microphone, CancellationToken ct) + => await StartAsync(api, title, vlc => new CameraStreamer(vlc).CreateCaptureMedia(camera, microphone), ct); + + internal static async Task StartAsync(PodcastApiClient api, string title, + Func createMedia, CancellationToken ct) + { + Core.Initialize(); + var vlc = CameraStreamer.CreatePreferredLibVlc(); + Media? media = null; + MediaPlayer? player = null; + PodcastBroadcaster? broadcaster = null; + var directory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "PostIt", "podcast-capture", Guid.NewGuid().ToString("N")); + PodcastResponse? podcast = null; + try + { + Directory.CreateDirectory(directory); + media = createMedia(vlc); + ConfigureOutput(media, directory); + player = new MediaPlayer(vlc); + podcast = await api.CreateAsync(title, ct); + await File.WriteAllTextAsync(Path.Combine(directory, "podcast.json"), JsonSerializer.Serialize(podcast), ct); + ct.ThrowIfCancellationRequested(); + broadcaster = new PodcastBroadcaster(api, podcast, vlc, player, media, directory); + if (!player.Play(media)) + throw new InvalidOperationException("LibVLC ne peut pas demarrer la capture. Verifiez la camera et les encodeurs H.264/AAC."); + broadcaster.Completion = broadcaster.UploadAsync(broadcaster._cancellation.Token); + return broadcaster; + } + catch (Exception startupError) + { + if (broadcaster is not null) + { + broadcaster._player.EncounteredError -= broadcaster.OnEncoderError; + broadcaster._cancellation.Dispose(); + broadcaster._stopGate.Dispose(); + } + player?.Dispose(); + media?.Dispose(); + vlc.Dispose(); + if (Directory.Exists(directory)) Directory.Delete(directory, recursive: true); + if (podcast is not null) + { + try + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + await api.CompleteAsync(podcast.Id, timeout.Token); + } + catch (Exception finishError) + { + throw new AggregateException("Capture impossible ; le direct serveur expirera automatiquement.", startupError, finishError); + } + } + throw; + } + } + + public async Task StopAsync() + { + await StopEncoderAsync(); + await Completion; + } + + private async Task StopEncoderAsync() + { + await _stopGate.WaitAsync(); + try + { + if (!_stopped) + { + await Task.Run(_player.Stop); + _stopped = true; + } + } + finally { _stopGate.Release(); } + } + + private async Task UploadAsync(CancellationToken ct) + { + var next = 0; + var lastSegment = Stopwatch.StartNew(); + try + { + while (true) + { + ct.ThrowIfCancellationRequested(); + var finalPass = _stopped; + if (_encoderFailed) + throw new InvalidOperationException("La capture LibVLC a echoue. Verifiez camera, microphone et encodeurs H.264/AAC."); + var pendingBytes = Directory.EnumerateFiles(_directory, "*.ts").Sum(path => new FileInfo(path).Length); + if (pendingBytes > 64L * 1024 * 1024) + throw new IOException("Le reseau est trop lent : la capture depasse 64 Mio de segments en attente."); + var playlist = Path.Combine(_directory, "index.m3u8"); + if (File.Exists(playlist)) + { + using var reader = new StreamReader(new FileStream(playlist, FileMode.Open, + FileAccess.Read, FileShare.ReadWrite | FileShare.Delete)); + var segments = HlsCapturePlaylist.Parse(await reader.ReadToEndAsync(ct)); + foreach (var segment in segments.Where(s => s.Sequence >= next)) + { + if (segment.Sequence != next) + throw new InvalidDataException("Un segment manque dans la capture."); + var path = Path.Combine(_directory, segment.FileName); + if (new FileInfo(path).Length > 8 * 1024 * 1024) + throw new IOException("Un segment de capture depasse 8 Mio."); + var bytes = await File.ReadAllBytesAsync(path, ct); + var response = await UploadWithRetryAsync(segment, bytes, ct); + Podcast = response; + File.Delete(path); + next++; + lastSegment.Restart(); + Progress?.Invoke(response); + } + } + if (finalPass) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(TimeSpan.FromSeconds(20)); + Podcast = await _api.CompleteAsync(Podcast.Id, timeout.Token); + return; + } + if (lastSegment.Elapsed > TimeSpan.FromSeconds(25)) + throw new IOException("Aucun segment produit depuis 25 secondes. Verifiez la capture et les encodeurs LibVLC."); + await Task.Delay(400, ct); + } + } + finally + { + // Stop the camera even when uploading or parsing fails. + await StopEncoderAsync(); + } + } + + private async Task UploadWithRetryAsync(CaptureSegment segment, byte[] bytes, CancellationToken ct) + { + for (var attempt = 0; ; attempt++) + { + try + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(TimeSpan.FromSeconds(10)); + return await _api.UploadAsync(Podcast.Id, segment.Sequence, segment.DurationSeconds, bytes, timeout.Token); + } + catch (HttpRequestException ex) when (attempt < 2 && (ex.StatusCode is null + or HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests || (int)ex.StatusCode >= 500)) + { + await Task.Delay(TimeSpan.FromSeconds(attempt + 1), ct); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested && attempt < 2) + { + await Task.Delay(TimeSpan.FromSeconds(attempt + 1), ct); + } + } + } + + private void OnEncoderError(object? sender, EventArgs e) => _encoderFailed = true; + + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + _cancellation.Cancel(); + try { await Completion; } + finally + { + _player.EncounteredError -= OnEncoderError; + _player.Dispose(); + _media.Dispose(); + _vlc.Dispose(); + _cancellation.Dispose(); + _stopGate.Dispose(); + // Keep failed, unacknowledged segments for manual recovery. + if (Completion.IsCompletedSuccessfully && Directory.Exists(_directory)) + Directory.Delete(_directory, recursive: true); + } + } +} diff --git a/src/PostIt/PostIt/Services/PodcastMediaInput.cs b/src/PostIt/PostIt/Services/PodcastMediaInput.cs new file mode 100644 index 000000000..1cd5657eb --- /dev/null +++ b/src/PostIt/PostIt/Services/PodcastMediaInput.cs @@ -0,0 +1,78 @@ +using System; +using System.Runtime.InteropServices; +using System.Threading; +using LibVLCSharp.Shared; +using Yavsc.Abstract.Streaming; +using Yavsc.Api.Client; + +namespace PostIt.Services; + +// LibVLC consumes a sequential transport stream; credentials stay in the existing HTTP transport. +public sealed class PodcastMediaInput(PodcastApiClient api, Guid id, bool live) : MediaInput +{ + private readonly CancellationTokenSource _cancellation = new(); + private byte[] _segment = Array.Empty(); + private int _offset; + private int _sequence = -1; + public event Action? Failed; + + public override bool Open(out ulong size) + { + size = ulong.MaxValue; + return true; + } + + public override int Read(IntPtr buffer, uint length) + { + if (length == 0) return 0; + try + { + var ct = _cancellation.Token; + ct.ThrowIfCancellationRequested(); + while (_offset == _segment.Length) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(TimeSpan.FromSeconds(20)); + var manifest = api.GetAsync(id, timeout.Token).GetAwaiter().GetResult(); + if (_sequence < 0) + _sequence = live ? Math.Max(0, manifest.Segments.Count - 3) : 0; + if (_sequence < manifest.Segments.Count) + { + _segment = api.DownloadSegmentAsync(id, _sequence, timeout.Token).GetAwaiter().GetResult(); + if (_segment.Length == 0 || _segment.Length != manifest.Segments[_sequence].Bytes) + throw new InvalidOperationException("Segment podcast incomplet."); + _sequence++; + _offset = 0; + break; + } + if (manifest.Podcast.State != PodcastState.Live) return 0; + if (ct.WaitHandle.WaitOne(700)) ct.ThrowIfCancellationRequested(); + } + var count = (int)Math.Min(length, (uint)(_segment.Length - _offset)); + Marshal.Copy(_segment, _offset, buffer, count); + _offset += count; + return count; + } + catch (OperationCanceledException) when (_cancellation.IsCancellationRequested) { return -1; } + // No managed exception may escape a native LibVLC callback. + catch (Exception ex) + { + Failed?.Invoke($"Lecture du podcast interrompue : {ex.Message}"); + return -1; + } + } + + public override bool Seek(ulong offset) => false; + public override void Close() { } + public void Cancel() => _cancellation.Cancel(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _cancellation.Cancel(); + _cancellation.Dispose(); + } + base.Dispose(disposing); + } +} diff --git a/src/PostIt/PostIt/Services/YavscApiClient.cs b/src/PostIt/PostIt/Services/YavscApiClient.cs index 296e8b3b1..c19b61f20 100644 --- a/src/PostIt/PostIt/Services/YavscApiClient.cs +++ b/src/PostIt/PostIt/Services/YavscApiClient.cs @@ -540,8 +540,11 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable response = await sendOnce().ConfigureAwait(false); } - await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false); - return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false); + using (response) + { + await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false); + return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false); + } } public async Task LogoutAsync() diff --git a/src/PostIt/PostIt/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs index 5807df93a..eff0094a5 100644 --- a/src/PostIt/PostIt/ViewLocator.cs +++ b/src/PostIt/PostIt/ViewLocator.cs @@ -43,6 +43,7 @@ public class ViewLocator : IDataTemplate { BlogsViewModel => services.GetRequiredService(), VideoPageViewModel => services.GetRequiredService(), + PodcastsPageViewModel => services.GetRequiredService(), Settings => services.GetRequiredService(), AboutPageViewModel => services.GetRequiredService(), HomePageViewModel => services.GetRequiredService(), diff --git a/src/PostIt/PostIt/ViewModels/Blogs/VideoPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Blogs/VideoPageViewModel.cs index 9ce6e8f17..e8fc1e535 100644 --- a/src/PostIt/PostIt/ViewModels/Blogs/VideoPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Blogs/VideoPageViewModel.cs @@ -1,4 +1,6 @@ using System; +using Yavsc.Abstract.Streaming; +using Yavsc.Api.Client; namespace PostIt.ViewModels; @@ -6,7 +8,10 @@ public sealed class VideoPageViewModel(Uri source, string mimeType) : ViewModelB { public Uri Source { get; } = source; public string MimeType { get; } = mimeType; - public string Title => Uri.UnescapeDataString(System.IO.Path.GetFileName(Source.AbsolutePath)); + public PodcastApiClient? PodcastClient { get; init; } + public PodcastResponse? Podcast { get; init; } + public bool CanOpenExternal => Podcast is null; + public string Title => Podcast?.Title ?? Uri.UnescapeDataString(System.IO.Path.GetFileName(Source.AbsolutePath)); public override bool CanNavigateNext { get => false; protected set { } } public override bool CanNavigatePrevious { get => true; protected set { } } } diff --git a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs index 253ac17c0..d2e023fd4 100644 --- a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs @@ -74,10 +74,19 @@ public class HomePageViewModel : ViewModelBase OpenPerformerConfiguration = new AsyncRelayCommand(OpenPerformerConfigurationAsync); OpenAdministration = new AsyncRelayCommand(OpenAdministrationAsync, () => SessionStatus.IsAdministrator); OpenChat = new AsyncRelayCommand(OpenChatAsync); + OpenPodcasts = new AsyncRelayCommand(async () => + { + var app = Application.Current as App ?? throw new InvalidOperationException("Application indisponible."); + var vm = app.ServiceProvider?.GetRequiredService() + ?? throw new InvalidOperationException("Page podcasts indisponible."); + await app.PushPageAsync(vm); + }); } public IAsyncRelayCommand OpenChat { get; } + public IAsyncRelayCommand OpenPodcasts { get; } + public bool SupportsPodcasts => OperatingSystem.IsWindows() || OperatingSystem.IsLinux(); public IAsyncRelayCommand OpenBlogs { get; } public IAsyncRelayCommand OpenProfile { get; } public IAsyncRelayCommand OpenPerformerConfiguration { get; } diff --git a/src/PostIt/PostIt/ViewModels/PodcastsPageViewModel.cs b/src/PostIt/PostIt/ViewModels/PodcastsPageViewModel.cs new file mode 100644 index 000000000..e5375b2d0 --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/PodcastsPageViewModel.cs @@ -0,0 +1,276 @@ +using System; +using System.Collections.ObjectModel; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LibVLCSharp.Shared; +using PostIt.Services; +using Yavsc.Abstract.Identity; +using Yavsc.Abstract.Streaming; +using Yavsc.Api.Client; + +namespace PostIt.ViewModels; + +public sealed record CaptureDevice(string Name, string Value); + +public partial class PodcastsPageViewModel : ViewModelBase +{ + private readonly PodcastApiClient _client; + private readonly YavscApiClient _api; + private readonly SessionStatusViewModel _session; + private CancellationTokenSource _pageCancellation = new(); + private PodcastBroadcaster? _broadcaster; + private string? _ownerId; + private string _title = ""; + private string _status = ""; + private bool _isBroadcasting; + private bool _busy; + private CaptureDevice? _camera; + private CaptureDevice? _microphone; + private PodcastResponse? _selected; + + public ObservableCollection Podcasts { get; } = new(); + public ObservableCollection Cameras { get; } = new(); + public ObservableCollection Microphones { get; } = new(); + public bool IsDesktop => OperatingSystem.IsWindows() || OperatingSystem.IsLinux(); + + + public string Title { get => _title; set { if (SetProperty(ref _title, value)) UpdateCommands(); } } + + + public string Status { get => _status; private set => SetProperty(ref _status, value); } + public bool IsBroadcasting + { + get => _isBroadcasting; + private set { if (SetProperty(ref _isBroadcasting, value)) UpdateCommands(); } + } + + public bool IsBusy + { + get => _busy; + private set { if (SetProperty(ref _busy, value)) UpdateCommands(); } + } + + public CaptureDevice? SelectedCamera + { + get => _camera; + set { if (SetProperty(ref _camera, value)) UpdateCommands(); } + } + + public CaptureDevice? SelectedMicrophone + { + get => _microphone; + set { if (SetProperty(ref _microphone, value)) UpdateCommands(); } + } + + public PodcastResponse? SelectedPodcast + { + get => _selected; + set { if (SetProperty(ref _selected, value)) UpdateCommands(); } + } + + public IAsyncRelayCommand RefreshCommand { get; } + public IRelayCommand DetectDevicesCommand { get; } + public IAsyncRelayCommand StartCommand { get; } + public IAsyncRelayCommand StopCommand { get; } + public IAsyncRelayCommand PlayCommand { get; } + public IAsyncRelayCommand FinishCommand { get; } + public IAsyncRelayCommand DeleteCommand { get; } + public override bool CanNavigateNext { get => false; protected set { } } + public override bool CanNavigatePrevious { get => !IsBroadcasting; protected set { } } + + public PodcastsPageViewModel(PodcastApiClient client, YavscApiClient api, SessionStatusViewModel session) + { + _client = client; + _api = api; + _session = session; + RefreshCommand = new AsyncRelayCommand(RefreshAsync, () => !IsBusy); + DetectDevicesCommand = new RelayCommand(DetectDevices, () => IsDesktop && !IsBroadcasting && !IsBusy); + StartCommand = new AsyncRelayCommand(StartAsync, + () => IsDesktop && !IsBroadcasting && !IsBusy && _session.IsLoggedIn + && SelectedCamera is not null && !string.IsNullOrWhiteSpace(Title)); + StopCommand = new AsyncRelayCommand(StopAsync, () => IsBroadcasting && !IsBusy); + PlayCommand = new AsyncRelayCommand(PlayAsync, () => SelectedPodcast is { SegmentCount: > 0 } && !IsBroadcasting && !IsBusy); + FinishCommand = new AsyncRelayCommand(FinishAsync, + () => SelectedPodcast is { State: not PodcastState.Completed } p + && p.OwnerId == _ownerId && !IsBroadcasting && !IsBusy); + DeleteCommand = new AsyncRelayCommand(DeleteAsync, + () => SelectedPodcast is { State: not PodcastState.Live } p + && p.OwnerId == _ownerId && !IsBroadcasting && !IsBusy); + } + + private void UpdateCommands() + { + RefreshCommand.NotifyCanExecuteChanged(); + DetectDevicesCommand.NotifyCanExecuteChanged(); + StartCommand.NotifyCanExecuteChanged(); + StopCommand.NotifyCanExecuteChanged(); + PlayCommand.NotifyCanExecuteChanged(); + FinishCommand.NotifyCanExecuteChanged(); + DeleteCommand.NotifyCanExecuteChanged(); + OnPropertyChanged(nameof(CanNavigatePrevious)); + } + + public async Task RefreshAsync() + { + IsBusy = true; + try + { + var profileUrl = new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/"), "account/me"); + _ownerId = (await _api.CallAsync(HttpMethod.Get, profileUrl.AbsoluteUri)).UserId; + var podcasts = await _client.ListAsync(); + Podcasts.Clear(); + foreach (var podcast in podcasts) Podcasts.Add(podcast); + Status = "Directs et replays reserves aux utilisateurs connectes."; + } + catch (Exception ex) { Status = $"Chargement impossible : {ex.Message}"; } + finally { IsBusy = false; } + } + + public async Task ActivateAsync() + { + if (_pageCancellation.IsCancellationRequested) + { + _pageCancellation.Dispose(); + _pageCancellation = new(); + } + await RefreshAsync(); + } + + public async Task DeactivateAsync() + { + _pageCancellation.Cancel(); + if (_broadcaster is not null) await StopAsync(); + } + + private void DetectDevices() + { + try + { + Core.Initialize(); + using var vlc = CameraStreamer.CreatePreferredLibVlc(); + var capture = new CameraStreamer(vlc); + Cameras.Clear(); + foreach (var item in capture.GetCaptureDevices()) Cameras.Add(new(item.Key, item.Value)); + Microphones.Clear(); + Microphones.Add(new("Sans microphone", "")); + foreach (var item in capture.GetAudioCaptureDevices()) Microphones.Add(new(item.Key, item.Value)); + SelectedCamera = Cameras.Count == 0 ? null : Cameras[0]; + SelectedMicrophone = Microphones.Count > 1 ? Microphones[1] : Microphones[0]; + Status = Cameras.Count == 0 ? "Aucune camera detectee." : "Choisissez camera et microphone avant de demarrer."; + } + catch (Exception ex) { Status = $"Detection impossible : {ex.Message}. Verifiez l'installation de LibVLC."; } + } + + private async Task StartAsync() + { + IsBusy = true; + try + { + if (SelectedCamera is null) throw new InvalidOperationException("Choisissez une camera."); + _broadcaster = await PodcastBroadcaster.StartAsync(_client, Title, SelectedCamera.Value, + string.IsNullOrWhiteSpace(SelectedMicrophone?.Value) ? null : SelectedMicrophone.Value, _pageCancellation.Token); + _broadcaster.Progress += OnProgress; + _session.LogoutCompleted += OnLogout; + IsBroadcasting = true; + Status = "Capture en cours ; attente du premier segment HLS."; + _ = ObserveAsync(_broadcaster); + } + catch (Exception ex) { Status = $"Demarrage impossible : {ex.Message}"; } + finally { IsBusy = false; } + } + + private void OnProgress(PodcastResponse podcast) + => Dispatcher.UIThread.Post(() => + { + if (_broadcaster?.Podcast.Id == podcast.Id) + Status = $"En direct : {podcast.SegmentCount} segments, {podcast.DurationSeconds:0} s envoyees."; + }); + + private async Task ObserveAsync(PodcastBroadcaster broadcaster) + { + try + { + await broadcaster.Completion; + Status = broadcaster.Podcast.SegmentCount > 0 + ? "Direct termine. Le replay est disponible." + : "Direct termine sans segment recu : aucun replay a lire."; + } + catch (Exception ex) + { + Status = $"Diffusion interrompue : {ex.Message}. Les segments recus restent disponibles ; les segments non envoyes sont conserves localement."; + } + finally + { + broadcaster.Progress -= OnProgress; + _session.LogoutCompleted -= OnLogout; + try { await broadcaster.DisposeAsync(); } + catch (Exception ex) { Status += $" Nettoyage : {ex.Message}"; } + _broadcaster = null; + IsBroadcasting = false; + } + } + + public async Task StopAsync() + { + IsBusy = true; + try + { + if (_broadcaster is not null) await _broadcaster.StopAsync(); + } + catch (Exception ex) { Status = $"Arret du direct : {ex.Message}"; } + finally { IsBusy = false; } + } + + private async void OnLogout() + { + // Releasing capture is required even after the HTTP credentials have been cleared. + await StopAsync(); + } + + private async Task PlayAsync() + { + try + { + var podcast = SelectedPodcast ?? throw new InvalidOperationException("Selectionnez un podcast."); + var app = Application.Current as App ?? throw new InvalidOperationException("Application indisponible."); + var manifestUrl = new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/"), $"podcasts/{podcast.Id}/index.m3u8"); + await app.PushPageAsync(new VideoPageViewModel(manifestUrl, "application/vnd.apple.mpegurl") + { + PodcastClient = _client, Podcast = podcast + }); + } + catch (Exception ex) { Status = $"Lecture impossible : {ex.Message}"; } + } + + private async Task FinishAsync() + { + IsBusy = true; + try + { + var podcast = SelectedPodcast ?? throw new InvalidOperationException("Selectionnez un podcast."); + await _client.CompleteAsync(podcast.Id); + await RefreshAsync(); + } + catch (Exception ex) { Status = $"Finalisation impossible : {ex.Message}"; } + finally { IsBusy = false; } + } + + private async Task DeleteAsync() + { + IsBusy = true; + try + { + var podcast = SelectedPodcast ?? throw new InvalidOperationException("Selectionnez un podcast."); + await _client.DeleteAsync(podcast.Id); + SelectedPodcast = null; + await RefreshAsync(); + } + catch (Exception ex) { Status = $"Suppression impossible : {ex.Message}"; } + finally { IsBusy = false; } + } +} diff --git a/src/PostIt/PostIt/Views/Blogs/VideoPage.axaml b/src/PostIt/PostIt/Views/Blogs/VideoPage.axaml index c00cdf644..4f40934d8 100644 --- a/src/PostIt/PostIt/Views/Blogs/VideoPage.axaml +++ b/src/PostIt/PostIt/Views/Blogs/VideoPage.axaml @@ -7,12 +7,12 @@ x:DataType="vm:VideoPageViewModel"> - + S'inscrire Installer PostIt @@ -57,3 +59,29 @@ } + + diff --git a/src/Yavsc.Org/Views/FrontOffice/Profiles.cshtml b/src/Yavsc.Org/Views/FrontOffice/Profiles.cshtml index a2f5cdab1..d5daf5afd 100644 --- a/src/Yavsc.Org/Views/FrontOffice/Profiles.cshtml +++ b/src/Yavsc.Org/Views/FrontOffice/Profiles.cshtml @@ -2,10 +2,26 @@ @{ ViewBag.Title = (ViewBag.Activity?.Name ?? "Any") ; + var activityCode = ViewBag.Activity?.Code as string; + var performerId = ViewBag.PerformerId as string; + var permalinkExpiry = ViewBag.PermalinkExpiry is DateTimeOffset expiry ? expiry : DateTimeOffset.MinValue; }

@ViewBag.Title

@ViewBag.Activity.Description +@if (!string.IsNullOrWhiteSpace(performerId) && !string.IsNullOrWhiteSpace(activityCode)) +{ + +} + @foreach (var profile in Model) {
@Html.DisplayFor(m=>profile) @@ -15,3 +31,29 @@ } + + From d476c08046af66a79a0a554b040e5ccb7172df33 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Fri, 23 Oct 2026 13:32:42 +0100 Subject: [PATCH 16/18] Bug fix - notifications audience * Performers had their notifications publicaly published. * refacto admin group name --- CHANGELOG.md | 12 +- doc/README.md | 1 + doc/architecture/notifications.md | 229 ++++++++++++++++++ .../Layout/SessionStatusViewModel.cs | 2 +- src/Yavsc.Abstract/Constants.cs | 3 +- .../NotificationsApiController.cs | 2 +- .../ResourceUsageApiController.cs | 2 +- .../SiteAdministrationApiController.cs | 2 +- .../Controllers/Business/BillingController.cs | 2 +- .../Business/EstimateApiController.cs | 14 +- .../EstimateTemplatesApiController.cs | 4 +- .../Business/FrontOfficeApiController.cs | 12 +- .../Business/HairCutQueryApiController.cs | 8 +- .../HairMultiCutQueryApiController.cs | 8 +- .../Business/InvoicesController.cs | 2 +- .../Business/RdvQueryApiController.cs | 6 +- .../Controllers/PostRateApiController.cs | 2 +- .../Relationship/BlackListApiController.cs | 2 +- .../ChatRoomAccessApiController.cs | 8 +- .../Relationship/ChatRoomApiController.cs | 2 +- src/Yavsc.Api/Program.cs | 2 +- .../Services/AdministrationService.cs | 6 +- ...ministrationServiceCollectionExtensions.cs | 4 +- .../Controllers/FileSystemApiController.cs | 2 +- .../AdministrationController.cs | 13 +- .../Communicating/AnnouncesController.cs | 4 +- src/Yavsc.Org/Controllers/HomeController.cs | 7 +- .../Musical/InstrumentationController.cs | 10 +- src/Yavsc.Org/Extensions/HostingExtensions.cs | 4 +- .../Views/Shared/_LoginPartial.cshtml | 2 +- src/Yavsc.Server/Hubs/ChatHub.cs | 6 +- .../Fixtures/ApiWebServerFixture.cs | 2 +- .../Yavsc.Api.Tests/HomeControllerTests.cs | 102 ++++++++ src/test/Yavsc.Api.Tests/InvoiceApiTests.cs | 2 +- .../Yavsc.Api.Tests/PodcastControllerTests.cs | 2 +- .../ResourceUsageApiControllerTests.cs | 4 +- 36 files changed, 421 insertions(+), 74 deletions(-) create mode 100644 doc/architecture/notifications.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 47498be79..9cd4b3c62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ + # Changelog +## [1.0.8-rc32] - unstable + +### Added + +### Changed + +### Fixed + +* Performer notification audience + ## [1.0.8-rc31] - unstable ### Added @@ -16,7 +27,6 @@ * avoid NRE in MailSender on rdv query notifications * Android emulator smoke test skipped in CI because Calabash instrumentation signature mismatches the target APK on hosted runners - ## [1.0.8-rc30] - unstable ### Added diff --git a/doc/README.md b/doc/README.md index fb9bea943..10e32b0c9 100644 --- a/doc/README.md +++ b/doc/README.md @@ -16,6 +16,7 @@ La racine de l'architecture est [Architecture.md](Architecture.md). | [architecture/offres-frontmatter.md](architecture/offres-frontmatter.md) | Offre fournisseur, ClasseFormulaire, ClasseDevis, parsing frontmatter | | [architecture/postit-oidc.md](architecture/postit-oidc.md) | Client desktop PostIt, custom URI scheme, silent refresh | | [architecture/postit.md](architecture/postit.md) | PostIt — topologie des projets, ViewLocator custo, navigation, DI, conventions de binding | +| [architecture/notifications.md](architecture/notifications.md) | Notifications persistées, messages Web et événements temps réel côté serveur, Web et PostIt | | [architecture/decoupage-organisation.md](architecture/decoupage-organisation.md) | Découpage des projets .NET (Abstract, Server, Org, Api, Blogs, Web, Org.Tests) | | [testing.md](testing.md) | Stratégie de test : conventions des dossiers, EF Core in-memory, auth stubs, scaffold partagé | | [onboarding-agents.md](onboarding-agents.md) | Parcours pas-à-pas pour prise en main agents IA + architecture + tests | diff --git a/doc/architecture/notifications.md b/doc/architecture/notifications.md new file mode 100644 index 000000000..8cf85cfb8 --- /dev/null +++ b/doc/architecture/notifications.md @@ -0,0 +1,229 @@ +# Notifications Yavsc : serveur, Web et PostIt + +Ce document décrit les mécanismes de notification actuellement présents +dans le dépôt. Le terme « notification » recouvre plusieurs flux distincts : + +1. les **annonces persistées** en base, affichées dans le site Web et sur + l'accueil de PostIt ; +2. les **messages temporaires de l'interface Web**, attachés à une réponse + MVC par `ViewBag.Notify` ; +3. les **événements temps réel** du chat et les événements métier diffusés par + SignalR. + +Ces flux n'ont ni le même stockage, ni le même cycle de vie, ni les mêmes +clients. + +## Vue d'ensemble + +| Flux | Serveur / transport | Web | PostIt | +|---|---|---|---| +| Annonce persistée | Entité `Notification` en base, API HTTP | Affichée dans le layout et masquable | Liste sur `HomePage`, chargée par HTTP et masquable | +| Message d'interface | Liste de portée requête dans `ViewBag.Notify` | Affichée comme alerte dans le layout | Non consommé | +| Événement de chat | Hub SignalR authentifié à `/chathub` | Le site Web n'en consomme pas dans ses vues MVC | `notifyUser` et `notifyRoom` alimentent les journaux du chat | +| Événement métier `push` | SignalR vers les connexions actives, plus e-mail | Pas de consommateur identifié dans les vues Web | Pas de gestionnaire `push` dans le client PostIt actuel | + +Les notifications persistées sont donc différentes d'une notification système +native (notification du navigateur ou du système d'exploitation). Le client +PostIt affiche une liste dans sa page d'accueil ; il ne demande pas de +permission de notification native. + +## 1. Annonces persistées + +### Modèle et stockage + +L'entité partagée [`Notification`](../../src/Yavsc.Abstract/Messaging/Notification.cs) +est enregistrée dans la table `Notification` via `ApplicationDbContext`. +Elle contient : + +- `Id`, identifiant généré ; +- `title` et `body`, obligatoires, limités respectivement à 1 024 et 512 + caractères ; +- `icon`, `sound`, `tag`, `color`, propriétés optionnelles ; +- `click_action`, obligatoire, utilisé comme libellé d'action dans les + interfaces ; +- `Target`, chaîne de ciblage. Les valeurs documentées par le modèle sont + `null` (tout le monde), `user/{UserId}`, `pub/{topic}` et + `administration`. + +Le masquage d'une annonce pour un utilisateur connecté est stocké dans +`DismissClicked`, avec une clé composée de l'identifiant utilisateur et de +l'identifiant de notification. Le même avis peut ainsi être masqué +indépendamment par chaque utilisateur. + +### API + +[`NotificationsApiController`](../../src/Yavsc.Api/Controllers/Administration/NotificationsApiController.cs) +expose les deux routes GET suivantes, sous le préfixe `/api/v1` : + +| Route | Accès | Résultat | +|---|---|---| +| `GET /api/v1/notifications` | Anonyme ou authentifié | Notifications visibles à l'appelant, non masquées | +| `GET /api/v1/notifications/me` | Anonyme ou authentifié | Même action et mêmes règles que la route sans suffixe | + +La réponse est une liste JSON triée par `Id` décroissant. L'API exclut les +notifications masquées par l'utilisateur authentifié, puis applique les +règles d'audience : cible vide, cible `pub/…`, cible correspondant à +`user/{identifiant courant}`, ou cible `administration` pour un administrateur. +Une requête anonyme n'a ni identifiant utilisateur ni rôle administrateur. + +[`DismissClicksApiController`](../../src/Yavsc.Api/Controllers/Administration/DismissClicksApiController.cs) +est réservé aux utilisateurs authentifiés : + +| Route | Effet | +|---|---| +| `GET /api/v1/dismiss` | Liste les identifiants masqués par l'utilisateur | +| `POST /api/v1/dismiss/click/{notificationId}` | Masque l'avis pour l'utilisateur ; l'opération est idempotente | +| `DELETE /api/v1/dismiss/click/{notificationId}` | Rétablit l'avis s'il était masqué ; renvoie `404` sinon | + +Le POST renvoie `404` si l'identifiant de notification n'existe pas. Le modèle +de réponse est [`DismissedNotification`](../../src/Yavsc.Abstract/Messaging/DismissedNotification.cs). + +### Création et notifications métier + +Le contrôleur MVC +[`NotificationsController`](../../src/Yavsc.Org/Controllers/Communicating/NotificationsController.cs) +permet la gestion CRUD des enregistrements de notification. + +Le service [`YavscMessageSender`](../../src/Yavsc.Server/Services/YavscMessageSender.cs) +crée aussi une notification persistée, ciblée sur `user/{id}`, pour les +événements `RdvQueryEvent` et `ClientEstimateSignedEvent`. L'enregistrement +est effectué avant l'envoi d'e-mail. Le même service tente ensuite d'envoyer +un e-mail si l'adresse est disponible et confirmée, puis diffuse l'événement +`push` aux connexions SignalR actives de l'utilisateur. + +Cette diffusion `push` est conditionnée par la présence de connexions actives ; +l'avis stocké en base reste, lui, disponible lors d'une récupération ultérieure +par l'API. + +## 2. Site Web + +### Annonces persistées sur l'accueil + +[`HomeController.Index`](../../src/Yavsc.Org/Controllers/HomeController.cs) +charge les notifications non masquées et les place dans `ViewBag.Notify`. +[`_Layout.cshtml`](../../src/Yavsc.Org/Views/Shared/_Layout.cshtml) les rend +comme des alertes Bootstrap avec icône, titre, corps et action. Le titre et le +corps passent par les composants Markdown du site. + +Le clic sur l'action appelle `POST /Home/DismissNotification/{id}` via +[`site.js`](../../src/Yavsc.Org/wwwroot/js/site.js) : + +- pour un utilisateur connecté, le masquage est enregistré dans + `DismissClicked` ; +- pour un visiteur anonyme, les identifiants sont conservés dans la session + Web (`clicked`). + +La route renvoie `404` si l'avis n'existe pas et `204` après le masquage. +Comme l'API, l'accueil Web filtre `Target` : les cibles `user/{id}` ne sont +visibles qu'à l'utilisateur correspondant, et la cible `administration` +uniquement aux administrateurs. Un visiteur anonyme ne reçoit que les +notifications publiques (`Target` vide ou préfixe `pub/`). + +### Messages temporaires par requête + +Les helpers [`NotifyInfo`, `NotifyWarning` et `Notify`](../../src/Yavsc.Org/Helpers/ControllerHelpers.cs) +ajoutent des objets à `ViewBag.Notify`. Ce mécanisme n'écrit pas en base : +il ne vit que dans la réponse MVC qui rend le layout. Les méthodes +`NotifyInfo` et `NotifyWarning` partagent actuellement le même rendu générique +`alert-info`. + +## 3. PostIt + +[`NotificationsApiClient`](../../src/Yavsc.Api.Client/NotificationsApiClient.cs) +construit les routes à partir de la base d'API de l'hôte métier configuré. +Il fournit les méthodes de lecture publique et authentifiée, ainsi que les +opérations de lecture, masquage et restauration. + +Sur [`HomePage`](../../src/PostIt/PostIt/Views/Layout/HomePage.axaml), +[`HomePageViewModel`](../../src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs) +charge la liste : + +- sans session valide, il appelle la route publique + `GET /api/v1/notifications` ; +- avec une session valide, il appelle + `GET /api/v1/notifications/me` avec le client HTTP authentifié. + +Le chargement est lancé à l'ouverture de la page et relancé lorsque l'état de +connexion change. Un compteur de version ignore les réponses obsolètes si un +chargement plus récent a déjà commencé. L'interface distingue l'état de +chargement, la liste vide et l'erreur de chargement ; l'utilisateur peut aussi +actualiser manuellement. + +Le bouton d'une notification utilise `click_action` comme libellé et masque +l'élément. En session authentifiée, PostIt appelle +`POST /api/v1/dismiss/click/{id}` avant de retirer l'élément de la liste. Si +l'appel échoue, l'élément reste affiché et une erreur est présentée. Sans +session, l'élément est retiré de la liste locale, sans persistance du +masquage côté serveur. + +## 4. Événements temps réel + +### Hub du chat + +Le hub [`ChatHub`](../../src/Yavsc.Server/Hubs/ChatHub.cs) est publié par +Yavsc.Api à la racine sous `/chathub` (et non sous `/api/v1`) et porte +`[Authorize]`. Il s'appuie sur SignalR et sur `IConnexionManager` pour suivre +les utilisateurs et les salons. + +Les événements de notification émis par le hub comprennent notamment : + +| Événement SignalR | Données | Utilisation PostIt | +|---|---|---| +| `notifyUser` | `type`, identifiant ou nom cible, message | Ajout au journal système | +| `notifyRoom` | `type`, salon, utilisateur ou détail | Ajout au journal du salon ; met à jour la liste d'utilisateurs sur `UserJoin` | +| `joint` | `ChatRoomInfo` | Initialise la vue du salon rejoint | +| `ReceiveMessage` | message avec nom, salon et corps | Ajoute le message à l'historique du salon | +| `notifyUserInRoom` | `type`, salon, message | Pas de gestionnaire correspondant dans `ChatViewModel` actuellement | + +Les constantes de types du chat sont déclarées dans +[`NotificationTypes`](../../src/Yavsc.Abstract/Messaging/NotificationTypes.cs). +Les messages `notifyUser` et `notifyRoom` ne sont pas enregistrés en base : +ils décrivent l'état temps réel de connexion, de salon ou les erreurs de +conversation. + +Le [`ChatViewModel`](../../src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs) +ouvre le hub avec le jeton d'accès PostIt, tente un renouvellement silencieux +du jeton, et active la reconnexion automatique. Après reconnexion, il rejoint +à nouveau le salon sélectionné, car la connexion SignalR précédente et ses +groupes ne sont plus actifs. Les callbacks mettent à jour les collections sur +le thread UI d'Avalonia. + +### Événement métier `push` + +En plus des événements du chat, `YavscMessageSender` utilise le même contexte +SignalR pour émettre `push` avec le topic et une charge utile sérialisée. Ce +nom d'événement est distinct de `notifyUser` et `notifyRoom`. Aucun +gestionnaire `push` n'est présent dans le client PostIt actuel : les annonces +métier persistées sont visibles dans PostIt lors du prochain chargement ou +rafraîchissement HTTP. + +## 5. Points d'attention pour les évolutions + +- Conserver les règles d'audience alignées entre l'API et le site Web ; en + particulier, appliquer un filtrage `Target` côté Web avant d'exposer les + avis dans le layout. +- Ne pas confondre masquage utilisateur (`DismissClicked`) et suppression + globale de la notification : le masquage API est par utilisateur. +- Les sessions anonymes du site Web et PostIt n'ont pas le même comportement : + le site Web conserve le masquage en session, tandis que PostIt ne le garde + que dans la liste affichée jusqu'au prochain rechargement. +- Ajouter un nouveau type de cible ou un nouvel événement temps réel implique + d'examiner séparément son filtrage, son transport et les clients qui + l'écoutent. +- Les références à GCM et le nom historique de `YavscMessageSender` ne + signifient pas que PostIt reçoit une notification native hors ligne : le + chemin observé ici est l'e-mail, l'événement SignalR temps réel et la + notification persistée récupérée par HTTP. + +## Références d'implémentation + +- [`Notification`](../../src/Yavsc.Abstract/Messaging/Notification.cs) +- [`NotificationsApiController`](../../src/Yavsc.Api/Controllers/Administration/NotificationsApiController.cs) +- [`DismissClicksApiController`](../../src/Yavsc.Api/Controllers/Administration/DismissClicksApiController.cs) +- [`NotificationsController`](../../src/Yavsc.Org/Controllers/Communicating/NotificationsController.cs) +- [`HomeController`](../../src/Yavsc.Org/Controllers/HomeController.cs) +- [`YavscMessageSender`](../../src/Yavsc.Server/Services/YavscMessageSender.cs) +- [`ChatHub`](../../src/Yavsc.Server/Hubs/ChatHub.cs) +- [`NotificationsApiClient`](../../src/Yavsc.Api.Client/NotificationsApiClient.cs) +- [`HomePageViewModel`](../../src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs) +- [`ChatViewModel`](../../src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs) diff --git a/src/PostIt/PostIt/ViewModels/Layout/SessionStatusViewModel.cs b/src/PostIt/PostIt/ViewModels/Layout/SessionStatusViewModel.cs index d71ed9988..f6bf2282a 100644 --- a/src/PostIt/PostIt/ViewModels/Layout/SessionStatusViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Layout/SessionStatusViewModel.cs @@ -103,7 +103,7 @@ public partial class SessionStatusViewModel : ViewModelBase if (profile is null) throw new InvalidOperationException("Le serveur a renvoyé un profil vide."); if (version == _accessVersion) - IsAdministrator = profile.Roles.Contains(Yavsc.Constants.AdminGroupName, StringComparer.Ordinal); + IsAdministrator = profile.Roles.Contains(Yavsc.Constants.AdminName, StringComparer.Ordinal); } catch (Exception ex) { diff --git a/src/Yavsc.Abstract/Constants.cs b/src/Yavsc.Abstract/Constants.cs index 0d78351ca..3a01af3ca 100644 --- a/src/Yavsc.Abstract/Constants.cs +++ b/src/Yavsc.Abstract/Constants.cs @@ -14,6 +14,7 @@ namespace Yavsc public const string CirclePath = "circle"; public const string CommentsPath = "blogcomments"; public const string ChatHubPath = "chathub"; + public const string Administration = "administration"; public static readonly Scope[] SiteScopes = { new Scope { Id = "profile", Description = "Your profile informations" }, @@ -52,7 +53,7 @@ namespace Yavsc public const string DefaultFactor = "Default"; public const string MobileAppFactor = "Mobile Application"; public const string SMSFactor = "SMS"; - public const string AdminGroupName = "Administrator"; + public const string AdminName = "Administrator"; public const string PerformerGroupName = "Performer"; public const string StarGroupName = "Star"; public const string StarHunterGroupName = "StarHunter"; diff --git a/src/Yavsc.Api/Controllers/Administration/NotificationsApiController.cs b/src/Yavsc.Api/Controllers/Administration/NotificationsApiController.cs index 7053a5acc..c1c7cb698 100644 --- a/src/Yavsc.Api/Controllers/Administration/NotificationsApiController.cs +++ b/src/Yavsc.Api/Controllers/Administration/NotificationsApiController.cs @@ -24,7 +24,7 @@ public sealed class NotificationsApiController : Controller public async Task>> GetCurrentUserNotifications() { var currentUserId = User.GetUserId(); - var isAdmin = User.IsInRole(Constants.AdminGroupName); + var isAdmin = User.IsInRole(Constants.AdminName); var dismissedIds = string.IsNullOrWhiteSpace(currentUserId) ? new HashSet() diff --git a/src/Yavsc.Api/Controllers/Administration/ResourceUsageApiController.cs b/src/Yavsc.Api/Controllers/Administration/ResourceUsageApiController.cs index 0f6af3083..295c72a84 100644 --- a/src/Yavsc.Api/Controllers/Administration/ResourceUsageApiController.cs +++ b/src/Yavsc.Api/Controllers/Administration/ResourceUsageApiController.cs @@ -78,7 +78,7 @@ public sealed class ResourceUsageApiController : Controller [HttpGet(Constants.ResourceUsageAdminRoute)] public async Task>> GetAdminOverview() { - if (!User.IsInRole(Constants.AdminGroupName)) + if (!User.IsInRole(Constants.AdminName)) { return Forbid(); } diff --git a/src/Yavsc.Api/Controllers/Administration/SiteAdministrationApiController.cs b/src/Yavsc.Api/Controllers/Administration/SiteAdministrationApiController.cs index cd4352fbe..f42d58a73 100644 --- a/src/Yavsc.Api/Controllers/Administration/SiteAdministrationApiController.cs +++ b/src/Yavsc.Api/Controllers/Administration/SiteAdministrationApiController.cs @@ -7,7 +7,7 @@ using Yavsc.Api.Services; namespace Yavsc.ApiControllers.Administration; [ApiController] -[ApiExplorerSettings(GroupName = "administration")] +[ApiExplorerSettings(GroupName = Constants.Administration)] [Authorize("AdministratorOnly")] [Route(Constants.APIPrefix + "/administration")] [Produces("application/json")] diff --git a/src/Yavsc.Api/Controllers/Business/BillingController.cs b/src/Yavsc.Api/Controllers/Business/BillingController.cs index 6d4f77d89..ba1430a44 100644 --- a/src/Yavsc.Api/Controllers/Business/BillingController.cs +++ b/src/Yavsc.Api/Controllers/Business/BillingController.cs @@ -107,7 +107,7 @@ namespace Yavsc.ApiControllers logger.LogWarning("Got bill ack:"+bill.GetIsAcquitted().ToString()); if (bill is not NominativeServiceCommand { InvoiceEstimateId: not null } invoice) return Conflict(new { Error = "Create the invoice from a signed estimate first." }); - if (User.GetUserId() != invoice.InvoicePerformerId && !User.IsInRole(Constants.AdminGroupName)) + if (User.GetUserId() != invoice.InvoicePerformerId && !User.IsInRole(Constants.AdminName)) return Forbid(); return ViewComponent("Bill",new object[] { billingCode, bill, OutputFormat.Pdf, true } ); } diff --git a/src/Yavsc.Api/Controllers/Business/EstimateApiController.cs b/src/Yavsc.Api/Controllers/Business/EstimateApiController.cs index ba351ca83..af9c85a8f 100644 --- a/src/Yavsc.Api/Controllers/Business/EstimateApiController.cs +++ b/src/Yavsc.Api/Controllers/Business/EstimateApiController.cs @@ -24,12 +24,12 @@ namespace Yavsc.Controllers } bool UserIsAdminOrThis(string uid) { - if (User.IsInRole(Constants.AdminGroupName)) return true; + if (User.IsInRole(Constants.AdminName)) return true; return uid == User.GetUserId(); } bool UserIsAdminOrInThese(string oid, string uid) { - if (User.IsInRole(Constants.AdminGroupName)) return true; + if (User.IsInRole(Constants.AdminName)) return true; var cuid = User.GetUserId(); return cuid == uid || cuid == oid; } @@ -79,7 +79,7 @@ namespace Yavsc.Controllers return BadRequest(); } var uid = User.GetUserId(); - if (!User.IsInRole(Constants.AdminGroupName)) + if (!User.IsInRole(Constants.AdminName)) { if (uid != estimate.OwnerId) { @@ -115,7 +115,7 @@ namespace Yavsc.Controllers var uid = User.GetUserId(); if (estimate.OwnerId == null) estimate.OwnerId = uid; - if (!User.IsInRole(Constants.AdminGroupName)) + if (!User.IsInRole(Constants.AdminName)) { if (uid != estimate.OwnerId) { @@ -185,7 +185,7 @@ namespace Yavsc.Controllers return NotFound(); } var uid = User.GetUserId(); - if (!User.IsInRole(Constants.AdminGroupName)) + if (!User.IsInRole(Constants.AdminName)) { if (uid != estimate.OwnerId) { @@ -285,7 +285,7 @@ namespace Yavsc.Controllers if (estimate == null) return NotFound(); var uid = User.GetUserId(); - if (!User.IsInRole(Constants.AdminGroupName) && uid != estimate.OwnerId) + if (!User.IsInRole(Constants.AdminName) && uid != estimate.OwnerId) return new StatusCodeResult(StatusCodes.Status403Forbidden); if (body == null || body.FileId <= 0) @@ -319,7 +319,7 @@ namespace Yavsc.Controllers if (estimate == null) return NotFound(); var uid = User.GetUserId(); - if (!User.IsInRole(Constants.AdminGroupName) && uid != estimate.OwnerId) + if (!User.IsInRole(Constants.AdminName) && uid != estimate.OwnerId) return new StatusCodeResult(StatusCodes.Status403Forbidden); var link = _context.EstimateAttachedFiles diff --git a/src/Yavsc.Api/Controllers/Business/EstimateTemplatesApiController.cs b/src/Yavsc.Api/Controllers/Business/EstimateTemplatesApiController.cs index 748f60cc6..5fc814f4d 100644 --- a/src/Yavsc.Api/Controllers/Business/EstimateTemplatesApiController.cs +++ b/src/Yavsc.Api/Controllers/Business/EstimateTemplatesApiController.cs @@ -63,7 +63,7 @@ namespace Yavsc.Controllers } var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); if (estimateTemplate.OwnerId!=uid) - if (!User.IsInRole(Constants.AdminGroupName)) + if (!User.IsInRole(Constants.AdminName)) return new StatusCodeResult(StatusCodes.Status403Forbidden); _context.Entry(estimateTemplate).State = EntityState.Modified; @@ -133,7 +133,7 @@ namespace Yavsc.Controllers } var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); if (estimateTemplate.OwnerId!=uid) - if (!User.IsInRole(Constants.AdminGroupName)) + if (!User.IsInRole(Constants.AdminName)) return new StatusCodeResult(StatusCodes.Status403Forbidden); _context.EstimateTemplates.Remove(estimateTemplate); diff --git a/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs b/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs index 7ce2e14df..51ea6a2c4 100644 --- a/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs +++ b/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs @@ -86,7 +86,7 @@ namespace Yavsc.ApiControllers if (estimate is null) return NotFound(new { Error = "no estimate linked to this query" }); var uid = User.GetUserId(); - if (!User.IsInRole(Constants.AdminGroupName) + if (!User.IsInRole(Constants.AdminName) && uid != estimate.ClientId && uid != estimate.OwnerId) return Forbid(); @@ -117,7 +117,7 @@ namespace Yavsc.ApiControllers if (estimate is null) return NotFound(new { Error = "no estimate linked to this query" }); var uid = User.GetUserId(); - if (!User.IsInRole(Constants.AdminGroupName) + if (!User.IsInRole(Constants.AdminName) && uid != estimate.ClientId && uid != estimate.OwnerId) return Forbid(); @@ -403,7 +403,7 @@ namespace Yavsc.ApiControllers if (isProvider) return (query, ActorRole.Provider, null); if (isClient) return (query, ActorRole.Client, null); - if (User.IsInRole(Constants.AdminGroupName)) + if (User.IsInRole(Constants.AdminName)) return (query, ActorRole.Admin, null); return (null, ActorRole.None, Forbid()); @@ -453,7 +453,7 @@ namespace Yavsc.ApiControllers if (error is not null) return error; if (query is null) return BadRequest(new { Error = "query not found" }); - if (role != ActorRole.Client && !User.IsInRole(Constants.AdminGroupName)) + if (role != ActorRole.Client && !User.IsInRole(Constants.AdminName)) return new StatusCodeResult(StatusCodes.Status403Forbidden); if (body == null || body.FileId <= 0) @@ -463,7 +463,7 @@ namespace Yavsc.ApiControllers var file = await dbContext.UploadedFiles .FirstOrDefaultAsync(f => f.Id == body.FileId, token); if (file == null) return BadRequest(new { error = "file not found" }); - if (file.OwnerId != uid && !User.IsInRole(Constants.AdminGroupName)) + if (file.OwnerId != uid && !User.IsInRole(Constants.AdminName)) return new StatusCodeResult(StatusCodes.Status403Forbidden); var existing = await dbContext.QueryAttachedFiles @@ -490,7 +490,7 @@ namespace Yavsc.ApiControllers if (error is not null) return error; if (query is null) return BadRequest(new { Error = "query not found" }); - if (role != ActorRole.Client && !User.IsInRole(Constants.AdminGroupName)) + if (role != ActorRole.Client && !User.IsInRole(Constants.AdminName)) return new StatusCodeResult(StatusCodes.Status403Forbidden); var link = await dbContext.QueryAttachedFiles diff --git a/src/Yavsc.Api/Controllers/Business/HairCutQueryApiController.cs b/src/Yavsc.Api/Controllers/Business/HairCutQueryApiController.cs index bed63d895..adf03860b 100644 --- a/src/Yavsc.Api/Controllers/Business/HairCutQueryApiController.cs +++ b/src/Yavsc.Api/Controllers/Business/HairCutQueryApiController.cs @@ -70,7 +70,7 @@ public class HairCutQueryApiController : Controller return NotFound(); } - if (query.ClientId != uid && query.PerformerId != uid && !User.IsInRole(Constants.AdminGroupName)) + if (query.ClientId != uid && query.PerformerId != uid && !User.IsInRole(Constants.AdminName)) { return Forbid(); } @@ -141,7 +141,7 @@ public class HairCutQueryApiController : Controller } var uid = User.GetUserId(); - if (existing.ClientId != uid && !User.IsInRole(Constants.AdminGroupName)) + if (existing.ClientId != uid && !User.IsInRole(Constants.AdminName)) { return Forbid(); } @@ -185,7 +185,7 @@ public class HairCutQueryApiController : Controller return NotFound(); } - if (query.ClientId != uid && !User.IsInRole(Constants.AdminGroupName)) + if (query.ClientId != uid && !User.IsInRole(Constants.AdminName)) { return Forbid(); } @@ -234,4 +234,4 @@ public class HairCutQueryApiController : Controller Details = prestation.GetDisplayDetails(), }; } -} \ No newline at end of file +} diff --git a/src/Yavsc.Api/Controllers/Business/HairMultiCutQueryApiController.cs b/src/Yavsc.Api/Controllers/Business/HairMultiCutQueryApiController.cs index c6d4205bd..a4c224583 100644 --- a/src/Yavsc.Api/Controllers/Business/HairMultiCutQueryApiController.cs +++ b/src/Yavsc.Api/Controllers/Business/HairMultiCutQueryApiController.cs @@ -77,7 +77,7 @@ public class HairMultiCutQueryApiController : Controller return NotFound(); } - if (query.ClientId != uid && query.PerformerId != uid && !User.IsInRole(Constants.AdminGroupName)) + if (query.ClientId != uid && query.PerformerId != uid && !User.IsInRole(Constants.AdminName)) { return Forbid(); } @@ -152,7 +152,7 @@ public class HairMultiCutQueryApiController : Controller } var uid = User.GetUserId(); - if (existing.ClientId != uid && !User.IsInRole(Constants.AdminGroupName)) + if (existing.ClientId != uid && !User.IsInRole(Constants.AdminName)) { return Forbid(); } @@ -200,7 +200,7 @@ public class HairMultiCutQueryApiController : Controller return NotFound(); } - if (query.ClientId != uid && !User.IsInRole(Constants.AdminGroupName)) + if (query.ClientId != uid && !User.IsInRole(Constants.AdminName)) { return Forbid(); } @@ -285,4 +285,4 @@ public class HairMultiCutQueryApiController : Controller return query; } -} \ No newline at end of file +} diff --git a/src/Yavsc.Api/Controllers/Business/InvoicesController.cs b/src/Yavsc.Api/Controllers/Business/InvoicesController.cs index 737cef2a9..561909556 100644 --- a/src/Yavsc.Api/Controllers/Business/InvoicesController.cs +++ b/src/Yavsc.Api/Controllers/Business/InvoicesController.cs @@ -39,7 +39,7 @@ public sealed class InvoicesController(IInvoicingService invoices, ILogger> VerifyPayPal(long commandId, VerifyInvoicePayPalRequest request, CancellationToken ct) => ExecuteAsync(() => invoices.VerifyPayPalAsync(commandId, request.TransactionId, User.GetUserId(), IsAdmin, ct)); - private bool IsAdmin => User.IsInRole(Constants.AdminGroupName); + private bool IsAdmin => User.IsInRole(Constants.AdminName); private async Task> ExecuteAsync(Func> action, bool created = false) { diff --git a/src/Yavsc.Api/Controllers/Business/RdvQueryApiController.cs b/src/Yavsc.Api/Controllers/Business/RdvQueryApiController.cs index 0b5b08222..c041a6257 100644 --- a/src/Yavsc.Api/Controllers/Business/RdvQueryApiController.cs +++ b/src/Yavsc.Api/Controllers/Business/RdvQueryApiController.cs @@ -62,7 +62,7 @@ public class RdvQueryApiController : Controller return NotFound(); } - if (query.ClientId != uid && query.PerformerId != uid && !User.IsInRole(Constants.AdminGroupName)) + if (query.ClientId != uid && query.PerformerId != uid && !User.IsInRole(Constants.AdminName)) { return Forbid(); } @@ -157,7 +157,7 @@ public class RdvQueryApiController : Controller return NotFound(); } - if (existing.ClientId != uid && !User.IsInRole(Constants.AdminGroupName)) + if (existing.ClientId != uid && !User.IsInRole(Constants.AdminName)) { return Forbid(); } @@ -222,7 +222,7 @@ public class RdvQueryApiController : Controller return NotFound(); } - if (query.ClientId != uid && !User.IsInRole(Constants.AdminGroupName)) + if (query.ClientId != uid && !User.IsInRole(Constants.AdminName)) { return Forbid(); } diff --git a/src/Yavsc.Api/Controllers/PostRateApiController.cs b/src/Yavsc.Api/Controllers/PostRateApiController.cs index 83e2bee18..59a935119 100644 --- a/src/Yavsc.Api/Controllers/PostRateApiController.cs +++ b/src/Yavsc.Api/Controllers/PostRateApiController.cs @@ -35,7 +35,7 @@ namespace Yavsc.Controllers var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); if (blogpost.AuthorId!=uid) - if (!User.IsInRole(Constants.AdminGroupName)) + if (!User.IsInRole(Constants.AdminName)) return BadRequest(); _context.SaveChanges(User.GetUserId()); diff --git a/src/Yavsc.Api/Controllers/Relationship/BlackListApiController.cs b/src/Yavsc.Api/Controllers/Relationship/BlackListApiController.cs index 3a0ca6300..2e9bc871f 100644 --- a/src/Yavsc.Api/Controllers/Relationship/BlackListApiController.cs +++ b/src/Yavsc.Api/Controllers/Relationship/BlackListApiController.cs @@ -49,7 +49,7 @@ namespace Yavsc.Controllers { var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); if (uid != blackListed.OwnerId) - if (!User.IsInRole(Constants.AdminGroupName)) + if (!User.IsInRole(Constants.AdminName)) if (!User.IsInRole(Constants.FrontOfficeGroupName)) return false; return true; diff --git a/src/Yavsc.Api/Controllers/Relationship/ChatRoomAccessApiController.cs b/src/Yavsc.Api/Controllers/Relationship/ChatRoomAccessApiController.cs index fba8bd432..a4ec6dfdd 100644 --- a/src/Yavsc.Api/Controllers/Relationship/ChatRoomAccessApiController.cs +++ b/src/Yavsc.Api/Controllers/Relationship/ChatRoomAccessApiController.cs @@ -46,7 +46,7 @@ namespace Yavsc.Controllers var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); if (uid != chatRoomAccess.UserId && uid != chatRoomAccess.Room.OwnerId - && ! User.IsInMsRole(Constants.AdminGroupName)) + && ! User.IsInMsRole(Constants.AdminName)) { ModelState.AddModelError("UserId","get refused"); @@ -72,7 +72,7 @@ namespace Yavsc.Controllers } var room = _context.ChatRoom.First(channel => channel.Name == chatRoomAccess.ChannelName ); - if (uid != room.OwnerId && ! User.IsInMsRole(Constants.AdminGroupName)) + if (uid != room.OwnerId && ! User.IsInMsRole(Constants.AdminName)) { ModelState.AddModelError("ChannelName", "access put refused"); return BadRequest(ModelState); @@ -110,7 +110,7 @@ namespace Yavsc.Controllers var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); var room = _context.ChatRoom.First(channel => channel.Name == chatRoomAccess.ChannelName ); - if (room == null || (uid != room.OwnerId && ! User.IsInMsRole(Constants.AdminGroupName))) + if (room == null || (uid != room.OwnerId && ! User.IsInMsRole(Constants.AdminName))) { ModelState.AddModelError("ChannelName", "access post refused"); return BadRequest(ModelState); @@ -154,7 +154,7 @@ namespace Yavsc.Controllers var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); var room = _context.ChatRoom.First(channel => channel.Name == chatRoomAccess.ChannelName ); - if (room == null || (uid != room.OwnerId && chatRoomAccess.UserId != uid && ! User.IsInMsRole(Constants.AdminGroupName))) + if (room == null || (uid != room.OwnerId && chatRoomAccess.UserId != uid && ! User.IsInMsRole(Constants.AdminName))) { ModelState.AddModelError("UserId", "access drop refused"); return BadRequest(ModelState); diff --git a/src/Yavsc.Api/Controllers/Relationship/ChatRoomApiController.cs b/src/Yavsc.Api/Controllers/Relationship/ChatRoomApiController.cs index d0f712b34..f3a0075c5 100644 --- a/src/Yavsc.Api/Controllers/Relationship/ChatRoomApiController.cs +++ b/src/Yavsc.Api/Controllers/Relationship/ChatRoomApiController.cs @@ -136,7 +136,7 @@ namespace Yavsc.Controllers if (User.GetUserId() != chatRoom.OwnerId ) { - if (!User.IsInMsRole(Constants.AdminGroupName)) + if (!User.IsInMsRole(Constants.AdminName)) return BadRequest(new {error = "OwnerId"}); } diff --git a/src/Yavsc.Api/Program.cs b/src/Yavsc.Api/Program.cs index 03afcf58f..132cb26c7 100644 --- a/src/Yavsc.Api/Program.cs +++ b/src/Yavsc.Api/Program.cs @@ -63,7 +63,7 @@ internal class Program { policy .RequireAuthenticatedUser() - .RequireClaim(Constants.RoleClaimType, Constants.AdminGroupName); + .RequireClaim(Constants.RoleClaimType, Constants.AdminName); }); }) .AddYavscCors(builder.Configuration) diff --git a/src/Yavsc.Api/Services/AdministrationService.cs b/src/Yavsc.Api/Services/AdministrationService.cs index 65e8e4a72..f109a5648 100644 --- a/src/Yavsc.Api/Services/AdministrationService.cs +++ b/src/Yavsc.Api/Services/AdministrationService.cs @@ -32,7 +32,7 @@ public sealed partial class AdministrationService( public async Task GetSummaryAsync(CancellationToken ct) => new(await db.Users.CountAsync(ct), - (await users.GetUsersInRoleAsync(Constants.AdminGroupName)).Count, + (await users.GetUsersInRoleAsync(Constants.AdminName)).Count, typeof(AdministrationService).Assembly.GetName().ToString(), System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription); public async Task> GetRolesAsync(CancellationToken ct) @@ -46,7 +46,7 @@ public sealed partial class AdministrationService( public async Task EnsureRolesAsync(CancellationToken ct) { - foreach (var name in new[] { Constants.AdminGroupName, Constants.StarGroupName, Constants.PerformerGroupName, + foreach (var name in new[] { Constants.AdminName, Constants.StarGroupName, Constants.PerformerGroupName, Constants.FrontOfficeGroupName, Constants.StarHunterGroupName, Constants.BlogModeratorGroupName }) { ct.ThrowIfCancellationRequested(); @@ -73,7 +73,7 @@ public sealed partial class AdministrationService( CheckIdentity(await users.AddToRoleAsync(user, role.Name!)); else if (!member && alreadyMember) { - if (role.Name == Constants.AdminGroupName && + if (role.Name == Constants.AdminName && (await users.GetUsersInRoleAsync(role.Name)).Count <= 1) throw Conflict("Impossible de retirer le dernier administrateur."); CheckIdentity(await users.RemoveFromRoleAsync(user, role.Name!)); diff --git a/src/Yavsc.Api/Services/AdministrationServiceCollectionExtensions.cs b/src/Yavsc.Api/Services/AdministrationServiceCollectionExtensions.cs index 447595cec..c8762fbe8 100644 --- a/src/Yavsc.Api/Services/AdministrationServiceCollectionExtensions.cs +++ b/src/Yavsc.Api/Services/AdministrationServiceCollectionExtensions.cs @@ -9,8 +9,8 @@ public static class AdministrationServiceCollectionExtensions services.AddHttpContextAccessor(); services.AddScoped(); services.AddScoped(); - services.AddOpenApi("administration", options => - options.ShouldInclude = description => description.GroupName == "administration"); + services.AddOpenApi(Constants.Administration, options => + options.ShouldInclude = description => description.GroupName == Constants.Administration); return services; } } diff --git a/src/Yavsc.Blogs/Controllers/FileSystemApiController.cs b/src/Yavsc.Blogs/Controllers/FileSystemApiController.cs index eb4433d4f..50754ff00 100644 --- a/src/Yavsc.Blogs/Controllers/FileSystemApiController.cs +++ b/src/Yavsc.Blogs/Controllers/FileSystemApiController.cs @@ -216,7 +216,7 @@ namespace Yavsc.Blogs.Controllers // autorisés comme pour le reste du workflow. bool CanReadFile(UploadedFile file, string uid) { - if (User.IsInRole(Constants.AdminGroupName)) return true; + if (User.IsInRole(Constants.AdminName)) return true; if (file.OwnerId == uid) return true; if (dbContext.EstimateAttachedFiles.Any(a => diff --git a/src/Yavsc.Org/Controllers/Administration/AdministrationController.cs b/src/Yavsc.Org/Controllers/Administration/AdministrationController.cs index 88ea81ce8..ffffeb1cb 100644 --- a/src/Yavsc.Org/Controllers/Administration/AdministrationController.cs +++ b/src/Yavsc.Org/Controllers/Administration/AdministrationController.cs @@ -56,7 +56,7 @@ namespace Yavsc.Controllers { // ensure all roles existence foreach (string roleName in new string[] { - Constants.AdminGroupName, + Constants.AdminName, Constants.StarGroupName, Constants.PerformerGroupName, Constants.FrontOfficeGroupName, @@ -86,13 +86,12 @@ namespace Yavsc.Controllers public async Task Take() { // If some amdin already exists, make this method disapear - var admins = await _userManager.GetUsersInRoleAsync(Constants.AdminGroupName); + var admins = await _userManager.GetUsersInRoleAsync(Constants.AdminName); if (admins != null && admins.Count > 0) { // All is ok, nothing to do here. - if (User.IsInMsRole(Constants.AdminGroupName)) + if (User.IsInMsRole(Constants.AdminName)) { - return Ok(new { message = "you already got it." }); } return NotFound(); @@ -116,7 +115,7 @@ namespace Yavsc.Controllers return new BadRequestObjectResult(ModelState); } - var addToRoleResult = await _userManager.AddToRoleAsync(user, Constants.AdminGroupName); + var addToRoleResult = await _userManager.AddToRoleAsync(user, Constants.AdminName); if (!addToRoleResult.Succeeded) { AddErrors(addToRoleResult); @@ -130,11 +129,11 @@ namespace Yavsc.Controllers public async Task Index() { var adminCount = await _userManager.GetUsersInRoleAsync( - Constants.AdminGroupName); + Constants.AdminName); var userCount = await _dbContext.Users.CountAsync(); var youAreAdmin = await _userManager.IsInRoleAsync( await _userManager.FindByIdAsync(User.GetUserId()), - Constants.AdminGroupName); + Constants.AdminName); var roles = await _roleManager.Roles.Select(x => new RoleInfo { diff --git a/src/Yavsc.Org/Controllers/Communicating/AnnouncesController.cs b/src/Yavsc.Org/Controllers/Communicating/AnnouncesController.cs index d2aefcdb5..06ed3393c 100644 --- a/src/Yavsc.Org/Controllers/Communicating/AnnouncesController.cs +++ b/src/Yavsc.Org/Controllers/Communicating/AnnouncesController.cs @@ -57,7 +57,7 @@ namespace Yavsc.Controllers } private async Task SetupView(Announce announce) { - ViewBag.IsAdmin = User.IsInMsRole(Constants.AdminGroupName); + ViewBag.IsAdmin = User.IsInMsRole(Constants.AdminName); ViewBag.IsPerformer = User.IsInMsRole(Constants.PerformerGroupName); ViewBag.AllowEdit = announce==null || announce.Id<=0 || !_authorizationService.AuthorizeAsync(User,announce,new EditPermission()).IsFaulted; List dl = new List(); @@ -80,7 +80,7 @@ namespace Yavsc.Controllers if (ModelState.IsValid) { // Only allow admin to create corporate annonces - if (announce.For == Reason.Corporate && ! User.IsInMsRole(Constants.AdminGroupName)) + if (announce.For == Reason.Corporate && ! User.IsInMsRole(Constants.AdminName)) { ModelState.AddModelError("For", _localizer["YourNotAdmin"]); return View(announce); diff --git a/src/Yavsc.Org/Controllers/HomeController.cs b/src/Yavsc.Org/Controllers/HomeController.cs index ab066ee3b..f3b0dcad4 100644 --- a/src/Yavsc.Org/Controllers/HomeController.cs +++ b/src/Yavsc.Org/Controllers/HomeController.cs @@ -50,9 +50,14 @@ namespace Yavsc.Controllers if (clicked == null) clicked = new long[0]; } else clicked = _dbContext.DismissClicked.Where(d => d.UserId == uid).Select(d => d.NotificationId).ToArray(); + var isAdmin = User.IsInRole(Constants.AdminName); var notes = _dbContext.Notification.Where( n => !clicked.Contains(n.Id) - ); + ).Where(n => + string.IsNullOrWhiteSpace(n.Target) + || (n.Target != null && n.Target.StartsWith("pub/")) + || (!string.IsNullOrWhiteSpace(uid) && n.Target == $"user/{uid}") + || (isAdmin && n.Target == Constants.Administration)); if (notes.Any()) this.Notify(notes); var toShow = _dbContext.Activities diff --git a/src/Yavsc.Org/Controllers/Musical/InstrumentationController.cs b/src/Yavsc.Org/Controllers/Musical/InstrumentationController.cs index 70faa88b0..604e5175b 100644 --- a/src/Yavsc.Org/Controllers/Musical/InstrumentationController.cs +++ b/src/Yavsc.Org/Controllers/Musical/InstrumentationController.cs @@ -63,7 +63,7 @@ namespace Yavsc.Controllers var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); if (ModelState.IsValid) { - if (model.UserId != uid) if (!User.IsInMsRole(Constants.AdminGroupName)) + if (model.UserId != uid) if (!User.IsInMsRole(Constants.AdminName)) return new ChallengeResult(); _context.Instrumentation.Add(model); @@ -81,7 +81,7 @@ namespace Yavsc.Controllers { return NotFound(); } - if (id != uid) if (!User.IsInMsRole(Constants.AdminGroupName)) + if (id != uid) if (!User.IsInMsRole(Constants.AdminName)) return new ChallengeResult(); Instrumentation musicianSettings = await _context.Instrumentation.SingleAsync(m => m.UserId == id); if (musicianSettings == null) @@ -97,7 +97,7 @@ namespace Yavsc.Controllers public async Task Edit(Instrumentation musicianSettings) { var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); - if (musicianSettings.UserId != uid) if (!User.IsInMsRole(Constants.AdminGroupName)) + if (musicianSettings.UserId != uid) if (!User.IsInMsRole(Constants.AdminName)) return new ChallengeResult(); if (ModelState.IsValid) { @@ -123,7 +123,7 @@ namespace Yavsc.Controllers return NotFound(); } var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); - if (musicianSettings.UserId != uid) if (!User.IsInMsRole(Constants.AdminGroupName)) + if (musicianSettings.UserId != uid) if (!User.IsInMsRole(Constants.AdminName)) return new ChallengeResult(); return View(musicianSettings); } @@ -136,7 +136,7 @@ namespace Yavsc.Controllers Instrumentation musicianSettings = await _context.Instrumentation.SingleAsync(m => m.UserId == id); var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); - if (musicianSettings.UserId != uid) if (!User.IsInMsRole(Constants.AdminGroupName)) + if (musicianSettings.UserId != uid) if (!User.IsInMsRole(Constants.AdminName)) return new ChallengeResult(); diff --git a/src/Yavsc.Org/Extensions/HostingExtensions.cs b/src/Yavsc.Org/Extensions/HostingExtensions.cs index ff4a657dd..38ce39fb1 100644 --- a/src/Yavsc.Org/Extensions/HostingExtensions.cs +++ b/src/Yavsc.Org/Extensions/HostingExtensions.cs @@ -263,14 +263,14 @@ public static class HostingExtensions policy .RequireAuthenticatedUser() .RequireClaim(Constants.RoleClaimType, - new string[] { Constants.PerformerGroupName, Constants.AdminGroupName }) + new string[] { Constants.PerformerGroupName, Constants.AdminName }) ; }); options.AddPolicy("AdministratorOnly", policy => { _ = policy .RequireAuthenticatedUser() - .RequireClaim(Constants.RoleClaimType, Constants.AdminGroupName); + .RequireClaim(Constants.RoleClaimType, Constants.AdminName); }); options.AddPolicy("FrontOffice", policy => policy.RequireRole(Constants.FrontOfficeGroupName)); diff --git a/src/Yavsc.Org/Views/Shared/_LoginPartial.cshtml b/src/Yavsc.Org/Views/Shared/_LoginPartial.cshtml index 7bfa2a3f9..0c13e3c41 100644 --- a/src/Yavsc.Org/Views/Shared/_LoginPartial.cshtml +++ b/src/Yavsc.Org/Views/Shared/_LoginPartial.cshtml @@ -16,7 +16,7 @@
  • Features
  • - @if (User.IsInMsRole(Constants.AdminGroupName)) { + @if (User.IsInMsRole(Constants.AdminName)) {