Compare commits

...
Author SHA1 Message Date
84c98cb938
Release 1.0.8-rc20
All checks were successful
Dotnet build and test / build (push) Successful in 17m3s
Forgejo Release / release (push) Successful in 8m21s
2026-10-04 00:43:14 +01:00
9369ff4fcd
feat(chat): modele multi-salles cote client + fix serialisation ChatRoomInfo
Client PostIt:
- JoinedRooms, SelectedRoom, messages et utilisateurs par salle
  (FocusedMessages/FocusedUsers), OnRoomJoined alimente depuis la
  valeur de retour du Join
- ChatPage : ComboBox de choix de la salle en focus, listes bindees
  sur les vues de focus

Serveur:
- AddSignalR().AddJsonProtocol(IncludeFields=true) : ChatRoomInfo
  n'expose que des champs publics, STJ ne les serialisait pas et le
  client recevait des objets vides (JoinedRooms jamais alimente)
2026-10-04 00:35:26 +01:00
51e445b5ba
feat(chat): envoi de messages dans un salon joint
- DTO ChatMessage (Name/Room/Message) pour l'evenement ReceiveMessage
- ChatViewModel : MessageToSend, HasJoinedRoom, handler ReceiveMessage,
  SendMessageCommand (CanExecute: connecte + salon joint + texte non vide)
- ChatPage : TextBox de saisie (Enter envoie) + bouton Envoyer,
  StatusBar repositionnee en Grid.Row=3
2026-10-03 23:55:03 +01:00
75bb2600b5
fix(chat): KeyNotFoundException dans HubConnectionManager.OnConnected
ChatUserNames n'etait jamais alimente (relique d'un SetUserName disparu),
ce qui faisait lever KeyNotFoundException a chaque connexion SignalR et
fermait immediatement la connexion cote client ("Déconnecté (The server
closed the connection...)" dans PostIt).

- IConnexionManager.OnConnected prend desormais le userName en parametre
- HubConnectionManager.OnConnected alimente ChatUserNames et ChatCxIds
- ChatHub.OnConnectedAsync passe le userName resolu depuis les claims

Inclut aussi les changements PostIt lies (bandeau de statut avec
commande d'action, handlers Reconnecting/Reconnected/Closed).
2026-10-03 23:45:22 +01:00
b22a26f61e
SignalR chat connection OK 2026-10-03 22:47:47 +01:00
bb773ba29a
Fixes the /chathub URL 2026-10-03 20:31:28 +01:00
45 changed files with 723 additions and 279 deletions

View file

@ -2,19 +2,71 @@
## [1.0.8-rc20] - unstable ## [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 ### 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 ### Fixed
nothing * [Yavsc.Server] `KeyNotFoundException` dans
`HubConnectionManager.OnConnected` à chaque connexion SignalR :
### Changed `ChatUserNames` (et `ChatCxIds`) n'étaient jamais alimentés — relique
d'un `SetUserName` disparu lors d'une refactorisation. L'exception
nothçing 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 ## [1.0.8-rc19] - unstable

View file

@ -14,7 +14,7 @@ reinstall: copy-binaries
@echo "💯 backend started" @echo "💯 backend started"
reinstall-prod: 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)yavscOrg.service:
generated/$(APP_INSTALL_PREFIX)yavscBlogs.service: generated/$(APP_INSTALL_PREFIX)yavscBlogs.service:

View file

@ -57,5 +57,25 @@ server {
proxy_read_timeout 100s; proxy_read_timeout 100s;
proxy_buffering off; 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; include server-status.conf;
} }

View file

@ -4,6 +4,7 @@
<application <application
android:label="PostIt" android:label="PostIt"
android:icon="@drawable/Icon" android:icon="@drawable/Icon"
android:networkSecurityConfig="@xml/network_security_config"> android:networkSecurityConfig="@xml/network_security_config"
android:hardwareAccelerated="true">
</application> </application>
</manifest> </manifest>

View file

@ -1,4 +1,5 @@
using System.Net.Http; using System.Net.Http;
using PostIt.Models;
using PostIt.ViewModels; using PostIt.ViewModels;
using Yavsc; using Yavsc;
using Yavsc.Api.Client; using Yavsc.Api.Client;

View file

@ -2,6 +2,7 @@ using Avalonia.Controls;
using Avalonia.Headless.XUnit; using Avalonia.Headless.XUnit;
using Avalonia.VisualTree; using Avalonia.VisualTree;
using PostIt.Controls; using PostIt.Controls;
using PostIt.Models;
using PostIt.ViewModels; using PostIt.ViewModels;
namespace PostIt.Tests; namespace PostIt.Tests;
@ -21,7 +22,9 @@ public class StatusBarTests
[AvaloniaFact] [AvaloniaFact]
public void Status_message_is_rendered_by_a_SelectableTextBlock() 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 }; var window = new Window { Content = bar };
window.Show(); window.Show();
@ -32,4 +35,4 @@ public class StatusBarTests
Assert.NotNull(message); Assert.NotNull(message);
Assert.Equal("Échec de la suppression : boom", message!.Text); Assert.Equal("Échec de la suppression : boom", message!.Text);
} }
} }

View file

