diff --git a/CHANGELOG.md b/CHANGELOG.md index d89929c6a..60b3cb013 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added +* [PostIt/API] Persist the account-level `HideIpAddressInCalls` preference (default false), expose it through the authenticated profile API and PostIt profile editor, and preserve it when older clients omit it. Prepare future TURN-only calls without enabling media transport yet. * [PostIt] Show a local live capture preview on Android and Desktop without opening a second camera or reading back uploaded segments. Share Android's oriented EGL frames with an aspect-preserving preview surface; duplicate Desktop LibVLC output without local microphone playback. * [Podcasts] Prepare authenticated, seekable MP4 replays for new and existing Completed broadcasts using server-side FFmpeg stream copy and faststart. Retain MPEG-TS, account for both copies in the owner quota, recover preparation after restarts, and expose owner-only retries plus GET/HEAD byte-range playback. * [PostIt] Add Android/Desktop native video overlays for MP4 replay position, duration, seeking and play/pause, with three-second auto-hide. Use bounded authenticated range input with token refresh; preserve MPEG-TS live/interrupted playback. diff --git a/doc/architecture/audio-video-calls.md b/doc/architecture/audio-video-calls.md index 68f5c1e50..c172e22c1 100644 --- a/doc/architecture/audio-video-calls.md +++ b/doc/architecture/audio-video-calls.md @@ -2,8 +2,10 @@ ## Statut et périmètre -Ce document décrit un **plan d'implémentation**, pas une fonctionnalité déjà -disponible. Il complète la [topologie de PostIt](postit.md) et les +Ce document décrit un **plan d'implémentation** : les appels ne sont pas encore +disponibles. La préférence de confidentialité est persistée et éditable dans +le profil, mais ne pilote aucun transport média à ce stade. +Il complète la [topologie de PostIt](postit.md) et les [mécanismes de notification existants](notifications.md). La cible couvre : @@ -82,12 +84,17 @@ simplicité d'exploitation prime sur le coût serveur. ### Contrat fonctionnel -Prévoir un booléen de compte, nommé provisoirement `HideIpAddressInCalls`, -persisté côté serveur et accessible en lecture/écriture uniquement par -l'utilisateur concerné via la gestion de son profil. Le réglage est partagé -entre ses appareils ; une préférence locale seule ne suffit pas. +Le booléen de compte `HideIpAddressInCalls` est persisté côté serveur et +accessible en lecture/écriture uniquement par +l'utilisateur concerné via `GET /api/v1/account/me` et +`PUT /api/v1/account/me`, ainsi que dans « Mon profil » de PostIt. +Le réglage est partagé entre ses appareils ; une préférence locale seule +ne suffit pas. -Valeur initiale proposée : `false`, à confirmer avant implémentation. +Valeur initiale : `false`, pour les nouveaux comptes et les comptes existants +après migration. Le champ de mise à jour est nullable : son absence ou `null` +conserve la valeur existante, notamment pour les anciens clients ; `false` +explicite désactive la préférence. `false` autorise le chemin direct mais n'interdit pas TURN lorsqu'il est nécessaire à la connectivité. `true` impose un transport relayé. @@ -96,6 +103,12 @@ en précisant le surcoût potentiel en latence et en bande passante serveur. Il ne procure pas l'anonymat : les services contactés, notamment l'API, le serveur de signalisation et TURN, peuvent toujours connaître l'adresse IP. +La migration EF Core `HideIpAddressInCalls`, dans le projet Yavsc.Org, ajoute +la colonne booléenne non nullable à `AspNetUsers`. Elle doit être déployée +avant de servir les nouvelles lectures et écritures de profil. Cette étape +n'installe ni TURN ni SFU et n'implémente pas encore les règles de négociation +décrites ci-dessous. + ### Application à la négociation - Charger la préférence avant de créer le transport média. Si sa lecture diff --git a/src/PostIt/PostIt.Tests/AdministrationAccessTests.cs b/src/PostIt/PostIt.Tests/AdministrationAccessTests.cs index 50e64f713..fc733e3f4 100644 --- a/src/PostIt/PostIt.Tests/AdministrationAccessTests.cs +++ b/src/PostIt/PostIt.Tests/AdministrationAccessTests.cs @@ -180,6 +180,60 @@ public class AdministrationAccessTests Assert.Contains("Impossible de charger mon usage", vm.UsageError); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task My_profile_loads_and_includes_the_call_privacy_preference_in_the_save_payload(bool initial) + { + await using var api = new ProfileTransport("Performer") { HideIpAddressInCalls = initial }; + var vm = new UserProfilePageViewModel(api); + await vm.RefreshAsync(); + Assert.Equal(initial, vm.HideIpAddressInCalls); + vm.HideIpAddressInCalls = !initial; + Assert.Equal(!initial, vm.BuildProfileUpdateRequest().HideIpAddressInCalls); + Assert.True(vm.SaveProfileCommand.CanExecute(null)); + Assert.True(vm.IsProfileLoaded); + } + + [AvaloniaFact] + public async Task My_profile_privacy_checkbox_is_two_way_and_disabled_before_loading() + { + await using var api = new ProfileTransport("Performer") { HideIpAddressInCalls = true }; + var vm = new UserProfilePageViewModel(api); + var page = new UserProfilePage { DataContext = vm }; + var window = new Window { Content = page }; + try + { + window.Show(); + var checkbox = Assert.Single(page.GetVisualDescendants().OfType(), + control => Equals(control.Content, "Masquer mon adresse IP aux autres participants")); + Assert.False(checkbox.IsEffectivelyEnabled); + await vm.RefreshAsync(); + Assert.True(checkbox.IsEffectivelyEnabled); + Assert.True(checkbox.IsChecked); + checkbox.IsChecked = false; + Assert.False(vm.HideIpAddressInCalls); + Assert.Equal(false, vm.BuildProfileUpdateRequest().HideIpAddressInCalls); + vm.IsBusy = true; + Assert.False(checkbox.IsEffectivelyEnabled); + } + finally + { + window.Close(); + } + } + + [Fact] + public async Task Profile_read_failure_prevents_saving_a_default_call_privacy_preference() + { + await using var api = new ProfileTransport("Performer") { FailProfile = true }; + var vm = new UserProfilePageViewModel(api); + await vm.RefreshAsync(); + Assert.False(vm.IsProfileLoaded); + Assert.False(vm.SaveProfileCommand.CanExecute(null)); + Assert.Contains("Impossible de charger le profil", vm.StatusMessage); + } + internal sealed class ProfileTransport : YavscApiClient { private readonly string _tokenPath; @@ -188,6 +242,7 @@ public class AdministrationAccessTests public Task? PendingProfile { get; set; } public bool FailProfile { get; set; } public bool FailUsage { get; set; } + public bool HideIpAddressInCalls { get; set; } public ResourceUsageApiClient UsageClient => new(this, Settings.ApiUrl); public ProfileTransport(string role) @@ -216,7 +271,7 @@ public class AdministrationAccessTests if (FailProfile) throw new HttpRequestException("Profile unavailable"); result = PendingProfile is not null ? await PendingProfile - : new Me { UserId = "alice-id", UserName = "alice", Roles = [_role] }; + : new Me { UserId = "alice-id", UserName = "alice", Roles = [_role], HideIpAddressInCalls = HideIpAddressInCalls }; } else if (path.Contains("/resource-usage/")) { diff --git a/src/PostIt/PostIt/ViewModels/Layout/UserProfilePageViewModel.cs b/src/PostIt/PostIt/ViewModels/Layout/UserProfilePageViewModel.cs index 449648ef3..61f071683 100644 --- a/src/PostIt/PostIt/ViewModels/Layout/UserProfilePageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Layout/UserProfilePageViewModel.cs @@ -91,6 +91,9 @@ public partial class UserProfilePageViewModel : ViewModelBase [ObservableProperty] public partial bool AllowMonthlyEmail { get; set; } + [ObservableProperty] + public partial bool HideIpAddressInCalls { get; set; } + [ObservableProperty] public partial bool HasPassword { get; set; } @@ -195,6 +198,7 @@ public partial class UserProfilePageViewModel : ViewModelBase BankInfoSummary = string.IsNullOrWhiteSpace(me.BankInfoSummary) ? "Aucune information bancaire" : me.BankInfoSummary; EmailConfirmed = me.EmailConfirmed; AllowMonthlyEmail = me.AllowMonthlyEmail; + HideIpAddressInCalls = me.HideIpAddressInCalls; HasPassword = me.HasPassword; TwoFactorEnabled = me.TwoFactorEnabled; ExternalLoginCount = me.ExternalLoginCount; @@ -280,7 +284,8 @@ public partial class UserProfilePageViewModel : ViewModelBase UserName = NormalizeField(UserName, "—"), FullName = NormalizeField(FullName, "—"), Address = NormalizeField(Address, "—"), - GoogleCalendarId = NormalizeField(DedicatedGoogleCalendar, "Non configuré") + GoogleCalendarId = NormalizeField(DedicatedGoogleCalendar, "Non configuré"), + HideIpAddressInCalls = HideIpAddressInCalls }; } diff --git a/src/PostIt/PostIt/Views/Layout/UserProfilePage.axaml b/src/PostIt/PostIt/Views/Layout/UserProfilePage.axaml index 2c2ccf483..afdf47cfb 100644 --- a/src/PostIt/PostIt/Views/Layout/UserProfilePage.axaml +++ b/src/PostIt/PostIt/Views/Layout/UserProfilePage.axaml @@ -136,6 +136,16 @@ TextWrapping="Wrap"/> + + + + + +