diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5bfc336dd..658ae46ec 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,19 +2,71 @@
## [1.0.8-rc20] - unstable
+Chat SignalR fonctionnel de bout en bout (connexion, salons multiples,
+envoi/réception de messages), enregistreur audio et localisation dans
+PostIt, intégration Live.Avalonia démarrée, et reverse-proxy nginx
+apprêté pour WebSocket.
+
### Added
-* [PostIt][WIP] Chat user interface
+* [PostIt] Interface de chat : page `ChatPage` (connexion au hub,
+ saisie du nom de salon, liste des messages et des membres présents),
+ entrée depuis la page d'accueil.
+* [PostIt] Envoi de messages dans un salon joint : DTO `ChatMessage`
+ (Name/Room/Message) pour l'événement `ReceiveMessage`, saisie avec
+ envoi sur `Enter`, commande inactive tant qu'aucune salle n'est en
+ focus (le hub refuse l'envoi hors salon, `LabNoJoinNoSend`).
+* [PostIt] Modèle multi-salles côté client : `JoinedRooms`,
+ `SelectedRoom` (salle en focus), messages et utilisateurs indexés par
+ salle (`FocusedMessages`/`FocusedUsers`), ComboBox de choix de la
+ salle active ; un `Join` sur une salle déjà jointe ne fait que
+ changer le focus.
+* [PostIt] Enregistreur audio (`AudioRecorder`, interface
+ `IPCMProcessor`) et ressources de localisation
+ (`Resources.{resx,en-EN,pt-PT}`).
+* [PostIt][WIP] Début d'intégration Live.Avalonia (preview XAML à
+ chaud ; encore KO).
+* [contrib] Bloc `location /chathub` dans `template.vhost` : upgrade
+ WebSocket (`Upgrade`/`Connection`), `proxy_read_timeout 3600s` et
+ `proxy_buffering off` — sans quoi la connexion SignalR tombait au
+ premier silence ou l'upgrade échouait derrière nginx.
-* [PostIt][WIP] Live.Avalonia start of integration
+### Changed
+
+* [PostIt] `StatusNotice` déplacé de `Models` vers `ViewModels` et
+ enrichi d'une commande d'action optionnelle (`CheckCommand` +
+ `CheckCommandLabel`, interface `IWithLabel`) : le bandeau de statut
+ propose désormais l'action contextuelle (Connecter / Déconnecter /
+ Reconnecter / Vérifier la connexion).
+* [PostIt] `ChatViewModel` suit le cycle de vie complet de la
+ connexion : handlers `Reconnecting` / `Reconnected` / `Closed` de
+ `WithAutomaticReconnect`, pour refléter l'état réel au lieu d'un
+ `IsConnected` optimiste.
+* [Yavsc.Api] `AddSignalR().AddJsonProtocol(IncludeFields = true)` :
+ `ChatRoomInfo` n'expose que des champs publics, que System.Text.Json
+ ne sérialise pas par défaut — le client recevait des objets vides
+ (`Name` null, `Users` vide) et la liste des salons joints n'était
+ jamais alimentée.
+* [Yavsc.Server] `IConnexionManager.OnConnected` prend le `userName`
+ en paramètre (résolu depuis les claims dans `ChatHub`) au lieu de le
+ relire depuis un dictionnaire jamais alimenté.
+* [PostIt.Android] `network_security_config.xml` déplacé sous
+ `Resources/xml/` (chemin canonique Android).
### Fixed
-nothing
-
-### Changed
-
-nothçing
+* [Yavsc.Server] `KeyNotFoundException` dans
+ `HubConnectionManager.OnConnected` à chaque connexion SignalR :
+ `ChatUserNames` (et `ChatCxIds`) n'étaient jamais alimentés — relique
+ d'un `SetUserName` disparu lors d'une refactorisation. L'exception
+ dans `OnConnectedAsync` faisait fermer la connexion par le hub, et
+ PostIt affichait « Déconnecté (The server closed the connection…) »
+ immédiatement après la connexion.
+* [PostIt] URL du hub SignalR : retrait du préfixe `/api/v1` de
+ `ApiUrl` avant d'apposer `Constants.ChatHubPath` (le hub est mappé à
+ la racine du service, pas sous le préfixe versionné).
+* [PostIt] La `StatusBar` de la page chat référençait `Grid.Row="4"`
+ sur une grille à 3 rangées (rangée hors limites, bandeau invisible).
## [1.0.8-rc19] - unstable
diff --git a/contrib/Makefile b/contrib/Makefile
index deb55bcc6..5ccf773a1 100644
--- a/contrib/Makefile
+++ b/contrib/Makefile
@@ -14,7 +14,7 @@ reinstall: copy-binaries
@echo "💯 backend started"
reinstall-prod:
- make reinstall APP_INSTALL_ENV= APP_INSTALL_PREFIX= APP_INSTALL_ENV=production
+ make reinstall APP_INSTALL_PREFIX= APP_INSTALL_ENV=production
generated/$(APP_INSTALL_PREFIX)yavscOrg.service:
generated/$(APP_INSTALL_PREFIX)yavscBlogs.service:
diff --git a/contrib/template.vhost b/contrib/template.vhost
index ea2c2cb6d..b4c9c139e 100644
--- a/contrib/template.vhost
+++ b/contrib/template.vhost
@@ -57,5 +57,25 @@ server {
proxy_read_timeout 100s;
proxy_buffering off;
}
+
+ # SignalR (ChatHub) : WebSocket exige Upgrade/Connection et un timeout
+ # long, sinon la connexion tombe au premier silence ou l'upgrade échoue.
+ # Connection est fixé à "upgrade" (pas de map $connection_upgrade, qui
+ # exigerait une directive map {} globale hors de ce vhost) : SignalR
+ # négocie toujours un upgrade sur ce chemin, et la négociation initiale
+ # (POST /negotiate, sans en-tête Upgrade) n'est pas affectée par cet
+ # en-tête côté Kestrel.
+ location /chathub {
+ proxy_pass $BACKEND_SCHEME://$HTTP_HOST:$BACKEND_PORT;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_read_timeout 3600s;
+ proxy_buffering off;
+ }
include server-status.conf;
-}
\ No newline at end of file
+}
diff --git a/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml b/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml
index 46f0bc9bb..256555df1 100644
--- a/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml
+++ b/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml
@@ -4,6 +4,7 @@
+ android:networkSecurityConfig="@xml/network_security_config"
+ android:hardwareAccelerated="true">
diff --git a/src/PostIt/PostIt.Android/Resources/network_security_config.xml b/src/PostIt/PostIt.Android/Resources/xml/network_security_config.xml
similarity index 100%
rename from src/PostIt/PostIt.Android/Resources/network_security_config.xml
rename to src/PostIt/PostIt.Android/Resources/xml/network_security_config.xml
diff --git a/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs b/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs
index 17efd391b..31c82387d 100644
--- a/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs
+++ b/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs
@@ -1,4 +1,5 @@
using System.Net.Http;
+using PostIt.Models;
using PostIt.ViewModels;
using Yavsc;
using Yavsc.Api.Client;
diff --git a/src/PostIt/PostIt.Tests/StatusBarTests.cs b/src/PostIt/PostIt.Tests/StatusBarTests.cs
index 4053dbaf3..15889e0b0 100644
--- a/src/PostIt/PostIt.Tests/StatusBarTests.cs
+++ b/src/PostIt/PostIt.Tests/StatusBarTests.cs
@@ -2,6 +2,7 @@ using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.VisualTree;
using PostIt.Controls;
+using PostIt.Models;
using PostIt.ViewModels;
namespace PostIt.Tests;
@@ -21,7 +22,9 @@ public class StatusBarTests
[AvaloniaFact]
public void Status_message_is_rendered_by_a_SelectableTextBlock()
{
- var bar = new StatusBar { DataContext = StatusNotice.Error("Échec de la suppression : boom") };
+ var bar = new StatusBar {
+ DataContext =
+ StatusNotice.Error("Échec de la suppression : boom") };
var window = new Window { Content = bar };
window.Show();
@@ -32,4 +35,4 @@ public class StatusBarTests
Assert.NotNull(message);
Assert.Equal("Échec de la suppression : boom", message!.Text);
}
-}
\ No newline at end of file
+}
diff --git a/src/PostIt/PostIt/Controls/StatusBar.axaml b/src/PostIt/PostIt/Controls/StatusBar.axaml
index 2f46fee33..755ff7d3e 100644
--- a/src/PostIt/PostIt/Controls/StatusBar.axaml
+++ b/src/PostIt/PostIt/Controls/StatusBar.axaml
@@ -10,16 +10,19 @@
Padding="8">
-
+ FontWeight="Bold"
+ Foreground="{Binding Foreground}"
+ VerticalAlignment="Top"/>
+ Foreground="{Binding Foreground}"
+ TextWrapping="Wrap"
+ VerticalAlignment="Center"/>
+
diff --git a/src/PostIt/PostIt/Models/ChatMessage.cs b/src/PostIt/PostIt/Models/ChatMessage.cs
new file mode 100644
index 000000000..52733311a
--- /dev/null
+++ b/src/PostIt/PostIt/Models/ChatMessage.cs
@@ -0,0 +1,15 @@
+namespace PostIt.ViewModels.Chat
+{
+ ///
+ /// DTO de message de salon, calqué sur la charge envoyée par
+ /// ChatHub.Send côté serveur ({ Name, Room, Message }).
+ /// Le protocole JSON de SignalR sérialise en camelCase ; la
+ /// désérialisation est insensible à la casse par défaut.
+ ///
+ public class ChatMessage
+ {
+ public string? Name { get; set; }
+ public string? Room { get; set; }
+ public string? Message { get; set; }
+ }
+}
diff --git a/src/PostIt/PostIt/Models/ChatMessageDto.cs b/src/PostIt/PostIt/Models/ChatMessageDto.cs
new file mode 100644
index 000000000..b93ccbe29
--- /dev/null
+++ b/src/PostIt/PostIt/Models/ChatMessageDto.cs
@@ -0,0 +1,14 @@
+namespace PostIt.ViewModels.Chat
+{
+ ///
+ /// DTO minimal calqué sur l'objet anonyme émis par le hub
+ /// ({ Name, Room, Message }) via l'événement
+ /// ReceiveMessage.
+ ///
+ public class ChatMessageDto
+ {
+ public string? Name { get; set; }
+ public string? Room { get; set; }
+ public string? Message { get; set; }
+ }
+}
diff --git a/src/PostIt/PostIt/ViewModels/Chat/ChatRoomInfo.cs b/src/PostIt/PostIt/Models/ChatRoomInfo.cs
similarity index 100%
rename from src/PostIt/PostIt/ViewModels/Chat/ChatRoomInfo.cs
rename to src/PostIt/PostIt/Models/ChatRoomInfo.cs
diff --git a/src/PostIt/PostIt/Models/IWithLabel.cs b/src/PostIt/PostIt/Models/IWithLabel.cs
new file mode 100644
index 000000000..b76f912db
--- /dev/null
+++ b/src/PostIt/PostIt/Models/IWithLabel.cs
@@ -0,0 +1,6 @@
+namespace PostIt.ViewModels;
+
+internal interface IWithLabel
+{
+ string Label { get; }
+}
diff --git a/src/PostIt/PostIt/Models/StatusNotice.cs b/src/PostIt/PostIt/Models/StatusNotice.cs
deleted file mode 100644
index c50ac3503..000000000
--- a/src/PostIt/PostIt/Models/StatusNotice.cs
+++ /dev/null
@@ -1,35 +0,0 @@
-namespace PostIt.ViewModels;
-
-public enum StatusSeverity
-{
- Info,
- Warning,
- Error
-}
-
-public sealed class StatusNotice
-{
- public string Message { get; }
- public StatusSeverity Severity { get; }
- public string Glyph { get; }
- public string Background { get; }
- public string BorderBrush { get; }
- public string Foreground { get; }
-
- public StatusNotice(string message, StatusSeverity severity)
- {
- Message = string.IsNullOrWhiteSpace(message) ? "Pret." : message;
- Severity = severity;
-
- (Glyph, Background, BorderBrush, Foreground) = severity switch
- {
- StatusSeverity.Error => ("!", "#7F1D1D", "#C62828", "#e1f0f6"),
- StatusSeverity.Warning => ("~", "#7C4A03", "#E6A700", "#eaeaea"),
- _ => ("i", "#E8F0FE", "#5B8DEF", "#1E3A8A"),
- };
- }
-
- public static StatusNotice Info(string message) => new(message, StatusSeverity.Info);
- public static StatusNotice Warning(string message) => new(message, StatusSeverity.Warning);
- public static StatusNotice Error(string message) => new(message, StatusSeverity.Error);
-}
diff --git a/src/PostIt/PostIt/Models/StatusSeverity.cs b/src/PostIt/PostIt/Models/StatusSeverity.cs
new file mode 100644
index 000000000..eb6ef4502
--- /dev/null
+++ b/src/PostIt/PostIt/Models/StatusSeverity.cs
@@ -0,0 +1,8 @@
+namespace PostIt.Models;
+
+public enum StatusSeverity
+{
+ Info,
+ Warning,
+ Error
+}
diff --git a/src/PostIt/PostIt/ViewModels/ACL/CirclesPageViewModel.cs b/src/PostIt/PostIt/ViewModels/ACL/CirclesPageViewModel.cs
index cd068b4f4..6201c061b 100644
--- a/src/PostIt/PostIt/ViewModels/ACL/CirclesPageViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/ACL/CirclesPageViewModel.cs
@@ -5,7 +5,7 @@ using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
-using PostIt.Helpers;
+using PostIt.Models;
using PostIt.Services;
using Yavsc.Api.Client;
using Yavsc.Api.Client.Dtos;
diff --git a/src/PostIt/PostIt/ViewModels/ACL/PostAclDialogViewModel.cs b/src/PostIt/PostIt/ViewModels/ACL/PostAclDialogViewModel.cs
index be1b220fa..43ed60823 100644
--- a/src/PostIt/PostIt/ViewModels/ACL/PostAclDialogViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/ACL/PostAclDialogViewModel.cs
@@ -12,6 +12,7 @@ using Yavsc.Api.Client.Dtos;
using Yavsc.Abstract.BlogSpot;
using Yavsc.Abstract.Identity.Security;
using System.Net.Http;
+using PostIt.Models;
namespace PostIt.ViewModels;
diff --git a/src/PostIt/PostIt/ViewModels/Activity/ActivitiesPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/ActivitiesPageViewModel.cs
index bd13faf9b..1890309ec 100644
--- a/src/PostIt/PostIt/ViewModels/Activity/ActivitiesPageViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/Activity/ActivitiesPageViewModel.cs
@@ -8,6 +8,7 @@ using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers;
+using PostIt.Models;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
diff --git a/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs
index f1b05b749..5cd373708 100644
--- a/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs
@@ -12,6 +12,7 @@ using PostIt.Helpers;
using Yavsc;
using Yavsc.Api.Client;
using Yavsc.Abstract.Workflow;
+using PostIt.Models;
namespace PostIt.ViewModels;
diff --git a/src/PostIt/PostIt/ViewModels/Activity/BillingQueryDetailsPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/BillingQueryDetailsPageViewModel.cs
index c9f122dce..0df05b21a 100644
--- a/src/PostIt/PostIt/ViewModels/Activity/BillingQueryDetailsPageViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/Activity/BillingQueryDetailsPageViewModel.cs
@@ -388,4 +388,4 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
QueryStatus.Success => "V",
_ => "i",
};
-}
\ No newline at end of file
+}
diff --git a/src/PostIt/PostIt/ViewModels/Activity/CommandFormsPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/CommandFormsPageViewModel.cs
index 533fbc380..14e7e74b9 100644
--- a/src/PostIt/PostIt/ViewModels/Activity/CommandFormsPageViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/Activity/CommandFormsPageViewModel.cs
@@ -6,6 +6,7 @@ using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers;
+using PostIt.Models;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
diff --git a/src/PostIt/PostIt/ViewModels/Activity/EstimateEditionPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/EstimateEditionPageViewModel.cs
index 1f1e4b4be..f8f94436a 100644
--- a/src/PostIt/PostIt/ViewModels/Activity/EstimateEditionPageViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/Activity/EstimateEditionPageViewModel.cs
@@ -10,6 +10,7 @@ using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers;
+using PostIt.Models;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
diff --git a/src/PostIt/PostIt/ViewModels/Activity/EstimateListPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/EstimateListPageViewModel.cs
index c90a6d884..766dda903 100644
--- a/src/PostIt/PostIt/ViewModels/Activity/EstimateListPageViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/Activity/EstimateListPageViewModel.cs
@@ -9,6 +9,7 @@ using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers;
+using PostIt.Models;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
diff --git a/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs
index 7fd84fd88..360bf54ad 100644
--- a/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs
@@ -13,6 +13,7 @@ using PostIt.Helpers;
using Yavsc;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
+using PostIt.Models;
namespace PostIt.ViewModels;
diff --git a/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs b/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs
index a1e016e7f..6fe6eccc9 100644
--- a/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs
@@ -12,6 +12,7 @@ using Yavsc.Api.Client;
using Yavsc.Abstract.Files;
using PostIt.Helpers;
using AvaloniaEdit.Document;
+using PostIt.Models;
namespace PostIt.ViewModels;
diff --git a/src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs b/src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs
index 85392cfb1..ba452cb4a 100644
--- a/src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs
@@ -1,21 +1,42 @@
using System;
+using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.AspNetCore.SignalR.Client;
+using Microsoft.Extensions.DependencyInjection;
+using PostIt.Models;
+using PostIt.Services;
+using Yavsc;
namespace PostIt.ViewModels.Chat
{
+ ///
+ /// ViewModel pour la gestion du chat,
+ /// incluant la connexion au Hub SignalR,
+ /// la gestion des salons et des utilisateurs connectés.
+ ///
public partial class ChatViewModel : ViewModelBase
{
+ private readonly Settings _settings;
+ private readonly YavscApiClient? _api;
+
+ // Le paramètre optionnel permet l'instanciation design-time
+ // (Design.DataContext dans ChatPage.axaml) sans conteneur DI.
+ public ChatViewModel(YavscApiClient? api = null)
+ {
+ var app = App.Current as App;
+ _settings = app!.ServiceProvider!.GetRequiredService(); // Assurez-vous que _settings est initialisé avant utilisation.
+ _api = api;
+ Status = new StatusNotice("Déconnecté", StatusSeverity.Info,
+ new RelayCheckCommand("Connecter", async () => await ConnectAsync()));
+ FocusedMessages = _systemMessages;
+ }
+
private HubConnection? _connection;
- [ObservableProperty]
- public partial string ServerUrl { get; protected set; } = "http://localhost:5000/chathub";
-
-
[ObservableProperty]
public partial string CurrentRoom { get; set; } = "General";
@@ -24,14 +45,56 @@ namespace PostIt.ViewModels.Chat
[ObservableProperty]
public partial StatusNotice Status { get; set; }
- = new StatusNotice("Déconnecté", StatusSeverity.Info);
[ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(SendMessageCommand))]
public partial bool IsConnected { get; set; } = false;
- // Listes dynamiques pour l'UI
- public ObservableCollection Messages { get; } = new();
- public ObservableCollection ConnectedUsers { get; } = new();
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(SendMessageCommand))]
+ public partial string MessageToSend { get; set; } = "";
+
+ // Salles jointes (choix de la salle en focus dans la vue).
+ public ObservableCollection JoinedRooms { get; } = new();
+
+ // Salle en focus ; null = aucune salle, le journal affiche
+ // alors les notifications système.
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(SendMessageCommand))]
+ public partial string? SelectedRoom { get; set; }
+
+ // Notifications globales (connexion, erreurs de join, ...)
+ private readonly ObservableCollection _systemMessages = new();
+
+ // Messages et utilisateurs présents, indexés par salle.
+ private readonly Dictionary> _messagesByRoom = new();
+ private readonly Dictionary> _usersByRoom = new();
+ private static readonly ObservableCollection _noUsers = new();
+
+ // Vues "focus" exposées à la page : pointent vers la salle
+ // sélectionnée, ou vers le journal système / une liste vide.
+ [ObservableProperty]
+ public partial ObservableCollection FocusedMessages { get; set; }
+
+ [ObservableProperty]
+ public partial ObservableCollection FocusedUsers { get; set; } = _noUsers;
+
+ partial void OnSelectedRoomChanged(string? value)
+ {
+ FocusedMessages = value != null && _messagesByRoom.TryGetValue(value, out var msgs)
+ ? msgs : _systemMessages;
+ FocusedUsers = value != null && _usersByRoom.TryGetValue(value, out var users)
+ ? users : _noUsers;
+ }
+
+ // Trace une notification système dans la vue courante
+ // (et dans le journal global pour consultation ultérieure).
+ private void Log(string line)
+ {
+ _systemMessages.Add(line);
+ if (!ReferenceEquals(FocusedMessages, _systemMessages))
+ FocusedMessages.Add(line);
+ }
// Commandes pour les boutons
[RelayCommand]
@@ -40,20 +103,88 @@ namespace PostIt.ViewModels.Chat
[RelayCommand]
public async Task JoinRoomCommand() => await JoinRoomAsync();
- [RelayCommand]
- public async Task ChangeNickCommand() => await ChangeNickAsync();
+ // Le serveur (ChatHub.Send) refuse tout message d'un utilisateur
+ // qui n'a pas joint le salon ("NoJoinNoSend") : on garde la
+ // commande inactive tant qu'aucune salle n'est en focus.
+ private bool CanSendMessage() =>
+ IsConnected && SelectedRoom != null && !string.IsNullOrWhiteSpace(MessageToSend);
+
+ [RelayCommand(CanExecute = nameof(CanSendMessage))]
+ private async Task SendMessageAsync()
+ {
+ if (_connection == null || SelectedRoom == null) return;
+ var text = MessageToSend.Trim();
+ try
+ {
+ await _connection.InvokeAsync("Send", SelectedRoom, text);
+ MessageToSend = string.Empty;
+ }
+ catch (Exception ex)
+ {
+ Log($"Erreur d'envoi : {ex.Message}");
+ }
+ }
public override bool CanNavigateNext { get; protected set; } = false;
public override bool CanNavigatePrevious { get; protected set; } = true;
+ private async Task DisconnectAsync()
+ {
+ if (_connection != null)
+ {
+ await _connection.StopAsync();
+ await _connection.DisposeAsync();
+ _connection = null;
+ }
+ IsConnected = false;
+ SelectedRoom = null;
+ JoinedRooms.Clear();
+ _messagesByRoom.Clear();
+ _usersByRoom.Clear();
+ _systemMessages.Clear();
+ Status = new StatusNotice("Déconnecté", StatusSeverity.Info,
+ new RelayCheckCommand("Connecter", async () => await ConnectAsync()));
+ }
+
private async Task ConnectAsync()
{
try
{
- Status = new StatusNotice("Connexion en cours...", StatusSeverity.Info);
+ Status = new StatusNotice("Connexion en cours...", StatusSeverity.Info,
+ new RelayCheckCommand("Vérifier la connexion", async () => await CheckConnectionAsync()));
+
+ if (_api == null || !_api.HasValidSession)
+ {
+ Status = new StatusNotice("Session expirée : reconnectez-vous avant d'ouvrir le chat.", StatusSeverity.Error,
+ new RelayCheckCommand("Reconnecter", async () => await ConnectAsync()));
+ return;
+ }
+
+ // Le hub est mappé à la racine du service ("/chathub"),
+ // pas sous le préfixe versionné "/api/v1" : on le retire.
+ var hubBase = _settings.ApiUrl.TrimEnd('/');
+ if (hubBase.EndsWith("/api/v1", StringComparison.OrdinalIgnoreCase))
+ hubBase = hubBase[..^"/api/v1".Length];
+ var hubUrl = hubBase + "/" + Constants.ChatHubPath;
_connection = new HubConnectionBuilder()
- .WithUrl(ServerUrl)
+ .WithUrl(hubUrl,
+
+ options =>
+ {
+ // Le hub est [Authorize] : on attache le Bearer OIDC.
+ // AccessTokenProvider est rappelé à chaque reconnexion,
+ // donc on tente un refresh silencieux à chaque appel.
+ // Un refresh mort (invalid_grant) ne doit pas faire
+ // échouer StartAsync avec une exception interne : on
+ // renvoie le token courant (éventuellement expiré) et
+ // le serveur répondra un 401 propre, surfacé à l'UI.
+ options.AccessTokenProvider = async () =>
+ {
+ await _api.TrySilentLoginAsync();
+ return _api.CurrentAccessToken;
+ };
+ })
.WithAutomaticReconnect()
.Build();
@@ -61,7 +192,7 @@ namespace PostIt.ViewModels.Chat
_connection.On("notifyUser", (type, user, msg) =>
{
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
- Messages.Add($"[{type}] {user}: {msg}"));
+ Log($"[{type}] {user}: {msg}"));
});
// 2. Ecoute des notifications de salon (notifyRoom)
@@ -69,66 +200,153 @@ namespace PostIt.ViewModels.Chat
{
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
- Messages.Add($"*{user} a interagi avec le salon {room} (Action: {type})");
- if (type == "UserJoin" && !ConnectedUsers.Contains(user))
- ConnectedUsers.Add(user);
+ if (_messagesByRoom.TryGetValue(room, out var roomLog))
+ roomLog.Add($"*{user} a interagi avec le salon {room} (Action: {type})");
+ else
+ Log($"*{user} a interagi avec le salon {room} (Action: {type})");
+ if (type == "UserJoin" && _usersByRoom.TryGetValue(room, out var users)
+ && !users.Contains(user))
+ users.Add(user);
});
});
// 3. Réception de l'état complet suite au Join réussi (joint)
_connection.On("joint", (roomInfo) =>
+ {
+ Avalonia.Threading.Dispatcher.UIThread.Post(() => OnRoomJoined(roomInfo));
+ });
+
+ // 4. Réception des messages de salon (diffusés par ChatHub.Send)
+ _connection.On("ReceiveMessage", (msg) =>
{
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
- Messages.Add($"Salon rejoint avec succès : {roomInfo.Name}");
- ConnectedUsers.Clear();
- foreach (var u in roomInfo.Users) ConnectedUsers.Add(u);
+ var room = msg.Room ?? "?";
+ if (!_messagesByRoom.TryGetValue(room, out var roomLog))
+ roomLog = _messagesByRoom[room] = new();
+ roomLog.Add($"{msg.Name} : {msg.Message}");
});
});
await _connection.StartAsync();
IsConnected = true;
- Status = new StatusNotice("Connecté au Hub SignalR", StatusSeverity.Info);
+ Status = new StatusNotice("Connecté au Hub SignalR", StatusSeverity.Info, new RelayCheckCommand("Déconnecter", async () => await DisconnectAsync()));
+
+ // WithAutomaticReconnect ne lève pas d'exception en cas de
+ // perte réseau : l'état passe par Reconnecting/Closed. Sans
+ // ces handlers, IsConnected resterait optimiste et Join()
+ // échouerait avec "connection is not active".
+ _connection.Reconnecting += error =>
+ {
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ IsConnected = false;
+ Status = new StatusNotice($"Connexion perdue ({error?.Message ?? "réseau"}), reconnexion...", StatusSeverity.Error,
+ new RelayCheckCommand("Déconnecter", async () => await DisconnectAsync()));
+ });
+ return Task.CompletedTask;
+ };
+ _connection.Reconnected += _ =>
+ {
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ IsConnected = true;
+ Status = new StatusNotice("Reconnecté au Hub SignalR", StatusSeverity.Info,
+ new RelayCheckCommand("Déconnecter", async () => await DisconnectAsync()));
+ });
+ return Task.CompletedTask;
+ };
+ _connection.Closed += error =>
+ {
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ IsConnected = false;
+ Status = new StatusNotice($"Déconnecté ({error?.Message ?? "fin de connexion"})", StatusSeverity.Info,
+ new RelayCheckCommand("Connecter", async () => await ConnectAsync()));
+ });
+ return Task.CompletedTask;
+ };
}
catch (Exception ex)
{
- Status = new StatusNotice($"Erreur : {ex.Message}", StatusSeverity.Error);
+ Status = new StatusNotice($"Erreur : {ex.Message}", StatusSeverity.Error, new RelayCheckCommand("Reconnecter", async () => await ConnectAsync()));
IsConnected = false;
}
}
+ // Alimente JoinedRooms et les collections par salle à partir
+ // du ChatRoomInfo renvoyé par le hub (valeur de retour du Join
+ // et/ou événement "joint"). Idempotent.
+ private void OnRoomJoined(ChatRoomInfo roomInfo)
+ {
+ // ChatHub.Join renseigne toujours Name (salle existante ou
+ // créée) ; un Name nul signalerait un payload inattendu.
+ var roomName = roomInfo.Name ?? CurrentRoom;
+ if (roomInfo.Name == null)
+ Log("OnRoomJoined : nom de salle absent du payload, repli sur la salle demandée.");
+ if (!_messagesByRoom.TryGetValue(roomName, out var roomLog))
+ roomLog = _messagesByRoom[roomName] = new();
+ roomLog.Add($"Salon rejoint avec succès : {roomName}");
+ var users = _usersByRoom[roomName] = new();
+ foreach (var u in roomInfo.Users) users.Add(u);
+ if (!JoinedRooms.Contains(roomName))
+ JoinedRooms.Add(roomName);
+ SelectedRoom = roomName;
+ }
+
+ private Task CheckConnectionAsync()
+ {
+ var state = _connection?.State.ToString() ?? "aucune connexion";
+ Log($"État de la connexion : {state}");
+ return Task.CompletedTask;
+ }
+
private async Task JoinRoomAsync()
{
if (_connection == null || string.IsNullOrWhiteSpace(CurrentRoom)) return;
+ // Salle déjà jointe : on se contente de la mettre en focus.
+ if (JoinedRooms.Contains(CurrentRoom))
+ {
+ SelectedRoom = CurrentRoom;
+ return;
+ }
+
+ // InvokeCoreAsync exige une connexion active : avec la
+ // reconnexion automatique, l'état peut être Reconnecting au
+ // moment de l'appel. On tente une (re)connexion si nécessaire.
+ if (_connection.State != HubConnectionState.Connected)
+ {
+ Log($"Connexion non active ({_connection.State}), tentative de reconnexion...");
+ try
+ {
+ await _connection.StartAsync();
+ }
+ catch (Exception ex)
+ {
+ Log($"Reconnexion impossible : {ex.Message}");
+ return;
+ }
+ }
+
try
{
// Appel de la méthode publique 'Join' du Hub (renvoie un ChatRoomInfo)
var info = await _connection.InvokeAsync("Join", CurrentRoom);
if (info == null)
{
- Messages.Add("Erreur lors de la validation du salon par le serveur.");
+ Log("Erreur lors de la validation du salon par le serveur.");
+ }
+ else
+ {
+ // On alimente le modèle depuis la valeur de retour,
+ // sans attendre l'événement "joint".
+ OnRoomJoined(info);
}
}
catch (Exception ex)
{
- Messages.Add($"Erreur Join: {ex.Message}");
- }
- }
-
- private async Task ChangeNickAsync()
- {
- if (_connection == null || string.IsNullOrWhiteSpace(NickName)) return;
-
- try
- {
- // Appel de la méthode publique 'Nick' du Hub (void)
- await _connection.InvokeAsync("Nick", NickName);
- Messages.Add($"Demande de changement de pseudo vers '{NickName}' envoyée.");
- }
- catch (Exception ex)
- {
- Messages.Add($"Erreur Nick: {ex.Message}");
+ Log($"Erreur Join: {ex.Message}");
}
}
}
diff --git a/src/PostIt/PostIt/ViewModels/Chat/RelayCheckCommand.cs b/src/PostIt/PostIt/ViewModels/Chat/RelayCheckCommand.cs
new file mode 100644
index 000000000..909d7a859
--- /dev/null
+++ b/src/PostIt/PostIt/ViewModels/Chat/RelayCheckCommand.cs
@@ -0,0 +1,33 @@
+using System;
+using System.Threading.Tasks;
+using System.Windows.Input;
+
+namespace PostIt.ViewModels.Chat
+{
+ internal class RelayCheckCommand : ICommand, IWithLabel
+ {
+ public string Label { get; }
+ private Func value;
+
+ public RelayCheckCommand(string v, Func value)
+ {
+ this.Label = v;
+ this.value = value;
+ }
+
+ public event EventHandler? CanExecuteChanged;
+
+ public bool CanExecute(object? parameter)
+ {
+ return value != null;
+ }
+
+ public void Execute(object? parameter)
+ {
+ if (value != null)
+ {
+ _ = value();
+ }
+ }
+ }
+}
diff --git a/src/PostIt/PostIt/ViewModels/Commands/BillingCommandPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Commands/BillingCommandPageViewModel.cs
index 5ad2b08f4..4898c9560 100644
--- a/src/PostIt/PostIt/ViewModels/Commands/BillingCommandPageViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/Commands/BillingCommandPageViewModel.cs
@@ -2,6 +2,7 @@ using System;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
+using PostIt.Models;
using Yavsc;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
diff --git a/src/PostIt/PostIt/ViewModels/Commands/BrushViewModel.cs b/src/PostIt/PostIt/ViewModels/Commands/BrushViewModel.cs
index 72d9ce8ea..1b1847e96 100644
--- a/src/PostIt/PostIt/ViewModels/Commands/BrushViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/Commands/BrushViewModel.cs
@@ -6,6 +6,7 @@ using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
+using PostIt.Models;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
using Yavsc.Models.Billing;
diff --git a/src/PostIt/PostIt/ViewModels/Layout/ActionStatusViewModelExtensions.cs b/src/PostIt/PostIt/ViewModels/Layout/ActionStatusViewModelExtensions.cs
index 614d477c8..9fe286ac0 100644
--- a/src/PostIt/PostIt/ViewModels/Layout/ActionStatusViewModelExtensions.cs
+++ b/src/PostIt/PostIt/ViewModels/Layout/ActionStatusViewModelExtensions.cs
@@ -1,3 +1,5 @@
+using PostIt.Models;
+
namespace PostIt.ViewModels;
public interface IActionStatusViewModel
@@ -29,4 +31,4 @@ public static class ActionStatusViewModelExtensions
_ => StatusNotice.Info(normalizedMessage),
};
}
-}
\ No newline at end of file
+}
diff --git a/src/PostIt/PostIt/ViewModels/MyFiles/MyFilesViewModel.cs b/src/PostIt/PostIt/ViewModels/MyFiles/MyFilesViewModel.cs
index cc4b46828..169b29b16 100644
--- a/src/PostIt/PostIt/ViewModels/MyFiles/MyFilesViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/MyFiles/MyFilesViewModel.cs
@@ -7,6 +7,7 @@ using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers;
+using PostIt.Models;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
@@ -226,4 +227,4 @@ public partial class MyFilesViewModel : ViewModelBase, IActionStatusViewModel
var idx = current.LastIndexOf('/');
return idx < 0 ? string.Empty : current[..idx];
}
-}
\ No newline at end of file
+}
diff --git a/src/PostIt/PostIt/ViewModels/Settings/Settings.cs b/src/PostIt/PostIt/ViewModels/Settings/Settings.cs
index c034437b8..72eea5d35 100644
--- a/src/PostIt/PostIt/ViewModels/Settings/Settings.cs
+++ b/src/PostIt/PostIt/ViewModels/Settings/Settings.cs
@@ -9,6 +9,7 @@ using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Globalization;
+using PostIt.Models;
[assembly: InternalsVisibleTo("PostIt.Tests")]
diff --git a/src/PostIt/PostIt/ViewModels/StatusNotice.cs b/src/PostIt/PostIt/ViewModels/StatusNotice.cs
new file mode 100644
index 000000000..404287dbe
--- /dev/null
+++ b/src/PostIt/PostIt/ViewModels/StatusNotice.cs
@@ -0,0 +1,40 @@
+using System.Windows.Input;
+using PostIt.Models;
+
+namespace PostIt.ViewModels;
+
+public sealed class StatusNotice
+{
+ public string Message { get; }
+ public StatusSeverity Severity { get; }
+ public string Glyph { get; }
+ public string Background { get; }
+ public string BorderBrush { get; }
+ public string Foreground { get; }
+
+ public bool HasCheckCommand { get => CheckCommand != null; }
+
+ public ICommand? CheckCommand { get; }
+
+ public string CheckCommandLabel { get; }
+
+ public StatusNotice(string message, StatusSeverity severity, ICommand? checkCommand)
+ {
+ Message = string.IsNullOrWhiteSpace(message) ? "Pret." : message;
+ Severity = severity;
+
+ (Glyph, Background, BorderBrush, Foreground) = severity switch
+ {
+ StatusSeverity.Error => ("!", "#7F1D1D", "#C62828", "#e1f0f6"),
+ StatusSeverity.Warning => ("~", "#7C4A03", "#E6A700", "#eaeaea"),
+ _ => ("i", "#E8F0FE", "#5B8DEF", "#1E3A8A"),
+ };
+ CheckCommand = checkCommand;
+ CheckCommandLabel = HasCheckCommand ?
+ (checkCommand is IWithLabel labeled ? labeled.Label : "Check") : string.Empty;
+ }
+
+ public static StatusNotice Info(string message, ICommand? checkCommand = null) => new(message, StatusSeverity.Info, checkCommand);
+ public static StatusNotice Warning(string message, ICommand? checkCommand = null) => new(message, StatusSeverity.Warning, checkCommand);
+ public static StatusNotice Error(string message, ICommand? checkCommand = null) => new(message, StatusSeverity.Error, checkCommand);
+}
diff --git a/src/PostIt/PostIt/Views/Chat/ChatPage.axaml b/src/PostIt/PostIt/Views/Chat/ChatPage.axaml
index 0ac7e818c..4749736a7 100644
--- a/src/PostIt/PostIt/Views/Chat/ChatPage.axaml
+++ b/src/PostIt/PostIt/Views/Chat/ChatPage.axaml
@@ -1,4 +1,5 @@
+ x:DataType="vm:ChatViewModel"
+ >
+
+
+
-
+
-
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PostIt/PostIt/Views/Chat/ChatPage.axaml.cs b/src/PostIt/PostIt/Views/Chat/ChatPage.axaml.cs
index 5be61b86c..f73e1f827 100644
--- a/src/PostIt/PostIt/Views/Chat/ChatPage.axaml.cs
+++ b/src/PostIt/PostIt/Views/Chat/ChatPage.axaml.cs
@@ -7,4 +7,5 @@ public partial class ChatPage : ContentPage
{
InitializeComponent();
}
+
}
diff --git a/src/PostIt/PostIt/Views/Layout/MainView.axaml b/src/PostIt/PostIt/Views/Layout/MainView.axaml
index 92f53eb54..94b1d98bc 100644
--- a/src/PostIt/PostIt/Views/Layout/MainView.axaml
+++ b/src/PostIt/PostIt/Views/Layout/MainView.axaml
@@ -16,6 +16,7 @@
diff --git a/src/PostIt/PostIt/Views/Layout/SettingsPage.axaml.cs b/src/PostIt/PostIt/Views/Layout/SettingsPage.axaml.cs
index b50245919..9df94960d 100644
--- a/src/PostIt/PostIt/Views/Layout/SettingsPage.axaml.cs
+++ b/src/PostIt/PostIt/Views/Layout/SettingsPage.axaml.cs
@@ -8,6 +8,7 @@ using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
using Microsoft.Extensions.DependencyInjection;
+using PostIt.Models;
using PostIt.Services;
using PostIt.ViewModels;
diff --git a/src/Yavsc.Abstract/Chat/ChatHubConstants.cs b/src/Yavsc.Abstract/Chat/ChatHubConstants.cs
index c673e6fae..e51e77e63 100644
--- a/src/Yavsc.Abstract/Chat/ChatHubConstants.cs
+++ b/src/Yavsc.Abstract/Chat/ChatHubConstants.cs
@@ -4,10 +4,8 @@ namespace Yavsc.Abstract.Chat
{
public static class ChatHubConstants
{
- public const string HubGroupAuthenticated = "authenticated";
- public const string HubGroupAnonymous = "anonymous";
public const string HubGroupCops= "cops";
- public const string HubGroupRomsPrefix = "room_";
+ public const string HubGroupRoomsPrefix = "room_";
public const int MaxChanelName = 255;
public const string HubGroupFollowingPrefix = "fol ";
@@ -21,6 +19,6 @@ namespace Yavsc.Abstract.Chat
public const string HopWontKickOp = "Half operator cannot kick any operator";
public const string LabAuthChatUser = "Authenticated chat user";
public const string NoKickOnCop = "No, you won´t, you´ĺl never do kick a cop, it is the bad.";
- public const string LabNoJoinNoSend = "LabnoJoinNoSend";
+ public const string LabNoJoinNoSend = "LabNoJoinNoSend";
}
}
diff --git a/src/Yavsc.Abstract/Constants.cs b/src/Yavsc.Abstract/Constants.cs
index a2649491c..ca871f9d5 100644
--- a/src/Yavsc.Abstract/Constants.cs
+++ b/src/Yavsc.Abstract/Constants.cs
@@ -13,6 +13,7 @@ namespace Yavsc
public const string BlogTagPath = "blogtag";
public const string CirclePath = "circle";
public const string CommentsPath = "blogcomments";
+ public const string ChatHubPath = "chathub";
public static readonly Scope[] SiteScopes = {
new Scope { Id = "profile", Description = "Your profile informations" },
diff --git a/src/Yavsc.Api/Program.cs b/src/Yavsc.Api/Program.cs
index 8daff34d9..23c0c77fd 100644
--- a/src/Yavsc.Api/Program.cs
+++ b/src/Yavsc.Api/Program.cs
@@ -11,6 +11,7 @@ using Yavsc.Models;
using Yavsc;
using Yavsc.Server.Helpers;
using Yavsc.Services;
+using Yavsc.Server.Hubs;
internal class Program
{
@@ -65,7 +66,12 @@ internal class Program
services.AddAuthentication("Bearer")
.AddYavscJwtBearer(builder.Configuration);
- services.AddSignalR();
+ services.AddSignalR()
+ // ChatRoomInfo et d'autres DTO de hub exposent des champs
+ // publics (pas des propriétés) : STJ ne les sérialise pas
+ // sans IncludeFields, et le client recevrait des objets vides.
+ .AddJsonProtocol(options =>
+ options.PayloadSerializerOptions.IncludeFields = true);
services.AddSingleton();
// DbContextBuilder
@@ -114,13 +120,11 @@ internal class Program
.UseRouting()
.UseAuthentication()
.UseAuthorization()
- .UseCors("default")
- ;
+ .UseCors("default");
app.MapIdentityApi().RequireAuthorization("ApiScope");
app.MapDefaultControllerRoute();
-
-
app.UseSession();
+ app.MapHub("/" + Constants.ChatHubPath);
await app.RunAsync();
}
}
diff --git a/src/Yavsc.Org/Controllers/Accounting/ManageController.cs b/src/Yavsc.Org/Controllers/Accounting/ManageController.cs
index e09bbb5a4..32357c2c5 100644
--- a/src/Yavsc.Org/Controllers/Accounting/ManageController.cs
+++ b/src/Yavsc.Org/Controllers/Accounting/ManageController.cs
@@ -400,7 +400,7 @@ namespace Yavsc.Controllers
public IActionResult SetUserName()
{
- return View(new SetUserNameViewModel() { UserName = User.Identity.Name });
+ return View(new SetUserNameViewModel() { UserName = User.GetUserName() });
}
[HttpPost]
diff --git a/src/Yavsc.Org/Extensions/HostingExtensions.cs b/src/Yavsc.Org/Extensions/HostingExtensions.cs
index b5bb65cc5..563a74789 100644
--- a/src/Yavsc.Org/Extensions/HostingExtensions.cs
+++ b/src/Yavsc.Org/Extensions/HostingExtensions.cs
@@ -1053,8 +1053,6 @@ public static class HostingExtensions
app.UseCors("default");
app.MapDefaultControllerRoute();
app.MapStaticAssets(staticAssetsManifestPath);
- //app.MapRazorPages();
- app.MapHub("/chatHub");
WorkflowHelpers.ConfigureBillingService();
diff --git a/src/Yavsc.Server/Helpers/ServiceExtensions.cs b/src/Yavsc.Server/Helpers/ServiceExtensions.cs
index 397eada85..db5dbe983 100644
--- a/src/Yavsc.Server/Helpers/ServiceExtensions.cs
+++ b/src/Yavsc.Server/Helpers/ServiceExtensions.cs
@@ -128,7 +128,29 @@ public static class ServiceExtensions
options.ClaimsIssuer = authority;
options.Audience = audiences[0];
+ // Sending the access token in the query string is required when using WebSockets or ServerSentEvents
+ // due to a limitation in Browser APIs. We restrict it to only calls to the
+ // SignalR hub in this code.
+ // See https://docs.microsoft.com/aspnet/core/signalr/security#access-token-logging
+ // for more information about security considerations when using
+ // the query string to transmit the access token.
+ options.Events = new JwtBearerEvents
+ {
+ OnMessageReceived = context =>
+ {
+ var accessToken = context.Request.Query["access_token"];
+ // If the request is for our hub...
+ var path = context.HttpContext.Request.Path;
+ if (!string.IsNullOrEmpty(accessToken) &&
+ (path.StartsWithSegments("/hubs/chat")))
+ {
+ // Read the token out of the query string
+ context.Token = accessToken;
+ }
+ return Task.CompletedTask;
+ }
+ };
// Dev: every Yavsc resource service (Yavsc.Api, Yavsc.Blogs,
// Yavsc.Org itself) validates JWTs against the OP that runs
// on https://localhost:5001 with a self-signed dev cert.
diff --git a/src/Yavsc.Server/Hubs/ChatHub.cs b/src/Yavsc.Server/Hubs/ChatHub.cs
index 799f7441c..b9e54496c 100644
--- a/src/Yavsc.Server/Hubs/ChatHub.cs
+++ b/src/Yavsc.Server/Hubs/ChatHub.cs
@@ -24,6 +24,7 @@
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Localization;
+
#pragma warning disable CS4014 // Dans la mesure où cet appel n'est pas attendu, l'exécution de la méthode actuelle continue avant la fin de l'appel
namespace Yavsc.Server.Hubs
@@ -36,6 +37,8 @@ namespace Yavsc.Server.Hubs
using Yavsc.Models.Chat;
using Yavsc.Server.Helpers;
using Yavsc.Services;
+
+ [Authorize]
public partial class ChatHub : Hub, IDisposable
{
private readonly ApplicationDbContext _dbContext;
@@ -53,100 +56,70 @@ namespace Yavsc.Server.Hubs
_dbContext = dbContext;
_localizer = stringLocalizerFactory.Create(typeof(ChatHub));
- _cxManager = connexionManager;
- _cxManager.SetErrorHandler ((context, error) =>
+ _cxManager = connexionManager;
+ _cxManager.SetErrorHandler((context, error) =>
{
NotifyUser(NotificationTypes.Error, context, error);
});
_logger = loggerFactory.CreateLogger();
- InputValidator = new HubInputValidator(_localizer) {
+ InputValidator = new HubInputValidator(_localizer)
+ {
- NotifyUser = async (type, target, msg) => await this.NotifyUser(type, target, msg) };
- }
-
- void SetUserName(string cxId, string userName)
- {
- _cxManager.SetUserName(cxId, userName);
+ NotifyUser = async (type, target, msg) => await this.NotifyUser(type, target, msg)
+ };
}
public override async Task OnConnectedAsync()
{
- bool isAuth = Context.User?.Identity?.IsAuthenticated ?? false;
+ var user = Context.User;
bool isCop = false;
- string userName = setUserName();
- if (isAuth)
+ string userName = user.GetUserName();
+
+ _logger.LogInformation(_localizer.GetString(ChatHubConstants.LabAuthChatUser));
+
+ var userId = _dbContext.Users.First(u => u.UserName == userName).Id;
+
+ await Clients.Group(ChatHubConstants.HubGroupFollowingPrefix + userId).SendAsync("notifyUser", NotificationTypes.Connected, userName, null);
+ isCop = Context.User.IsInMsRole(Constants.AdminGroupName);
+ if (isCop)
{
-
- var group = isAuth ?
- ChatHubConstants.HubGroupAuthenticated : ChatHubConstants.HubGroupAnonymous;
- // Log ("Cx: " + group);
- await Groups.AddToGroupAsync(Context.ConnectionId, group);
- _logger.LogInformation(_localizer.GetString(ChatHubConstants.LabAuthChatUser));
-
- var userId = _dbContext.Users.First(u => u.UserName == Context.User.Identity.Name).Id;
-
- await Clients.Group(ChatHubConstants.HubGroupFollowingPrefix + userId).SendAsync("notifyUser", NotificationTypes.Connected, userName, null);
- isCop = Context.User.IsInMsRole(Constants.AdminGroupName) ;
- if (isCop)
- {
- await Groups.AddToGroupAsync(Context.ConnectionId, ChatHubConstants.HubGroupCops);
- }
-
- foreach (var uid in _dbContext.CircleMembers.Select(m => m.MemberId))
- {
- await Groups.AddToGroupAsync(Context.ConnectionId, ChatHubConstants.HubGroupFollowingPrefix + uid);
- }
+ await Groups.AddToGroupAsync(Context.ConnectionId, ChatHubConstants.HubGroupCops);
}
- else
+
+ foreach (var uid in _dbContext.CircleMembers.Select(m => m.MemberId))
{
- await Groups.AddToGroupAsync(Context.ConnectionId, ChatHubConstants.HubGroupAnonymous);
+ await Groups.AddToGroupAsync(Context.ConnectionId, ChatHubConstants.HubGroupFollowingPrefix + uid);
}
- _cxManager.OnConnected(Context.ConnectionId, isCop);
+ _cxManager.OnConnected(Context.ConnectionId, userName, isCop);
await base.OnConnectedAsync();
}
- string setUserName(string queryUname = "anon")
- {
- if (Context.User != null)
- if (Context.User.Identity.IsAuthenticated)
- {
- SetUserName(Context.ConnectionId, Context.User.Identity.Name);
- return Context.User.Identity.Name;
- }
- anonymousSequence++;
- var anonymousName = $"{ChatHubConstants.AnonymousUserNamePrefix}{queryUname}{anonymousSequence}";
- SetUserName(Context.ConnectionId, anonymousName);
- return anonymousName;
- }
-
- static long anonymousSequence = 0;
-
public override async Task OnDisconnectedAsync(Exception ex)
{
- string userName = Context.User?.Identity.Name;
+ string userName = Context.User?.GetUserName();
if (userName != null)
{
var user = _dbContext.Users.FirstOrDefault(u => u.UserName == userName);
- var userId = user.Id;
- await Clients.Group(ChatHubConstants.HubGroupFollowingPrefix + userId).SendAsync("notifyUser", NotificationTypes.DisConnected, userName, null);
+ if (user != null)
+ {
+ var userId = user.Id;
+ await Clients.Group(ChatHubConstants.HubGroupFollowingPrefix + userId).SendAsync("notifyUser", NotificationTypes.DisConnected, userName, null);
+ }
_cxManager.OnDisconnected(Context.ConnectionId);
}
- await base.OnDisconnectedAsync(ex);
+ await base.OnDisconnectedAsync(ex);
}
-
- public async Task Nick(string nickName)
+ ///
+ /// Renvoie le login résolu côté serveur (claim name de
+ /// l'organisation Yavsc.Org, via ).
+ /// Permet au client d'afficher l'identité réellement authentifiée,
+ /// sans jamais faire confiance à un pseudo fourni par le client.
+ ///
+ public string WhoAmI()
{
- if (!InputValidator.ValidateUserName(nickName)) return;
-
- var candidate = "?" + nickName;
- if (_cxManager.IsConnected(candidate))
- {
- await NotifyUser(NotificationTypes.ExistingUserName, nickName, "aborting");
- return;
- }
- _cxManager.SetUserName( Context.ConnectionId, candidate);
+ return Context.User?.GetUserName() ?? string.Empty;
}
bool IsPresent(string roomName, string userName)
@@ -159,11 +132,11 @@ namespace Yavsc.Server.Hubs
_logger.LogInformation($"Join:{roomName}");
if (!InputValidator.ValidateRoomName(roomName))
{
- _logger.LogError("!InputValidator.ValidateRoomName(roomName)");
+ _logger.LogError("!InputValidator.ValidateRoomName(roomName)");
return null;
}
- var roomGroupName = ChatHubConstants.HubGroupRomsPrefix + roomName;
+ var roomGroupName = ChatHubConstants.HubGroupRoomsPrefix + roomName;
var user = _cxManager.GetUserName(Context.ConnectionId);
await Groups.AddToGroupAsync(Context.ConnectionId, roomGroupName);
ChatRoomInfo channelInfo;
@@ -172,12 +145,18 @@ namespace Yavsc.Server.Hubs
_logger.LogInformation($"Joining");
channelInfo = _cxManager.Join(roomName, Context.ConnectionId);
await Clients.Group(roomGroupName).SendAsync("notifyRoom", NotificationTypes.UserJoin, roomName, user);
- } else {
+ }
+ else
+ {
_logger.LogInformation($"already present");
// in case in an additional connection,
// one only send info on room without
// warning any other user.
- _cxManager.TryGetChanInfo(roomName, out channelInfo);
+ if (!_cxManager.TryGetChanInfo(roomName, out channelInfo))
+ {
+ _logger.LogError($"Failed to retrieve channel info for room: {roomName}");
+ return null;
+ }
}
_logger.LogInformation($"returning channel info");
@@ -185,14 +164,13 @@ namespace Yavsc.Server.Hubs
return channelInfo;
}
- [Authorize]
public void Register(string room)
{
- if (!InputValidator.ValidateRoomName(room)) return ;
+ if (!InputValidator.ValidateRoomName(room)) return;
var existent = _dbContext.ChatRoom.Any(r => r.Name == room);
if (existent)
{
- NotifyUserInRoom(NotificationTypes.Error, room, "already registered.");
+ NotifyUserInRoom(NotificationTypes.Error, room, "already registered.");
return;
}
Debug.Assert(Context.User != null);
@@ -212,27 +190,25 @@ namespace Yavsc.Server.Hubs
_dbContext.SaveChanges(user.Id);
}
- [Authorize]
- public void KickBan(string roomName, string userName, string reason)
+ public void KickBan(string roomName, string userName, string reason)
{
- if (!InputValidator.ValidateRoomName(roomName)) return ;
- if (!InputValidator.ValidateUserName(userName)) return ;
+ if (!InputValidator.ValidateRoomName(roomName)) return;
+ if (!InputValidator.ValidateUserName(userName)) return;
if (!InputValidator.ValidateReason(reason)) return;
Kick(roomName, userName, reason);
Ban(roomName, userName, reason);
}
- [Authorize]
- public async Task Kick(string roomName, string userName, string reason)
+ public async Task Kick(string roomName, string userName, string reason)
{
- if (!InputValidator.ValidateRoomName(roomName)) return ;
- if (!InputValidator.ValidateUserName(userName)) return ;
+ if (!InputValidator.ValidateRoomName(roomName)) return;
+ if (!InputValidator.ValidateUserName(userName)) return;
if (!InputValidator.ValidateReason(reason)) return;
ChatRoomInfo channelInfo;
- var roomGroupName = ChatHubConstants.HubGroupRomsPrefix + roomName;
+ var roomGroupName = ChatHubConstants.HubGroupRoomsPrefix + roomName;
if (_cxManager.TryGetChanInfo(roomName, out channelInfo))
{
- if (!_cxManager.IsPresent(roomName,userName))
+ if (!_cxManager.IsPresent(roomName, userName))
{
NotifyErrorToCallerInRoom(roomName, $"{userName} was not found in {roomName}.");
return;
@@ -242,44 +218,44 @@ namespace Yavsc.Server.Hubs
if (!_cxManager.Kick(Context.ConnectionId, userName, roomName, reason)) return;
}
var cxIds = _cxManager.GetConnexionIds(userName);
- if (cxIds!=null) foreach(var cx in cxIds)
+ if (cxIds != null) foreach (var cx in cxIds)
await Groups.RemoveFromGroupAsync(cx, roomGroupName);
await Clients.Group(roomGroupName).SendAsync("notifyRoom", NotificationTypes.Kick, roomName, $"{userName}: {reason}");
}
- [Authorize]
- public void Ban(string roomName, string userName, string reason)
+ public void Ban(string roomName, string userName, string reason)
{
- if (!InputValidator.ValidateRoomName(roomName)) return ;
- if (!InputValidator.ValidateUserName(userName)) return ;
+ if (!InputValidator.ValidateRoomName(roomName)) return;
+ if (!InputValidator.ValidateUserName(userName)) return;
if (!InputValidator.ValidateReason(reason)) return;
var cxIds = _cxManager.GetConnexionIds(userName);
throw new NotImplementedException();
}
- [Authorize]
- public void GLine(string userName, string reason)
+ [Authorize(Constants.AdminGroupName)]
+ public void GLine(string userName, string reason)
{
- if (!InputValidator.ValidateUserName(userName)) return ;
+ if (!InputValidator.ValidateUserName(userName)) return;
if (!InputValidator.ValidateReason(reason)) return;
throw new NotImplementedException();
}
- public void Part(string roomName, string reason)
+ public void Part(string roomName, string reason)
{
- if (!InputValidator.ValidateRoomName(roomName)) return ;
+ if (!InputValidator.ValidateRoomName(roomName)) return;
if (!InputValidator.ValidateReason(reason)) return;
- if (_cxManager.Part(Context.ConnectionId, roomName, reason))
- {
- var roomGroupName = ChatHubConstants.HubGroupRomsPrefix + roomName;
+ if (_cxManager.Part(Context.ConnectionId, roomName, reason))
+ {
+ var roomGroupName = ChatHubConstants.HubGroupRoomsPrefix + roomName;
var group = Clients.Group(roomGroupName);
var userName = _cxManager.GetUserName(Context.ConnectionId);
group.SendAsync("notifyRoom", NotificationTypes.UserPart, roomName, $"{userName}: {reason}");
Groups.RemoveFromGroupAsync(Context.ConnectionId, roomGroupName);
- }
- else {
- _logger.LogError("Could not part");
- }
+ }
+ else
+ {
+ _logger.LogError("Could not part");
+ }
}
void NotifyErrorToCallerInRoom(string room, string reason)
@@ -288,19 +264,21 @@ namespace Yavsc.Server.Hubs
_logger.LogError($"NotifyErrorToCallerInRoom: {room}, {reason}");
}
- public async Task Send(string roomName, string message)
+ public async Task Send(string roomName, string message)
{
_logger.LogInformation($"Send {roomName} {message}");
- if (!InputValidator.ValidateRoomName(roomName)) {
+ if (!InputValidator.ValidateRoomName(roomName))
+ {
_logger.LogError($"Invalid roomName : {roomName}");
- return ;
+ return;
}
- if (!InputValidator.ValidateMessage(message)) {
+ if (!InputValidator.ValidateMessage(message))
+ {
_logger.LogError($"Invalid message : {message}");
- return ;
+ return;
}
- var groupName = ChatHubConstants.HubGroupRomsPrefix + roomName;
- ChatRoomInfo channelInfo ;
+ var groupName = ChatHubConstants.HubGroupRoomsPrefix + roomName;
+ ChatRoomInfo channelInfo;
if (!_cxManager.TryGetChanInfo(roomName, out channelInfo))
{
_logger.LogError($"No such room : {roomName}");
@@ -317,7 +295,7 @@ namespace Yavsc.Server.Hubs
return;
}
var group = Clients.Group(groupName);
- var msg = new { Name = userName, Room = roomName, Message = message};
+ var msg = new { Name = userName, Room = roomName, Message = message };
await group.SendAsync("ReceiveMessage", msg);
}
@@ -332,8 +310,7 @@ namespace Yavsc.Server.Hubs
await Clients.Caller.SendAsync("notifyUserInRoom", type, room, message);
}
- [Authorize]
- public async Task SendPV(string userName, string message)
+ public async Task SendPV(string userName, string message)
{
// Authorized code
Debug.Assert(Context.User != null);
@@ -342,17 +319,17 @@ namespace Yavsc.Server.Hubs
if (!InputValidator.ValidateUserName(userName))
{
_logger.LogError($"Invalid username : {userName}");
- return ;
+ return;
}
if (!InputValidator.ValidateMessage(message))
{
_logger.LogError($"Invalid message : {message}");
- return ;
+ return;
}
_logger.LogInformation($"Message form is validated.");
- var identityUserName = Context.User.GetUserName();
+ var identityUserName = Context.User.GetUserName();
- if (userName[0] != '?' && Context.User!=null)
+ if (userName[0] != '?' && Context.User != null)
if (!Context.User.IsInMsRole(Constants.AdminGroupName))
{
@@ -373,7 +350,7 @@ namespace Yavsc.Server.Hubs
_logger.LogInformation("getting cx id´s");
var cxIds = _cxManager.GetConnexionIds(userName);
- if (cxIds==null || cxIds.Count()==0)
+ if (cxIds == null || cxIds.Count() == 0)
_logger.LogError($"No such connected user : {userName}");
else foreach (var connectionId in cxIds)
{
@@ -385,18 +362,18 @@ namespace Yavsc.Server.Hubs
}
}
- [Authorize]
- public async Task SendStream(string connectionId, long streamId, string message)
+ public async Task SendStream(string connectionId, long streamId, string message)
{
// Authorized code
Debug.Assert(Context.User != null);
Debug.Assert(Context.User.Identity != null);
if (!InputValidator.ValidateMessage(message)) return;
- var sender = Context.User.Identity.Name;
+ var sender = Context.User.GetUserName();
var cli = Clients.Client(connectionId);
await cli.SendAsync("addStreamInfo", sender, streamId, message);
}
}
}
-#pragma warning restore CS4014 // Dans la mesure où cet appel n'est pas attendu, l'exécution de la méthode actuelle continue avant la fin de l'appel
+
+#pragma warning restore CS4014
diff --git a/src/Yavsc.Server/Interfaces/IConnexionManager.cs b/src/Yavsc.Server/Interfaces/IConnexionManager.cs
index 434054249..4e13a50a5 100644
--- a/src/Yavsc.Server/Interfaces/IConnexionManager.cs
+++ b/src/Yavsc.Server/Interfaces/IConnexionManager.cs
@@ -3,10 +3,8 @@ using Yavsc.ViewModels.Chat;
namespace Yavsc.Services
{
public interface IConnexionManager {
- void SetUserName(string cxId, string userName);
-
string GetUserName (string cxId);
- void OnConnected(string cxId, bool isCop);
+ void OnConnected(string cxId, string userName, bool isCop);
bool IsConnected(string candidate);
void OnDisconnected (string cxId);
diff --git a/src/Yavsc.Server/Services/HubConnectionManager.cs b/src/Yavsc.Server/Services/HubConnectionManager.cs
index 05ed49dda..98d57085e 100644
--- a/src/Yavsc.Server/Services/HubConnectionManager.cs
+++ b/src/Yavsc.Server/Services/HubConnectionManager.cs
@@ -53,30 +53,15 @@ namespace Yavsc.Services
_localizer = stringLocFactory.Create(typeof(HubConnectionManager));
}
- public void SetUserName(string cxId, string userName)
+ public void OnConnected(string cxId, string userName, bool isCop)
{
- string oldUname;
- if (ChatUserNames.TryGetValue(cxId, out oldUname))
- {
- // this is a rename
- if (oldUname == userName) return;
- ChatCxIds[userName] = ChatCxIds[oldUname];
- ChatCxIds[oldUname] = null;
- }
- else
- {
- // this is a connexion
- ChatCxIds[userName] = new List() { cxId };
- }
ChatUserNames[cxId] = userName;
- }
- // Username must have been set before calling this method.
- public void OnConnected(string cxId, bool isCop)
- {
- var username = ChatUserNames[cxId];
- if (!IsConnected(username))
- ChatRoomPresence[username] = new List();
- _isCop[username] = isCop;
+ ChatCxIds.AddOrUpdate(userName,
+ _ => new List { cxId },
+ (_, list) => { lock (list) { if (!list.Contains(cxId)) list.Add(cxId); } return list; });
+ if (!IsConnected(userName))
+ ChatRoomPresence[userName] = new List();
+ _isCop[userName] = isCop;
}
public bool IsConnected(string candidate)