@ -10,16 +10,19 @@
Padding="8"> Padding="8">
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding Glyph}" <TextBlock Text="{Binding Glyph}"
FontWeight="Bold" FontWeight="Bold"
Foreground="{Binding Foreground}" Foreground="{Binding Foreground}"
VerticalAlignment="Top"/> VerticalAlignment="Top"/>
<!-- SelectableTextBlock so the user can select + copy the
status message (TextBlock is not selectable in
Avalonia). Keeps Text/TextWrapping/Foreground. -->
<SelectableTextBlock Text="{Binding Message}" <SelectableTextBlock Text="{Binding Message}"
Foreground="{Binding Foreground}" Foreground="{Binding Foreground}"
TextWrapping="Wrap" TextWrapping="Wrap"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<Button Content="{Binding CheckCommandLabel}"
Foreground="{Binding Foreground}"
VerticalAlignment="Center"
IsVisible="{Binding HasCheckCommand}"
Command="{Binding CheckCommand}"
/>
</StackPanel> </StackPanel>
</Border> </Border>
</UserControl> </UserControl>

View file

@ -0,0 +1,15 @@
namespace PostIt.ViewModels.Chat
{
/// <summary>
/// DTO de message de salon, calqué sur la charge envoyée par
/// <c>ChatHub.Send</c> 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.
/// </summary>
public class ChatMessage
{
public string? Name { get; set; }
public string? Room { get; set; }
public string? Message { get; set; }
}
}

View file

@ -0,0 +1,14 @@
namespace PostIt.ViewModels.Chat
{
/// <summary>
/// DTO minimal calqué sur l'objet anonyme émis par le hub
/// (<c>{ Name, Room, Message }</c>) via l'événement
/// <c>ReceiveMessage</c>.
/// </summary>
public class ChatMessageDto
{
public string? Name { get; set; }
public string? Room { get; set; }
public string? Message { get; set; }
}
}

View file

@ -0,0 +1,6 @@
namespace PostIt.ViewModels;
internal interface IWithLabel
{
string Label { get; }
}

View file

@ -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);
}

View file

@ -0,0 +1,8 @@
namespace PostIt.Models;
public enum StatusSeverity
{
Info,
Warning,
Error
}

View file

@ -5,7 +5,7 @@ using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using PostIt.Helpers; using PostIt.Models;
using PostIt.Services; using PostIt.Services;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using Yavsc.Api.Client.Dtos; using Yavsc.Api.Client.Dtos;

View file

@ -12,6 +12,7 @@ using Yavsc.Api.Client.Dtos;
using Yavsc.Abstract.BlogSpot; using Yavsc.Abstract.BlogSpot;
using Yavsc.Abstract.Identity.Security; using Yavsc.Abstract.Identity.Security;
using System.Net.Http; using System.Net.Http;
using PostIt.Models;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;

View file

@ -8,6 +8,7 @@ using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers; using PostIt.Helpers;
using PostIt.Models;
using Yavsc.Abstract.Workflow; using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client; using Yavsc.Api.Client;

View file

@ -12,6 +12,7 @@ using PostIt.Helpers;
using Yavsc; using Yavsc;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using Yavsc.Abstract.Workflow; using Yavsc.Abstract.Workflow;
using PostIt.Models;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;

View file

@ -388,4 +388,4 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
QueryStatus.Success => "V", QueryStatus.Success => "V",
_ => "i", _ => "i",
}; };
} }

View file

@ -6,6 +6,7 @@ using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers; using PostIt.Helpers;
using PostIt.Models;
using Yavsc.Abstract.Workflow; using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client; using Yavsc.Api.Client;

View file

@ -10,6 +10,7 @@ using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers; using PostIt.Helpers;
using PostIt.Models;
using Yavsc.Api.Client; using Yavsc.Api.Client;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;

View file

@ -9,6 +9,7 @@ using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers; using PostIt.Helpers;
using PostIt.Models;
using Yavsc.Api.Client; using Yavsc.Api.Client;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;

View file

@ -13,6 +13,7 @@ using PostIt.Helpers;
using Yavsc; using Yavsc;
using Yavsc.Abstract.Workflow; using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using PostIt.Models;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;

View file

@ -12,6 +12,7 @@ using Yavsc.Api.Client;
using Yavsc.Abstract.Files; using Yavsc.Abstract.Files;
using PostIt.Helpers; using PostIt.Helpers;
using AvaloniaEdit.Document; using AvaloniaEdit.Document;
using PostIt.Models;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;

View file

