Merge branch 'fix/postit-acl-and-pub' into release/1.0.8-rc6
All checks were successful
Dotnet build and test / build (push) Successful in 7m31s

This commit is contained in:
Paul Schneider 2026-08-29 15:42:12 +01:00
commit 1b237fa540
Signed by: notazof
GPG key ID: 1DD5D838E5343B06
16 changed files with 385 additions and 229 deletions

5
.vscode/tasks.json vendored
View file

@ -7,12 +7,13 @@
"fileLocation": ["relative", "${workspaceFolder}"],
"source": "dotnet",
"pattern": {
"regexp": "^\\s+(.*)\\((\\d+):(\\d+)\\):\\s+(error|warning)\\s+(.*)$",
"regexp": "^\\s+(.*)\\((\\d+),(\\d+)\\):\\s+(error|warning) (.+): (.*)$",
"file": 1,
"line": 2,
"column": 3,
"severity": 4,
"message": 5
"code": 5,
"message": 6
}
}
],

View file

@ -1,21 +1,18 @@
# Changelog
Toutes les modifications notables de PostIt et de la plateforme Yavsc
sont documentées dans ce fichier.
## [1.0.8-rc7] - unstable
Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et ce projet adhère au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Added
À noter : la **parité du numéro de patch** porte une signification de canal :
* [PostIt] The search pattern now persists
- **patch pair** (ex. `1.0.0`, `1.0.2`) → **stable**
- **patch impair** (ex. `1.0.1`, `1.0.3`) → **preview**
- **suffixe** (ex. `1.0.0-rc1`, `1.0.0-alpha`) → **instable**
### Changed
Cette convention est partagée avec le dépôt
[`postit-debian`](https://forgejo.pschneider.fr/notazof/postit-debian)
pour la production des paquets `.deb`.
* The blog spot path is now `/api/v1/blogspot` (yet in last release)
### Fixed
* [Yavsc.Org][TODO] La forme de l'email de l'utilisateur est maintenant validée avant l'envoi du formulaire d'enregistrement
## [1.0.8-rc6] - unstable

View file

@ -49,6 +49,26 @@ Les tests sont répartis en :
item « Tests d'intégration smoke par BC ».
- `src/PostIt.Tests/` — tests unitaires du client desktop PostIt.
## Le CHANGELOG.md
Le `CHANGELOG.md` est un document de changement de version
Toutes les modifications notables de PostIt et de la plateforme Yavsc
sont documentées dans ce fichier.
Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et ce projet adhère au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
À noter : la **parité du numéro de patch** porte une signification de canal :
- **patch pair** (ex. `1.0.0`, `1.0.2`) → **stable**
- **patch impair** (ex. `1.0.1`, `1.0.3`) → **preview**
- **suffixe** (ex. `1.0.0-rc1`, `1.0.0-alpha`) → **instable**
Cette convention est partagée avec le dépôt
[`postit-debian`](https://forgejo.pschneider.fr/notazof/postit-debian)
pour la production des paquets `.deb`.
## Navigation (PostIt)
La navigation est centralisée dans

View file

@ -81,7 +81,7 @@ public class MainPageSaveTests
Assert.NotEmpty(recorder.Calls);
var (method, path, body) = recorder.FirstCall;
Assert.Equal(HttpMethod.Post, method);
Assert.Equal("blog", path);
Assert.Equal("blogspot", path);
var sent = Assert.IsType<BlogPostDto>(body);
Assert.Equal(typed, sent.Title);
}

View file

@ -1,3 +1,5 @@
using System.Text.Json;
namespace PostIt.Tests;
public class SettingsLoadTests
@ -149,4 +151,26 @@ public class SettingsLoadTests
Assert.True(settings.Loaded);
}
[Fact]
public void SearchText_is_serialized_in_settings_and_round_trips()
{
var settings = new PostIt.ViewModels.Settings
{
Authentication = new AuthenticationSettings
{
Authority = "https://example.test/",
ClientId = "postit-tests",
Scopes = new[] { "openid" }
}
};
settings.SearchText = "bonjour";
var json = JsonSerializer.Serialize(settings);
var roundTrip = JsonSerializer.Deserialize<PostIt.ViewModels.Settings>(json);
Assert.NotNull(roundTrip);
Assert.Equal("bonjour", roundTrip.SearchText);
}
}

View file

