From d8200cb98ddef152313ef1f102b866c50f107cbd Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 25 Oct 2026 16:49:51 +0000 Subject: [PATCH] feat(postit): persist call IP privacy preference Store HideIpAddressInCalls on user accounts, expose it in the profile API and PostIt editor, and preserve preferences for older clients. Add the EF migration, regression tests and architecture documentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + doc/architecture/audio-video-calls.md | 27 +- .../PostIt.Tests/AdministrationAccessTests.cs | 57 +- .../Layout/UserProfilePageViewModel.cs | 7 +- .../PostIt/Views/Layout/UserProfilePage.axaml | 10 + src/Yavsc.Abstract/Identity/Me.cs | 1 + .../Identity/ProfileUpdateRequest.cs | 1 + .../accounting/AccountController.cs | 15 +- ...025155604_HideIpAddressInCalls.Designer.cs | 4969 +++++++++++++++++ .../20261025155604_HideIpAddressInCalls.cs | 29 + .../ApplicationDbContextModelSnapshot.cs | 3 + src/Yavsc.Server/Models/ApplicationUser.cs | 3 + .../AccountCallPreferenceTests.cs | 149 + .../Fixtures/ApiWebServerFixture.cs | 1 + 14 files changed, 5262 insertions(+), 11 deletions(-) create mode 100644 src/Yavsc.Org/Migrations/20261025155604_HideIpAddressInCalls.Designer.cs create mode 100644 src/Yavsc.Org/Migrations/20261025155604_HideIpAddressInCalls.cs create mode 100644 src/test/Yavsc.Api.Tests/AccountCallPreferenceTests.cs 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"/> + + + + + +