@ -1,21 +1,42 @@
using System; using System;
using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.AspNetCore.SignalR.Client; using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Models;
using PostIt.Services;
using Yavsc;
namespace PostIt.ViewModels.Chat namespace PostIt.ViewModels.Chat
{ {
/// <summary>
/// ViewModel pour la gestion du chat,
/// incluant la connexion au Hub SignalR,
/// la gestion des salons et des utilisateurs connectés.
/// </summary>
public partial class ChatViewModel : ViewModelBase 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<Settings>(); // 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; private HubConnection? _connection;
[ObservableProperty]
public partial string ServerUrl { get; protected set; } = "http://localhost:5000/chathub";
[ObservableProperty] [ObservableProperty]
public partial string CurrentRoom { get; set; } = "General"; public partial string CurrentRoom { get; set; } = "General";
@ -24,14 +45,56 @@ namespace PostIt.ViewModels.Chat
[ObservableProperty] [ObservableProperty]
public partial StatusNotice Status { get; set; } public partial StatusNotice Status { get; set; }
= new StatusNotice("Déconnecté", StatusSeverity.Info);
[ObservableProperty] [ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SendMessageCommand))]
public partial bool IsConnected { get; set; } = false; public partial bool IsConnected { get; set; } = false;
// Listes dynamiques pour l'UI [ObservableProperty]
public ObservableCollection<string> Messages { get; } = new(); [NotifyCanExecuteChangedFor(nameof(SendMessageCommand))]
public ObservableCollection<string> ConnectedUsers { get; } = new(); public partial string MessageToSend { get; set; } = "";
// Salles jointes (choix de la salle en focus dans la vue).
public ObservableCollection<string> 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<string> _systemMessages = new();
// Messages et utilisateurs présents, indexés par salle.
private readonly Dictionary<string, ObservableCollection<string>> _messagesByRoom = new();
private readonly Dictionary<string, ObservableCollection<string>> _usersByRoom = new();
private static readonly ObservableCollection<string> _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<string> FocusedMessages { get; set; }
[ObservableProperty]
public partial ObservableCollection<string> 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 // Commandes pour les boutons
[RelayCommand] [RelayCommand]
@ -40,20 +103,88 @@ namespace PostIt.ViewModels.Chat
[RelayCommand] [RelayCommand]
public async Task JoinRoomCommand() => await JoinRoomAsync(); public async Task JoinRoomCommand() => await JoinRoomAsync();
[RelayCommand] // Le serveur (ChatHub.Send) refuse tout message d'un utilisateur
public async Task ChangeNickCommand() => await ChangeNickAsync(); // 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 CanNavigateNext { get; protected set; } = false;
public override bool CanNavigatePrevious { get; protected set; } = true; 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() private async Task ConnectAsync()
{ {
try 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() _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() .WithAutomaticReconnect()
.Build(); .Build();
@ -61,7 +192,7 @@ namespace PostIt.ViewModels.Chat
_connection.On<string, string, string>("notifyUser", (type, user, msg) => _connection.On<string, string, string>("notifyUser", (type, user, msg) =>
{ {
Avalonia.Threading.Dispatcher.UIThread.Post(() => Avalonia.Threading.Dispatcher.UIThread.Post(() =>
Messages.Add($"[{type}] {user}: {msg}")); Log($"[{type}] {user}: {msg}"));
}); });
// 2. Ecoute des notifications de salon (notifyRoom) // 2. Ecoute des notifications de salon (notifyRoom)
@ -69,66 +200,153 @@ namespace PostIt.ViewModels.Chat
{ {
Avalonia.Threading.Dispatcher.UIThread.Post(() => Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{ {
Messages.Add($"*{user} a interagi avec le salon {room} (Action: {type})"); if (_messagesByRoom.TryGetValue(room, out var roomLog))
if (type == "UserJoin" && !ConnectedUsers.Contains(user)) roomLog.Add($"*{user} a interagi avec le salon {room} (Action: {type})");
ConnectedUsers.Add(user); 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) // 3. Réception de l'état complet suite au Join réussi (joint)
_connection.On<ChatRoomInfo>("joint", (roomInfo) => _connection.On<ChatRoomInfo>("joint", (roomInfo) =>
{
Avalonia.Threading.Dispatcher.UIThread.Post(() => OnRoomJoined(roomInfo));
});
// 4. Réception des messages de salon (diffusés par ChatHub.Send)
_connection.On<ChatMessage>("ReceiveMessage", (msg) =>
{ {
Avalonia.Threading.Dispatcher.UIThread.Post(() => Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{ {
Messages.Add($"Salon rejoint avec succès : {roomInfo.Name}"); var room = msg.Room ?? "?";
ConnectedUsers.Clear(); if (!_messagesByRoom.TryGetValue(room, out var roomLog))
foreach (var u in roomInfo.Users) ConnectedUsers.Add(u); roomLog = _messagesByRoom[room] = new();
roomLog.Add($"{msg.Name} : {msg.Message}");
}); });
}); });
await _connection.StartAsync(); await _connection.StartAsync();
IsConnected = true; 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) 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; 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() private async Task JoinRoomAsync()
{ {
if (_connection == null || string.IsNullOrWhiteSpace(CurrentRoom)) return; 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 try
{ {
// Appel de la méthode publique 'Join' du Hub (renvoie un ChatRoomInfo) // Appel de la méthode publique 'Join' du Hub (renvoie un ChatRoomInfo)
var info = await _connection.InvokeAsync<ChatRoomInfo>("Join", CurrentRoom); var info = await _connection.InvokeAsync<ChatRoomInfo>("Join", CurrentRoom);
if (info == null) 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) catch (Exception ex)
{ {
Messages.Add($"Erreur Join: {ex.Message}"); Log($"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}");
} }
} }
} }

View file

@ -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<Task> value;
public RelayCheckCommand(string v, Func<Task> 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();
}
}
}
}

View file

@ -2,6 +2,7 @@ using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using PostIt.Models;
using Yavsc; using Yavsc;
using Yavsc.Abstract.Workflow; using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client; using Yavsc.Api.Client;

View file

@ -6,6 +6,7 @@ using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using PostIt.Models;
using Yavsc.Abstract.Workflow; using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using Yavsc.Models.Billing; using Yavsc.Models.Billing;

View file

@ -1,3 +1,5 @@
using PostIt.Models;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
public interface IActionStatusViewModel public interface IActionStatusViewModel
@ -29,4 +31,4 @@ public static class ActionStatusViewModelExtensions
_ => StatusNotice.Info(normalizedMessage), _ => StatusNotice.Info(normalizedMessage),
}; };
} }
} }