@ -157,6 +157,7 @@ private void ConfigureRootView(MainView rootView)
{
var app = (App)Current!;
var mainVm = app.ServiceProvider!.GetRequiredService<MainViewModel>();
await mainVm.InitializeAsync();
await app.PushPageAsync(mainVm);
}

View file

@ -42,17 +42,9 @@ public static class ServiceCollectionHelpers
// the navigation stack, each bound to a fresh
// SettingsViewModel and missing any in-flight edits.
services.AddSingleton<SettingsPage>();
services.AddTransient<HomePage>();
services.AddTransient<SignaturePage>();
services.AddTransient<CirclesPage>();
// Dialogs (modal-light pages): the ViewLocator resolves
// them when a caller pushes a PostAclDialogViewModel or
// AddCircleMemberDialogViewModel via App.PushPageAsync.
// App.PushPageAsync overwrites the page's DataContext with
// the caller-built VM, so the parameterless ctor is enough
// here — the parametrised ctors stay for direct test wiring.
services.AddTransient<PostAclDialog>();
services.AddTransient<AddCircleMemberDialog>();
services.AddSingleton<HomePage>();
services.AddSingleton<SignaturePage>();
services.AddSingleton<CirclesPage>();
// ViewModels
services.AddSingleton(settings);
services.AddSingleton<YavscApiClient>(api);
@ -61,18 +53,25 @@ public static class ServiceCollectionHelpers
services.AddSingleton(blogAclClient);
services.AddSingleton(userSearchClient);
services.AddSingleton<IUserDirectory>(userDirectory);
services.AddTransient<MainViewModel>();
services.AddTransient<HomePageViewModel>();
services.AddTransient<SignaturePageViewModel>();
services.AddTransient<CirclesPageViewModel>();
services.AddSingleton<HomePageViewModel>();
services.AddSingleton<SignaturePageViewModel>();
services.AddSingleton<CirclesPageViewModel>();
// Dialogs (modal-light pages): the ViewLocator resolves
// them when a caller pushes a PostAclDialogViewModel or
// AddCircleMemberDialogViewModel via App.PushPageAsync.
// App.PushPageAsync overwrites the page's DataContext with
// the caller-built VM, so the parameterless ctor is enough
// here — the parametrised ctors stay for direct test wiring.
services.AddTransient<PostAclDialog>();
services.AddTransient<AddCircleMemberDialog>();
// Persistent session banner: one instance for the lifetime of
// the app so the same VM survives page navigation.
var sessionStatus = new SessionStatusViewModel { Api = api };
sessionStatus.Refresh();
services.AddSingleton(sessionStatus);
services.AddTransient<SessionStatusBanner>();
services.AddSingleton<SessionStatusBanner>();
services.AddSingleton<MainViewModel>();
return services.BuildServiceProvider();
}
}

View file