View file

@ -7,6 +7,7 @@ using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers; using PostIt.Helpers;
using PostIt.Models;
using Yavsc.Api.Client; using Yavsc.Api.Client;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
@ -226,4 +227,4 @@ public partial class MyFilesViewModel : ViewModelBase, IActionStatusViewModel
var idx = current.LastIndexOf('/'); var idx = current.LastIndexOf('/');
return idx < 0 ? string.Empty : current[..idx]; return idx < 0 ? string.Empty : current[..idx];
} }
} }

View file

@ -9,6 +9,7 @@ using System.Net.Http;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using System.Globalization; using System.Globalization;
using PostIt.Models;
[assembly: InternalsVisibleTo("PostIt.Tests")] [assembly: InternalsVisibleTo("PostIt.Tests")]

View file

@ -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);
}

View file

@ -1,4 +1,5 @@
<ContentPage <ContentPage
x:Class="PostIt.Views.Chat.ChatPage"
xmlns="https://github.com/avaloniaui" xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit" xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
@ -9,38 +10,102 @@
xmlns:postitControls="using:PostIt.Controls" xmlns:postitControls="using:PostIt.Controls"
xmlns:views="using:PostIt.Views.Chat" xmlns:views="using:PostIt.Views.Chat"
xmlns:vm="using:PostIt.ViewModels.Chat" xmlns:vm="using:PostIt.ViewModels.Chat"
x:Class="PostIt.Views.Chat.ChatPage" x:DataType="vm:ChatViewModel"
x:DataType="vm:ChatViewModel" > >
<Design.DataContext>
<vm:ChatViewModel />
</Design.DataContext>
<Grid RowDefinitions="Auto, *, Auto" Margin="10"> <Grid Margin="10" RowDefinitions="Auto, *, Auto, Auto">
<Grid Grid.Row="1" ColumnDefinitions="*, 200" Margin="0,0,0,10"> <Grid
Grid.Row="1"
Margin="0,0,0,10"
ColumnDefinitions="*, 200">
<!-- Journal des flux de messages & notifications --> <!-- Journal des flux de messages & notifications -->
<Grid Grid.Column="0" RowDefinitions="Auto, *"> <Grid Grid.Column="0" RowDefinitions="Auto, *">
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="10" Margin="0,0,0,5"> <StackPanel
<TextBox Text="{Binding CurrentRoom}" Watermark="Nom du salon" Width="200" VerticalAlignment="Center"/> Grid.Row="0"
<Button Content="Rejoindre Salon (Join)" Command="{Binding JoinRoomCommand}"/> Margin="0,0,0,5"
</StackPanel> Orientation="Horizontal"
Spacing="10">
<TextBox
Width="200"
VerticalAlignment="Center"
Text="{Binding CurrentRoom}"
PlaceholderText="Nom du salon" />
<Button Command="{Binding JoinRoomCommand}" Content="Rejoindre Salon (Join)" />
<!-- Choix de la salle en focus parmi celles jointes -->
<ComboBox
Width="180"
VerticalAlignment="Center"
ItemsSource="{Binding JoinedRooms}"
PlaceholderText="Salle active"
SelectedItem="{Binding SelectedRoom}" />
</StackPanel>
<ListBox Grid.Row="1" ItemsSource="{Binding Messages}" BorderBrush="#E0E0E0" BorderThickness="1"> <ListBox
<ListBox.ItemTemplate> Grid.Row="1"
<DataTemplate> BorderBrush="#E0E0E0"
<TextBlock Text="{Binding}" TextWrapping="Wrap"/> BorderThickness="1"
</DataTemplate> ItemsSource="{Binding FocusedMessages}">
</ListBox.ItemTemplate> <ListBox.ItemTemplate>
</ListBox> <DataTemplate>
</Grid> <TextBlock Text="{Binding}" TextWrapping="Wrap" />
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
<!-- Liste des utilisateurs présents (MàJ via l'événement 'joint') --> <!-- Liste des utilisateurs présents (MàJ via l'événement 'joint') -->
<Border Grid.Column="1" Margin="10,0,0,0" BorderBrush="#E0E0E0" BorderThickness="1" > <Border
<Grid RowDefinitions="Auto, *"> Grid.Column="1"
<TextBlock Grid.Row="0" Text="Membres Présents" FontWeight="Bold" Margin="5" HorizontalAlignment="Center"/> Margin="10,0,0,0"
<ListBox Grid.Row="1" ItemsSource="{Binding ConnectedUsers}" BorderThickness="0"/> BorderBrush="#E0E0E0"
</Grid> BorderThickness="1">
</Border> <Grid RowDefinitions="Auto, *">
<TextBlock
Grid.Row="0"
Margin="5"
HorizontalAlignment="Center"
FontWeight="Bold"
Text="Membres Présents" />
<ListBox
Grid.Row="1"
BorderThickness="0"
ItemsSource="{Binding FocusedUsers}" />
</Grid> </Grid>
<postitControls:StatusBar Grid.Row="4" DataContext="{Binding Status}" x:Name="StatusBar" /> </Border>
</Grid> </Grid>
<!-- Saisie et envoi d'un message dans le salon joint -->
<Grid
Grid.Row="2"
Margin="0,0,0,10"
ColumnDefinitions="*, Auto">
<TextBox
Grid.Column="0"
VerticalAlignment="Center"
AcceptsReturn="False"
IsEnabled="{Binding !!SelectedRoom}"
PlaceholderText="Votre message..."
Text="{Binding MessageToSend}">
<TextBox.KeyBindings>
<KeyBinding Command="{Binding SendMessageCommand}" Gesture="Enter" />
</TextBox.KeyBindings>
</TextBox>
<Button
Grid.Column="1"
Margin="10,0,0,0"
VerticalAlignment="Center"
Command="{Binding SendMessageCommand}"
Content="Envoyer" />
</Grid>
<postitControls:StatusBar
x:Name="StatusBar"
Grid.Row="3"
DataContext="{Binding Status}" />
</Grid>
</ContentPage> </ContentPage>

View file

@ -7,4 +7,5 @@ public partial class ChatPage : ContentPage
{ {
InitializeComponent(); InitializeComponent();
} }
} }

View file

@ -16,6 +16,7 @@
<DockPanel LastChildFill="True"> <DockPanel LastChildFill="True">
<views:SessionStatusBanner <views:SessionStatusBanner
x:Name="SessionBanner" x:Name="SessionBanner"
DataContext="{Binding SessionStatus}"
DockPanel.Dock="Bottom"/> DockPanel.Dock="Bottom"/>
<NavigationPage x:Name="NavRoot"/> <NavigationPage x:Name="NavRoot"/>
</DockPanel> </DockPanel>

View file

@ -8,6 +8,7 @@ using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using PostIt.Models;
using PostIt.Services; using PostIt.Services;
using PostIt.ViewModels; using PostIt.ViewModels;

View file

@ -4,10 +4,8 @@ namespace Yavsc.Abstract.Chat
{ {
public static class ChatHubConstants public static class ChatHubConstants
{ {
public const string HubGroupAuthenticated = "authenticated";
public const string HubGroupAnonymous = "anonymous";
public const string HubGroupCops= "cops"; public const string HubGroupCops= "cops";
public const string HubGroupRomsPrefix = "room_"; public const string HubGroupRoomsPrefix = "room_";
public const int MaxChanelName = 255; public const int MaxChanelName = 255;
public const string HubGroupFollowingPrefix = "fol "; 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 HopWontKickOp = "Half operator cannot kick any operator";
public const string LabAuthChatUser = "Authenticated chat user"; 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 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";
} }
} }

View file

@ -13,6 +13,7 @@ namespace Yavsc
public const string BlogTagPath = "blogtag"; public const string BlogTagPath = "blogtag";
public const string CirclePath = "circle"; public const string CirclePath = "circle";
public const string CommentsPath = "blogcomments"; public const string CommentsPath = "blogcomments";
public const string ChatHubPath = "chathub";
public static readonly Scope[] SiteScopes = { public static readonly Scope[] SiteScopes = {
new Scope { Id = "profile", Description = "Your profile informations" }, new Scope { Id = "profile", Description = "Your profile informations" },

View file

@ -11,6 +11,7 @@ using Yavsc.Models;
using Yavsc; using Yavsc;
using Yavsc.Server.Helpers; using Yavsc.Server.Helpers;
using Yavsc.Services; using Yavsc.Services;
using Yavsc.Server.Hubs;
internal class Program internal class Program
{ {
@ -65,7 +66,12 @@ internal class Program
services.AddAuthentication("Bearer") services.AddAuthentication("Bearer")
.AddYavscJwtBearer(builder.Configuration); .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<IConnexionManager, HubConnectionManager>(); services.AddSingleton<IConnexionManager, HubConnectionManager>();
// DbContextBuilder // DbContextBuilder
@ -114,13 +120,11 @@ internal class Program
.UseRouting() .UseRouting()
.UseAuthentication() .UseAuthentication()
.UseAuthorization() .UseAuthorization()
.UseCors("default") .UseCors("default");
;
app.MapIdentityApi<ApplicationUser>().RequireAuthorization("ApiScope"); app.MapIdentityApi<ApplicationUser>().RequireAuthorization("ApiScope");
app.MapDefaultControllerRoute(); app.MapDefaultControllerRoute();
app.UseSession(); app.UseSession();
app.MapHub<ChatHub>("/" + Constants.ChatHubPath);
await app.RunAsync(); await app.RunAsync();
} }
} }

View file

@ -400,7 +400,7 @@ namespace Yavsc.Controllers
public IActionResult SetUserName() public IActionResult SetUserName()
{ {
return View(new SetUserNameViewModel() { UserName = User.Identity.Name }); return View(new SetUserNameViewModel() { UserName = User.GetUserName() });
} }
[HttpPost] [HttpPost]

View file

@ -1053,8 +1053,6 @@ public static class HostingExtensions
app.UseCors("default"); app.UseCors("default");
app.MapDefaultControllerRoute(); app.MapDefaultControllerRoute();
app.MapStaticAssets(staticAssetsManifestPath); app.MapStaticAssets(staticAssetsManifestPath);
//app.MapRazorPages();
app.MapHub<ChatHub>("/chatHub");
WorkflowHelpers.ConfigureBillingService(); WorkflowHelpers.ConfigureBillingService();

View file