@ -48,7 +48,7 @@ public partial class MainViewModel : ViewModelBase
/// mutable field. Toggling is its own action.</summary>
[ObservableProperty]
public partial bool DraftIsPublished { get; set; }
public bool IsLoaded { get; private set; }
public Settings SettingsModel { get; }
[ObservableProperty]
@ -72,6 +72,194 @@ public partial class MainViewModel : ViewModelBase
[ObservableProperty]
public partial Settings Settings { get; private set; }
[RelayCommand]
internal async Task RefreshAsync()
{
await ExecuteAsync(async () =>
{
var posts = await BlogClient!.GetPostsAsync();
Posts.Clear();
foreach (var post in posts.OrderByDescending(p => p.DateModified))
{
Posts.Add(post);
}
ApplyFilter();
StatusMessage = $"Loaded {Posts.Count} posts.";
});
}
[RelayCommand]
internal async Task SearchAsync() {
await RefreshAsync();
ApplyFilter();
}
[RelayCommand]
internal async Task SaveAsync()
{
// The button is already disabled when the title is empty
// (see CanSave), but the test path (and any programmatic
// ICommand.Execute) bypasses CanExecute, so we still
// guard here. Better to no-op with a status message
// than to send a request the server will reject.
if (string.IsNullOrWhiteSpace(DraftTitle))
{
StatusMessage = "Title is required.";
return;
}
await ExecuteAsync(async () =>
{
// Build a fresh BlogPostDto from the editor buffer on
// every Save — we no longer mutate SelectedPost in
// place. The previous behaviour copied the buffer
// (which was a no-op when SelectedPost was null)
// back onto the model and relied on a
// [Required] violation to surface the missing
// input; the new shape keeps the editor buffer as
// the single source of truth for outgoing payloads
// and the selected post as a read-only hint for
// the update path.
if (SelectedPost is null || SelectedPost.Id == 0)
{
var draft = new BlogPostDto
{
Title = DraftTitle,
Article = DraftArticle ?? string.Empty,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
IsPublished = DraftIsPublished
};
var created = await BlogClient!.CreatePostAsync(draft);
if (created is not null)
{
SelectedPost = created;
StatusMessage = $"Created post {created.Id}.";
}
}
else
{
var update = new BlogPostDto
{
Id = SelectedPost.Id,
AuthorId = SelectedPost.AuthorId,
Photo = SelectedPost.Photo,
Title = DraftTitle,
Article = DraftArticle ?? string.Empty,
DateCreated = SelectedPost.DateCreated,
DateModified = DateTime.UtcNow,
};
await BlogClient!.UpdatePostAsync(SelectedPost.Id, update);
StatusMessage = $"Saved post {SelectedPost.Id}.";
}
await RefreshPostsAsync();
});
}
[RelayCommand]
internal async Task DeleteAsync()
{
if (SelectedPost is null || SelectedPost.Id == 0)
{
StatusMessage = "Select an existing post before deleting.";
return;
}
await ExecuteAsync(async () =>
{
await BlogClient!.DeletePostAsync(SelectedPost.Id);
StatusMessage = $"Deleted post {SelectedPost.Id}.";
SelectedPost = null;
await RefreshPostsAsync();
});
}
/// <summary>
/// Toggle the publication state of the currently selected
/// post. Pushes the new state to
/// <c>PUT /api/BlogApi/{id}/publish</c> and reflects it
/// locally in <see cref="DraftIsPublished"/> + the
/// selected post so the UI updates without a full
/// refresh.
///
/// <para>The toggle is its own action — separate from Save
/// — because <c>Publish</c> is not part of the
/// <c>BlogPostDto</c> payload. Bundling it into Save
/// would require a wire-shape change and a second server
/// overload; the dedicated endpoint keeps the wire
/// contract clean.</para>
/// </summary>
[RelayCommand]
internal async Task TogglePublishAsync()
{
if (SelectedPost is null || SelectedPost.Id == 0)
{
StatusMessage = "Sélectionnez un billet existant pour changer sa publication.";
return;
}
await ExecuteAsync(async () =>
{
var desired = !DraftIsPublished;
await BlogClient!.SetPublishAsync(SelectedPost.Id, desired);
DraftIsPublished = desired;
// Mirror into the selected post so a subsequent
// RefreshPostsAsync() doesn't blow away the
// locally flipped state until the round-trip
// re-hydrates it.
SelectedPost.IsPublished = desired;
StatusMessage = desired
? $"Billet {SelectedPost.Id} publié."
: $"Billet {SelectedPost.Id} remis en brouillon.";
});
}
/// <summary>
/// DEV ONLY: open the signature capture page. The production
/// entry point is a SignalR push from Yavsc.Org ("devis
/// received, sign here"); this command is the dev-time
/// shortcut to reach the page without that infrastructure.
/// Aligned on the same VM-first navigation pattern as
/// <see cref="OpenSettings"/>: the VM resolves the target VM
/// through <see cref="Services"/>, the <c>ViewLocator</c> picks
/// the matching <c>Control</c> at bind time. No
/// <c>Click</code> handler, no <c>App.ServiceProvider</c>
/// access from the view layer.
/// </summary>
[RelayCommand]
internal async Task OpenSignatureDevAsync()
{
await ((App)App.Current!).PushPageAsync(SignatureModel).ConfigureAwait(true);
}
[RelayCommand(CanExecute = nameof(CanManageAcl))]
public async Task ManageAclAsync()
{
if (SelectedPost is null)
{
StatusMessage = "Select an existing post before managing ACL.";
return;
}
await ((App)App.Current!).PushPageAsync(GetACLViewModel(SelectedPost)).ConfigureAwait(true);
}
[RelayCommand]
public async Task OpenCirclesAsync()
{
var circlesVm = ResolveServices().GetRequiredService<CirclesPageViewModel>();
await ((App)App.Current!).PushPageAsync(circlesVm).ConfigureAwait(true);
}
private ViewModelBase GetACLViewModel(BlogPostDto selectedPost)
{
var sp = ResolveServices();
var aclClient = sp.GetRequiredService<BlogAclApiClient>();
var circleClient = sp.GetRequiredService<CircleApiClient>();
return new PostAclDialogViewModel(selectedPost, aclClient, circleClient);
}
/// <summary>
/// API surface that hits the Yavsc.Blogs deployment at
/// <see cref="Settings.ApiUrl"/>. Owned and constructed by
@ -130,17 +318,18 @@ public partial class MainViewModel : ViewModelBase
private void Init(Settings? settings)
{
SearchText = string.Empty;
Posts = new ObservableCollection<BlogPostDto>();
FilteredPosts = new ObservableCollection<BlogPostDto>();
SelectedPost = null;
IsBusy = false;
StatusMessage = "Ready";
Settings = settings ?? new Settings();
SearchText = Settings.SearchText;
WindowTitle = "PostIt";
DraftTitle = string.Empty;
DraftArticle = string.Empty;
DraftIsPublished = false;
IsLoaded = false;
// Production path: DI injects the canonical Settings singleton
// and we use it as-is. Test path: tests call this constructor
// without a Settings argument; we fall back to a fresh
@ -152,6 +341,15 @@ public partial class MainViewModel : ViewModelBase
// (thread-safe dispatcher marshalling) so the duplicate
// instance is now merely wasteful, not dangerous.
Settings.PropertyChanged += (s, e) =>
{
if (e.PropertyName == nameof(Settings.SearchText))
{
SearchText = Settings.SearchText;
ApplyFilter();
}
};
}
/// <summary>Save is enabled as soon as the user has typed
@ -179,7 +377,14 @@ public partial class MainViewModel : ViewModelBase
Init(settings);
}
partial void OnSearchTextChanged(string value) => ApplyFilter();
partial void OnSearchTextChanged(string value)
{
if (Settings is not null && Settings.SearchText != value)
{
Settings.SearchText = value;
}
ApplyFilter();
}
partial void OnSelectedPostChanged(BlogPostDto? value)
{
@ -206,170 +411,6 @@ public partial class MainViewModel : ViewModelBase
partial void OnDraftTitleChanged(string value) => SaveCommand.NotifyCanExecuteChanged();
partial void OnDraftArticleChanged(string value) => SaveCommand.NotifyCanExecuteChanged();
[RelayCommand]
internal async Task LoadPosts()
{
await ExecuteAsync(async () =>
{
var posts = await BlogClient!.GetPostsAsync();
Posts.Clear();
foreach (var post in posts.OrderByDescending(p => p.DateModified))
{
Posts.Add(post);
}
ApplyFilter();
StatusMessage = $"Loaded {Posts.Count} posts.";
});
}
[RelayCommand]
internal void Search() => ApplyFilter();
[RelayCommand]
internal async Task Save()
{
// The button is already disabled when the title is empty
// (see CanSave), but the test path (and any programmatic
// ICommand.Execute) bypasses CanExecute, so we still
// guard here. Better to no-op with a status message
// than to send a request the server will reject.
if (string.IsNullOrWhiteSpace(DraftTitle))
{
StatusMessage = "Title is required.";
return;
}
await ExecuteAsync(async () =>
{
// Build a fresh BlogPostDto from the editor buffer on
// every Save — we no longer mutate SelectedPost in
// place. The previous behaviour copied the buffer
// (which was a no-op when SelectedPost was null)
// back onto the model and relied on a
// [Required] violation to surface the missing
// input; the new shape keeps the editor buffer as
// the single source of truth for outgoing payloads
// and the selected post as a read-only hint for
// the update path.
if (SelectedPost is null || SelectedPost.Id == 0)
{
var draft = new BlogPostDto
{
Title = DraftTitle,
Article = DraftArticle ?? string.Empty,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
};
var created = await BlogClient!.CreatePostAsync(draft);
if (created is not null)
{
SelectedPost = created;
StatusMessage = $"Created post {created.Id}.";
}
}
else
{
var update = new BlogPostDto
{
Id = SelectedPost.Id,
AuthorId = SelectedPost.AuthorId,
Photo = SelectedPost.Photo,
Title = DraftTitle,
Article = DraftArticle ?? string.Empty,
DateCreated = SelectedPost.DateCreated,
DateModified = DateTime.UtcNow,
};
await BlogClient!.UpdatePostAsync(SelectedPost.Id, update);
StatusMessage = $"Saved post {SelectedPost.Id}.";
}
await RefreshPostsAsync();
});
}
[RelayCommand]
internal async Task Delete()
{
if (SelectedPost is null || SelectedPost.Id == 0)
{
StatusMessage = "Select an existing post before deleting.";
return;
}
await ExecuteAsync(async () =>
{
await BlogClient!.DeletePostAsync(SelectedPost.Id);
StatusMessage = $"Deleted post {SelectedPost.Id}.";
SelectedPost = null;
await RefreshPostsAsync();
});
}
/// <summary>
/// Toggle the publication state of the currently selected
/// post. Pushes the new state to
/// <c>PUT /api/BlogApi/{id}/publish</c> and reflects it
/// locally in <see cref="DraftIsPublished"/> + the
/// selected post so the UI updates without a full
/// refresh.
///
/// <para>The toggle is its own action — separate from Save
/// — because <c>Publish</c> is not part of the
/// <c>BlogPostDto</c> payload. Bundling it into Save
/// would require a wire-shape change and a second server
/// overload; the dedicated endpoint keeps the wire
/// contract clean.</para>
/// </summary>
[RelayCommand]
internal async Task TogglePublish()
{
if (SelectedPost is null || SelectedPost.Id == 0)
{
StatusMessage = "Sélectionnez un billet existant pour changer sa publication.";
return;
}
await ExecuteAsync(async () =>
{
var desired = !DraftIsPublished;
await BlogClient!.SetPublishAsync(SelectedPost.Id, desired);
DraftIsPublished = desired;
// Mirror into the selected post so a subsequent
// RefreshPostsAsync() doesn't blow away the
// locally flipped state until the round-trip
// re-hydrates it.
SelectedPost.IsPublished = desired;
StatusMessage = desired
? $"Billet {SelectedPost.Id} publié."
: $"Billet {SelectedPost.Id} remis en brouillon.";
});
}
/// <summary>
/// DEV ONLY: open the signature capture page. The production
/// entry point is a SignalR push from Yavsc.Org ("devis
/// received, sign here"); this command is the dev-time
/// shortcut to reach the page without that infrastructure.
/// Aligned on the same VM-first navigation pattern as
/// <see cref="OpenSettings"/>: the VM resolves the target VM
/// through <see cref="Services"/>, the <c>ViewLocator</c> picks
/// the matching <c>Control</c> at bind time. No
/// <c>Click</code> handler, no <c>App.ServiceProvider</c>
/// access from the view layer.
/// </summary>
[RelayCommand]
internal async Task OpenSignatureDev()
{
await ((App)App.Current!).PushPageAsync(SignatureModel).ConfigureAwait(true);
}
private ViewModelBase GetACLViewModel(BlogPostDto selectedPost)
{
var sp = ResolveServices();
var aclClient = sp.GetRequiredService<BlogAclApiClient>();
var circleClient = sp.GetRequiredService<CircleApiClient>();
return new PostAclDialogViewModel(selectedPost, aclClient, circleClient);
}
private async Task RefreshPostsAsync()
{
@ -426,28 +467,18 @@ public partial class MainViewModel : ViewModelBase
private void UpdateCommandStates()
{
LoadPostsCommand.NotifyCanExecuteChanged();
RefreshCommand.NotifyCanExecuteChanged();
SaveCommand.NotifyCanExecuteChanged();
DeleteCommand.NotifyCanExecuteChanged();
}
[RelayCommand(CanExecute = nameof(CanManageAcl))]
public async Task ManageAcl()
internal async Task InitializeAsync()
{
if (SelectedPost is null)
if (!IsLoaded)
{
StatusMessage = "Select an existing post before managing ACL.";
return;
await RefreshAsync();
IsLoaded = true;
}
await ((App)App.Current!).PushPageAsync(GetACLViewModel(SelectedPost)).ConfigureAwait(true);
}
[RelayCommand]
public async Task OpenCircles()
{
var circlesVm = ResolveServices().GetRequiredService<CirclesPageViewModel>();
await ((App)App.Current!).PushPageAsync(circlesVm).ConfigureAwait(true);
}
}

View file

@ -28,6 +28,9 @@ public partial class Settings : ViewModelBase
[ObservableProperty]
public partial string BusinessApiUrl { get; set; } = "https://business.pschneider.fr/api/v1/";
[ObservableProperty]
public partial string SearchText { get; set; } = string.Empty;
/// <summary>
/// Catch top-level mutations: the four ObservableProperty
/// setters above all funnel through here, and we flip
@ -43,6 +46,7 @@ public partial class Settings : ViewModelBase
partial void OnDarkModeChanged(bool value) => MarkDirty();
partial void OnBlogsApiUrlChanged(string value) => MarkDirty();
partial void OnBusinessApiUrlChanged(string value) => MarkDirty();
partial void OnSearchTextChanged(string value) => MarkDirty();
/// <summary>
/// Authentication can be reassigned wholesale by
@ -295,6 +299,7 @@ public partial class Settings : ViewModelBase
{
this.Authentication = settings.Authentication;
this.DarkMode = settings.DarkMode;
this.SearchText = settings.SearchText ?? string.Empty;
if (!(settings.Authentication is null))
{
this.Authentication = new AuthenticationSettings();
@ -348,6 +353,7 @@ public partial class Settings : ViewModelBase
Scopes = AuthenticationSettings.DefaultScopes
};
this.DarkMode = false;
this.SearchText = string.Empty;
}
/// <summary>

View file

@ -29,15 +29,15 @@
VerticalAlignment="Top">
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Command="{Binding LoadPosts}" Content="Load posts" />
<Button Command="{Binding Search}" Content="Filter" />
<Button Command="{Binding Save}" Content="Save" />
<Button Command="{Binding Delete}" Content="Delete" />
<Button Command="{Binding RefreshAsync}" Content="Refresh" />
<Button Command="{Binding SearchAsync}" Content="Filter" />
<Button Command="{Binding SaveAsync}" Content="Save" />
<Button Command="{Binding DeleteAsync}" Content="Delete" />
<Button x:Name="ManageAclButton"
Command="{Binding ManageAcl}"
Command="{Binding ManageAclAsync}"
Content="ACL" />
<Button x:Name="OpenCirclesButton"
Command="{Binding OpenCircles}"
Command="{Binding OpenCirclesAsync}"
Content="Mes cercles" />
<!-- Publication toggle: a CheckBox wired to
DraftIsPublished. Clicking it fires
@ -49,7 +49,7 @@
and the buffer in sync. -->
<CheckBox Content="Publié"
IsChecked="{Binding DraftIsPublished, Mode=TwoWay}"
Command="{Binding TogglePublishCommand}"
Command="{Binding TogglePublishAsync}"
VerticalAlignment="Center"/>
<!--
DEV ONLY: temporary shortcut to open the signature
@ -59,7 +59,7 @@
MainPage.axaml.cs once the SignalR handler lands.
-->
<Button x:Name="OpenSignatureDevButton"
Command="{Binding OpenSignatureDev}"
Command="{Binding OpenSignatureDevAsync}"
Content="[DEV] Signature"
ToolTip.Tip="DEV ONLY — to remove when SignalR handler lands" />
</StackPanel>

View file

@ -1,4 +1,6 @@
using System;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
namespace PostIt.Views;
@ -8,4 +10,16 @@ public partial class MainPage : ContentPage
{
InitializeComponent();
}
}
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
if (DataContext is ViewModels.MainViewModel vm)
{
if (!vm.IsLoaded)
{
vm.RefreshAsync().Wait();
}
}
}
}