@ -128,7 +128,29 @@ public static class ServiceExtensions
options.ClaimsIssuer = authority; options.ClaimsIssuer = authority;
options.Audience = audiences[0]; 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, // Dev: every Yavsc resource service (Yavsc.Api, Yavsc.Blogs,
// Yavsc.Org itself) validates JWTs against the OP that runs // Yavsc.Org itself) validates JWTs against the OP that runs
// on https://localhost:5001 with a self-signed dev cert. // on https://localhost:5001 with a self-signed dev cert.

View file

@ -24,6 +24,7 @@
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Localization; 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 #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 namespace Yavsc.Server.Hubs
@ -36,6 +37,8 @@ namespace Yavsc.Server.Hubs
using Yavsc.Models.Chat; using Yavsc.Models.Chat;
using Yavsc.Server.Helpers; using Yavsc.Server.Helpers;
using Yavsc.Services; using Yavsc.Services;
[Authorize]
public partial class ChatHub : Hub, IDisposable public partial class ChatHub : Hub, IDisposable
{ {
private readonly ApplicationDbContext _dbContext; private readonly ApplicationDbContext _dbContext;
@ -53,100 +56,70 @@ namespace Yavsc.Server.Hubs
_dbContext = dbContext; _dbContext = dbContext;
_localizer = stringLocalizerFactory.Create(typeof(ChatHub)); _localizer = stringLocalizerFactory.Create(typeof(ChatHub));
_cxManager = connexionManager; _cxManager = connexionManager;
_cxManager.SetErrorHandler ((context, error) => _cxManager.SetErrorHandler((context, error) =>
{ {
NotifyUser(NotificationTypes.Error, context, error); NotifyUser(NotificationTypes.Error, context, error);
}); });
_logger = loggerFactory.CreateLogger<ChatHub>(); _logger = loggerFactory.CreateLogger<ChatHub>();
InputValidator = new HubInputValidator(_localizer) { InputValidator = new HubInputValidator(_localizer)
{
NotifyUser = async (type, target, msg) => await this.NotifyUser(type, target, msg) }; NotifyUser = async (type, target, msg) => await this.NotifyUser(type, target, msg)
} };
void SetUserName(string cxId, string userName)
{
_cxManager.SetUserName(cxId, userName);
} }
public override async Task OnConnectedAsync() public override async Task OnConnectedAsync()
{ {
bool isAuth = Context.User?.Identity?.IsAuthenticated ?? false; var user = Context.User;
bool isCop = false; bool isCop = false;
string userName = setUserName(); string userName = user.GetUserName();
if (isAuth)
_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)
{ {
await Groups.AddToGroupAsync(Context.ConnectionId, ChatHubConstants.HubGroupCops);
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);
}
} }
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(); 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) public override async Task OnDisconnectedAsync(Exception ex)
{ {
string userName = Context.User?.Identity.Name; string userName = Context.User?.GetUserName();
if (userName != null) if (userName != null)
{ {
var user = _dbContext.Users.FirstOrDefault(u => u.UserName == userName); var user = _dbContext.Users.FirstOrDefault(u => u.UserName == userName);
var userId = user.Id; if (user != null)
await Clients.Group(ChatHubConstants.HubGroupFollowingPrefix + userId).SendAsync("notifyUser", NotificationTypes.DisConnected, userName, null); {
var userId = user.Id;
await Clients.Group(ChatHubConstants.HubGroupFollowingPrefix + userId).SendAsync("notifyUser", NotificationTypes.DisConnected, userName, null);
}
_cxManager.OnDisconnected(Context.ConnectionId); _cxManager.OnDisconnected(Context.ConnectionId);
} }
await base.OnDisconnectedAsync(ex); await base.OnDisconnectedAsync(ex);
} }
/// <summary>
public async Task Nick(string nickName) /// Renvoie le login résolu côté serveur (claim <c>name</c> de
/// l'organisation Yavsc.Org, via <see cref="UserHelpers.GetUserName"/>).
/// Permet au client d'afficher l'identité réellement authentifiée,
/// sans jamais faire confiance à un pseudo fourni par le client.
/// </summary>
public string WhoAmI()
{ {
if (!InputValidator.ValidateUserName(nickName)) return; return Context.User?.GetUserName() ?? string.Empty;
var candidate = "?" + nickName;
if (_cxManager.IsConnected(candidate))
{
await NotifyUser(NotificationTypes.ExistingUserName, nickName, "aborting");
return;
}
_cxManager.SetUserName( Context.ConnectionId, candidate);
} }
bool IsPresent(string roomName, string userName) bool IsPresent(string roomName, string userName)
@ -159,11 +132,11 @@ namespace Yavsc.Server.Hubs
_logger.LogInformation($"Join:{roomName}"); _logger.LogInformation($"Join:{roomName}");
if (!InputValidator.ValidateRoomName(roomName)) if (!InputValidator.ValidateRoomName(roomName))
{ {
_logger.LogError("!InputValidator.ValidateRoomName(roomName)"); _logger.LogError("!InputValidator.ValidateRoomName(roomName)");
return null; return null;
} }
var roomGroupName = ChatHubConstants.HubGroupRomsPrefix + roomName; var roomGroupName = ChatHubConstants.HubGroupRoomsPrefix + roomName;
var user = _cxManager.GetUserName(Context.ConnectionId); var user = _cxManager.GetUserName(Context.ConnectionId);
await Groups.AddToGroupAsync(Context.ConnectionId, roomGroupName); await Groups.AddToGroupAsync(Context.ConnectionId, roomGroupName);
ChatRoomInfo channelInfo; ChatRoomInfo channelInfo;
@ -172,12 +145,18 @@ namespace Yavsc.Server.Hubs
_logger.LogInformation($"Joining"); _logger.LogInformation($"Joining");
channelInfo = _cxManager.Join(roomName, Context.ConnectionId); channelInfo = _cxManager.Join(roomName, Context.ConnectionId);
await Clients.Group(roomGroupName).SendAsync("notifyRoom", NotificationTypes.UserJoin, roomName, user); await Clients.Group(roomGroupName).SendAsync("notifyRoom", NotificationTypes.UserJoin, roomName, user);
} else { }
else
{
_logger.LogInformation($"already present"); _logger.LogInformation($"already present");
// in case in an additional connection, // in case in an additional connection,
// one only send info on room without // one only send info on room without
// warning any other user. // 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"); _logger.LogInformation($"returning channel info");
@ -185,14 +164,13 @@ namespace Yavsc.Server.Hubs
return channelInfo; return channelInfo;
} }
[Authorize]
public void Register(string room) public void Register(string room)
{ {
if (!InputValidator.ValidateRoomName(room)) return ; if (!InputValidator.ValidateRoomName(room)) return;
var existent = _dbContext.ChatRoom.Any(r => r.Name == room); var existent = _dbContext.ChatRoom.Any(r => r.Name == room);
if (existent) if (existent)
{ {
NotifyUserInRoom(NotificationTypes.Error, room, "already registered."); NotifyUserInRoom(NotificationTypes.Error, room, "already registered.");
return; return;
} }
Debug.Assert(Context.User != null); Debug.Assert(Context.User != null);
@ -212,27 +190,25 @@ namespace Yavsc.Server.Hubs
_dbContext.SaveChanges(user.Id); _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.ValidateRoomName(roomName)) return;
if (!InputValidator.ValidateUserName(userName)) return ; if (!InputValidator.ValidateUserName(userName)) return;
if (!InputValidator.ValidateReason(reason)) return; if (!InputValidator.ValidateReason(reason)) return;
Kick(roomName, userName, reason); Kick(roomName, userName, reason);
Ban(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.ValidateRoomName(roomName)) return;
if (!InputValidator.ValidateUserName(userName)) return ; if (!InputValidator.ValidateUserName(userName)) return;
if (!InputValidator.ValidateReason(reason)) return; if (!InputValidator.ValidateReason(reason)) return;
ChatRoomInfo channelInfo; ChatRoomInfo channelInfo;
var roomGroupName = ChatHubConstants.HubGroupRomsPrefix + roomName; var roomGroupName = ChatHubConstants.HubGroupRoomsPrefix + roomName;
if (_cxManager.TryGetChanInfo(roomName, out channelInfo)) if (_cxManager.TryGetChanInfo(roomName, out channelInfo))
{ {
if (!_cxManager.IsPresent(roomName,userName)) if (!_cxManager.IsPresent(roomName, userName))
{ {
NotifyErrorToCallerInRoom(roomName, $"{userName} was not found in {roomName}."); NotifyErrorToCallerInRoom(roomName, $"{userName} was not found in {roomName}.");
return; return;
@ -242,44 +218,44 @@ namespace Yavsc.Server.Hubs
if (!_cxManager.Kick(Context.ConnectionId, userName, roomName, reason)) return; if (!_cxManager.Kick(Context.ConnectionId, userName, roomName, reason)) return;
} }
var cxIds = _cxManager.GetConnexionIds(userName); 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 Groups.RemoveFromGroupAsync(cx, roomGroupName);
await Clients.Group(roomGroupName).SendAsync("notifyRoom", NotificationTypes.Kick, roomName, $"{userName}: {reason}"); 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.ValidateRoomName(roomName)) return;
if (!InputValidator.ValidateUserName(userName)) return ; if (!InputValidator.ValidateUserName(userName)) return;
if (!InputValidator.ValidateReason(reason)) return; if (!InputValidator.ValidateReason(reason)) return;
var cxIds = _cxManager.GetConnexionIds(userName); var cxIds = _cxManager.GetConnexionIds(userName);
throw new NotImplementedException(); throw new NotImplementedException();
} }
[Authorize] [Authorize(Constants.AdminGroupName)]
public void GLine(string userName, string reason) public void GLine(string userName, string reason)
{ {
if (!InputValidator.ValidateUserName(userName)) return ; if (!InputValidator.ValidateUserName(userName)) return;
if (!InputValidator.ValidateReason(reason)) return; if (!InputValidator.ValidateReason(reason)) return;
throw new NotImplementedException(); 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 (!InputValidator.ValidateReason(reason)) return;
if (_cxManager.Part(Context.ConnectionId, roomName, reason)) if (_cxManager.Part(Context.ConnectionId, roomName, reason))
{ {
var roomGroupName = ChatHubConstants.HubGroupRomsPrefix + roomName; var roomGroupName = ChatHubConstants.HubGroupRoomsPrefix + roomName;
var group = Clients.Group(roomGroupName); var group = Clients.Group(roomGroupName);
var userName = _cxManager.GetUserName(Context.ConnectionId); var userName = _cxManager.GetUserName(Context.ConnectionId);
group.SendAsync("notifyRoom", NotificationTypes.UserPart, roomName, $"{userName}: {reason}"); group.SendAsync("notifyRoom", NotificationTypes.UserPart, roomName, $"{userName}: {reason}");
Groups.RemoveFromGroupAsync(Context.ConnectionId, roomGroupName); Groups.RemoveFromGroupAsync(Context.ConnectionId, roomGroupName);
} }
else { else
_logger.LogError("Could not part"); {
} _logger.LogError("Could not part");
}
} }
void NotifyErrorToCallerInRoom(string room, string reason) void NotifyErrorToCallerInRoom(string room, string reason)
@ -288,19 +264,21 @@ namespace Yavsc.Server.Hubs
_logger.LogError($"NotifyErrorToCallerInRoom: {room}, {reason}"); _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}"); _logger.LogInformation($"Send {roomName} {message}");
if (!InputValidator.ValidateRoomName(roomName)) { if (!InputValidator.ValidateRoomName(roomName))
{
_logger.LogError($"Invalid roomName : {roomName}"); _logger.LogError($"Invalid roomName : {roomName}");
return ; return;
} }
if (!InputValidator.ValidateMessage(message)) { if (!InputValidator.ValidateMessage(message))
{
_logger.LogError($"Invalid message : {message}"); _logger.LogError($"Invalid message : {message}");
return ; return;
} }
var groupName = ChatHubConstants.HubGroupRomsPrefix + roomName; var groupName = ChatHubConstants.HubGroupRoomsPrefix + roomName;
ChatRoomInfo channelInfo ; ChatRoomInfo channelInfo;
if (!_cxManager.TryGetChanInfo(roomName, out channelInfo)) if (!_cxManager.TryGetChanInfo(roomName, out channelInfo))
{ {
_logger.LogError($"No such room : {roomName}"); _logger.LogError($"No such room : {roomName}");
@ -317,7 +295,7 @@ namespace Yavsc.Server.Hubs
return; return;
} }
var group = Clients.Group(groupName); 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); await group.SendAsync("ReceiveMessage", msg);
} }
@ -332,8 +310,7 @@ namespace Yavsc.Server.Hubs
await Clients.Caller.SendAsync("notifyUserInRoom", type, room, message); 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 // Authorized code
Debug.Assert(Context.User != null); Debug.Assert(Context.User != null);
@ -342,17 +319,17 @@ namespace Yavsc.Server.Hubs
if (!InputValidator.ValidateUserName(userName)) if (!InputValidator.ValidateUserName(userName))
{ {
_logger.LogError($"Invalid username : {userName}"); _logger.LogError($"Invalid username : {userName}");
return ; return;
} }
if (!InputValidator.ValidateMessage(message)) if (!InputValidator.ValidateMessage(message))
{ {
_logger.LogError($"Invalid message : {message}"); _logger.LogError($"Invalid message : {message}");
return ; return;
} }
_logger.LogInformation($"Message form is validated."); _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)) if (!Context.User.IsInMsRole(Constants.AdminGroupName))
{ {
@ -373,7 +350,7 @@ namespace Yavsc.Server.Hubs
_logger.LogInformation("getting cx id´s"); _logger.LogInformation("getting cx id´s");
var cxIds = _cxManager.GetConnexionIds(userName); var cxIds = _cxManager.GetConnexionIds(userName);
if (cxIds==null || cxIds.Count()==0) if (cxIds == null || cxIds.Count() == 0)
_logger.LogError($"No such connected user : {userName}"); _logger.LogError($"No such connected user : {userName}");
else foreach (var connectionId in cxIds) 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 // Authorized code
Debug.Assert(Context.User != null); Debug.Assert(Context.User != null);
Debug.Assert(Context.User.Identity != null); Debug.Assert(Context.User.Identity != null);
if (!InputValidator.ValidateMessage(message)) return; if (!InputValidator.ValidateMessage(message)) return;
var sender = Context.User.Identity.Name; var sender = Context.User.GetUserName();
var cli = Clients.Client(connectionId); var cli = Clients.Client(connectionId);
await cli.SendAsync("addStreamInfo", sender, streamId, message); 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

View file

@ -3,10 +3,8 @@ using Yavsc.ViewModels.Chat;
namespace Yavsc.Services namespace Yavsc.Services
{ {
public interface IConnexionManager { public interface IConnexionManager {
void SetUserName(string cxId, string userName);
string GetUserName (string cxId); string GetUserName (string cxId);
void OnConnected(string cxId, bool isCop); void OnConnected(string cxId, string userName, bool isCop);
bool IsConnected(string candidate); bool IsConnected(string candidate);
void OnDisconnected (string cxId); void OnDisconnected (string cxId);

View file

@ -53,30 +53,15 @@ namespace Yavsc.Services
_localizer = stringLocFactory.Create(typeof(HubConnectionManager)); _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<string>() { cxId };
}
ChatUserNames[cxId] = userName; ChatUserNames[cxId] = userName;
} ChatCxIds.AddOrUpdate(userName,
// Username must have been set before calling this method. _ => new List<string> { cxId },
public void OnConnected(string cxId, bool isCop) (_, list) => { lock (list) { if (!list.Contains(cxId)) list.Add(cxId); } return list; });
{ if (!IsConnected(userName))
var username = ChatUserNames[cxId]; ChatRoomPresence[userName] = new List<string>();
if (!IsConnected(username)) _isCop[userName] = isCop;
ChatRoomPresence[username] = new List<string>();
_isCop[username] = isCop;
} }
public bool IsConnected(string candidate) public bool IsConnected(string candidate)