View file

@ -1,3 +1,4 @@
using System;
using Avalonia.Controls;
namespace PostIt.Views;
@ -8,4 +9,16 @@ public partial class MainView : UserControl
{
InitializeComponent();
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is ViewModels.MainViewModel vm)
{
if (!vm.IsLoaded)
{
vm.RefreshAsync().Wait();
}
}
}
}

View file

@ -7,7 +7,5 @@
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.9" />
<PackageVersion Include="xunit.v3.common" Version="3.2.2" />
<PackageVersion Include="xunit.v3.extensibility.core" Version="3.2.2" />
</ItemGroup>
</Project>

View file

@ -55,5 +55,6 @@ namespace Yavsc.Org.Tests
client.Calls.Select(c => c.Kind).ToArray());
Assert.Equal(_serverFixture.SiteSettings.Owner.EMail, client.LastSentMessage?.To.Mailboxes.First().Address);
}
}
}

View file

@ -20,6 +20,5 @@
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.2" />
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.9" />
<PackageVersion Include="YamlDotNet" Version="18.0.0" />
</ItemGroup>
</Project>

View file

@ -9,6 +9,7 @@ using Yavsc.Settings;
using Yavsc.Models;
using Microsoft.AspNetCore.Identity.UI.Services;
using Microsoft.Extensions.Localization;
using System.Text.RegularExpressions;
using System.Web;
namespace Yavsc.Services
@ -53,45 +54,96 @@ namespace Yavsc.Services
/// </returns>
public Task SendEmailAsync(string email, string subject, string htmlMessage)
{
return SendEmailAsync("", email, subject, htmlMessage);
return SendEmailAsync(null, email, subject, htmlMessage);
}
internal static MailboxAddress BuildMailboxAddress(string? displayName, string? rawAddress)
{
if (string.IsNullOrWhiteSpace(rawAddress))
{
throw new FormatException("Email address is empty.");
}
var candidate = rawAddress.Trim();
if (candidate.Contains('<') || candidate.Contains('>'))
{
var emailMatch = Regex.Match(candidate,
@"[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}",
RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
if (emailMatch.Success)
{
candidate = emailMatch.Value;
}
else
{
candidate = candidate.Trim('<', '>', '"', '\'');
}
}
candidate = candidate.Trim('"', '\'', '<', '>', ' ');
candidate = candidate.Replace(" ", string.Empty);
if (!MailboxAddress.TryParse(candidate, out var parsedAddress))
{
throw new FormatException($"Invalid email address '{rawAddress}'.");
}
var safeName = string.IsNullOrWhiteSpace(displayName)
? parsedAddress.Name
: displayName.Trim();
return new MailboxAddress(safeName ?? string.Empty, parsedAddress.Address);
}
public async Task<string> SendEmailAsync(string name, string email, string subject, string htmlMessage)
{
logger.LogInformation($"SendEmail for {email} : {subject}");
MimeMessage msg = new();
msg.From.Add(new MailboxAddress(siteSettings.Owner.Name,
siteSettings.Owner.EMail));
msg.To.Add(new MailboxAddress(name, email));
TextPart text;
msg.Body = text = new TextPart("html")
try
{
Text = $"<html><body>{htmlMessage}</body></html>"
};
msg.Subject = subject;
msg.MessageId = MimeKit.Utils.MimeUtils.GenerateMessageId(
siteSettings.Authority
);
using ISmtpClient sc = _smtpClientFactory.CreateClient();
{
sc.Timeout = 30000;
sc.Connect(
smtpSettings.Host,
smtpSettings.Port,
SecureSocketOptions.Auto
);
if (smtpSettings.UserName != null)
logger.LogInformation($"SendEmail for {email} : {subject}");
MimeMessage msg = new();
msg.From.Add(BuildMailboxAddress(siteSettings.Owner.Name, siteSettings.Owner.EMail));
msg.To.Add(BuildMailboxAddress(name, email));
TextPart text;
msg.Body = text = new TextPart("html")
{
sc.Authenticate(smtpSettings.UserName, smtpSettings.Password);
}
Text = $"<html><body>{htmlMessage}</body></html>"
};
await sc.SendAsync(msg);
logger.LogInformation($"Sent : {msg.MessageId}");
sc.Disconnect(true);
msg.Subject = subject;
msg.MessageId = MimeKit.Utils.MimeUtils.GenerateMessageId(
siteSettings.Authority
);
using ISmtpClient sc = _smtpClientFactory.CreateClient();
{
sc.Timeout = 30000;
sc.Connect(
smtpSettings.Host,
smtpSettings.Port,
SecureSocketOptions.Auto
);
if (smtpSettings.UserName != null)
{
sc.Authenticate(smtpSettings.UserName, smtpSettings.Password);
}
await sc.SendAsync(msg);
logger.LogInformation($"Sent : {msg.MessageId}");
sc.Disconnect(true);
}
return msg.MessageId;
}
catch (FormatException ex)
{
logger.LogError(ex, "Refusing to send email because the recipient or sender address is malformed. To={To}, From={From}", email, siteSettings.Owner.EMail);
return string.Empty;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to send email. To={To}, Subject={Subject}", email, subject);
throw;
}
return msg.MessageId;
}
public void SendEmailFromCriteria(string Criteria)