From eba44b46e2e0c9cc04d2a218bab1f3dca630d266 Mon Sep 17 00:00:00 2001
From: Paul Schneider
Date: Sun, 12 Jul 2026 15:51:55 +0100
Subject: [PATCH 001/128] postit: allow self-signed OIDC TLS in Development
---
src/PostIt/PostIt/ViewModels/Settings.cs | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
diff --git a/src/PostIt/PostIt/ViewModels/Settings.cs b/src/PostIt/PostIt/ViewModels/Settings.cs
index 5bd3a844..9d556712 100644
--- a/src/PostIt/PostIt/ViewModels/Settings.cs
+++ b/src/PostIt/PostIt/ViewModels/Settings.cs
@@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
using System;
using System.Collections.Generic;
using System.IO;
+using System.Net.Http;
using System.Text.Json;
using System.Threading;
@@ -182,6 +183,16 @@ public partial class Settings : ViewModelBase
// PKCE is enabled by default when no client_secret is provided.
};
+ if (IsDevelopmentEnvironment())
+ {
+ // Dev only: allow local/self-signed TLS for discovery/token
+ // endpoints when the machine does not trust a custom root.
+ options.BackchannelHandler = new HttpClientHandler
+ {
+ ServerCertificateCustomValidationCallback = (_, _, _, _) => true
+ };
+ }
+
if (browser is not null)
options.Browser = browser;
@@ -231,6 +242,14 @@ public partial class Settings : ViewModelBase
}
}
+ private static bool IsDevelopmentEnvironment()
+ {
+ return string.Equals(
+ Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"),
+ "Development",
+ StringComparison.OrdinalIgnoreCase);
+ }
+
internal void Load()
{
if (Loaded) return;
From 13964b9f7f2de228aac7966d1bf8d976eea78d83 Mon Sep 17 00:00:00 2001
From: Paul Schneider
Date: Sun, 12 Jul 2026 16:07:28 +0100
Subject: [PATCH 002/128] org: show full error details in development
---
src/Yavsc.Org/Views/Shared/Error.cshtml | 39 +++++++++++++++++++++----
1 file changed, 33 insertions(+), 6 deletions(-)
diff --git a/src/Yavsc.Org/Views/Shared/Error.cshtml b/src/Yavsc.Org/Views/Shared/Error.cshtml
index ed29d328..49c7539f 100644
--- a/src/Yavsc.Org/Views/Shared/Error.cshtml
+++ b/src/Yavsc.Org/Views/Shared/Error.cshtml
@@ -1,14 +1,41 @@
-@model ErrorViewModel
+@using Microsoft.AspNetCore.Hosting
+@using Yavsc.Models
+@inject IWebHostEnvironment Env
+@model object
@{
ViewBag.Title = "Error";
}
Error.
-An error occurred while processing your request.
-@if (Model!=null) if (Model.ShowRequestId)
+@if (Env.IsDevelopment())
{
-
- Request ID: @Model.RequestId
-
+ An unhandled exception occurred while processing your request.
+ if (Model is Exception exception)
+ {
+ @exception.ToString()
+ }
+ else if (Model is ErrorViewModel errorViewModel && !string.IsNullOrWhiteSpace(errorViewModel.Description))
+ {
+ @errorViewModel.Description
+ }
+}
+else
+{
+ An error occurred while processing your request.
+
+ if (Model is ErrorViewModel errorViewModel)
+ {
+ if (errorViewModel.ShowRequestId)
+ {
+
+ Request ID: @errorViewModel.RequestId
+
+ }
+
+ if (!string.IsNullOrWhiteSpace(errorViewModel.Description))
+ {
+ @errorViewModel.Description
+ }
+ }
}
From 3d80a3f2a17af9aa0ffca960c478b50ae02e95e2 Mon Sep 17 00:00:00 2001
From: Paul Schneider
Date: Sun, 12 Jul 2026 16:14:37 +0100
Subject: [PATCH 003/128] Post logout redirect uri
---
.vscode/settings.json | 1 +
src/PostIt/PostIt/ViewModels/Settings.cs | 2 +-
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 6e22cb5e..dc0a5a77 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -14,6 +14,7 @@
"envsubst",
"Newtonsoft",
"Npgsql",
+ "PKCE",
"postit",
"pschneider",
"SLNDIR",
diff --git a/src/PostIt/PostIt/ViewModels/Settings.cs b/src/PostIt/PostIt/ViewModels/Settings.cs
index 9d556712..890ca15c 100644
--- a/src/PostIt/PostIt/ViewModels/Settings.cs
+++ b/src/PostIt/PostIt/ViewModels/Settings.cs
@@ -179,7 +179,7 @@ public partial class Settings : ViewModelBase
RedirectUri = Authentication.RedirectUri,
Scope = string.Join(' ', MergeScopes(this.Authentication.Scopes)),
TokenClientCredentialStyle = IdentityModel.Client.ClientCredentialStyle.PostBody,
- PostLogoutRedirectUri = "https//yavsc.pschneider.fr",
+ PostLogoutRedirectUri = Authentication.Authority,
// PKCE is enabled by default when no client_secret is provided.
};
From 9622dbeaf2c901585364ed276f2cd33d80cb15da Mon Sep 17 00:00:00 2001
From: Paul Schneider
Date: Sun, 12 Jul 2026 16:26:43 +0100
Subject: [PATCH 004/128] Hsts
---
.vscode/settings.json | 1 +
1 file changed, 1 insertion(+)
diff --git a/.vscode/settings.json b/.vscode/settings.json
index dc0a5a77..16bbe483 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -12,6 +12,7 @@
"DOTNET",
"ecdsa",
"envsubst",
+ "Hsts",
"Newtonsoft",
"Npgsql",
"PKCE",
From fda43ba2d1a0fbcb1fde803a0d87aceecb3530ce Mon Sep 17 00:00:00 2001
From: Paul Schneider
Date: Sun, 12 Jul 2026 16:27:13 +0100
Subject: [PATCH 005/128] Hsts
---
src/Yavsc.Org/Extensions/HostingExtensions.cs | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/Yavsc.Org/Extensions/HostingExtensions.cs b/src/Yavsc.Org/Extensions/HostingExtensions.cs
index a494a05b..74a1a53e 100644
--- a/src/Yavsc.Org/Extensions/HostingExtensions.cs
+++ b/src/Yavsc.Org/Extensions/HostingExtensions.cs
@@ -972,7 +972,8 @@ public static class HostingExtensions
else
{
app.UseExceptionHandler("/Home/Error");
- logger.LogInformation("Running in production mode. Ensure the database is migrated.");
+ app.UseHsts();
+ logger.LogInformation("⨝ Running in production mode. Ensure the database is migrated.");
await app.MigrateDatabaseAsync();
}
From 786016344b0a63b2f105e14cb96bf5bd3b25055a Mon Sep 17 00:00:00 2001
From: Paul Schneider
Date: Sun, 12 Jul 2026 17:56:23 +0100
Subject: [PATCH 006/128] refacto error handling
---
.../Accounting/AccountController.cs | 10 ++--
.../Accounting/ManageController.cs | 14 -----
.../Controllers/Consent/ConsentController.cs | 17 +++---
.../Controllers/Device/DeviceController.cs | 20 ++++++-
src/Yavsc.Org/Controllers/HomeController.cs | 56 +++++++++++++++----
src/Yavsc.Org/Extensions/HostingExtensions.cs | 1 -
src/Yavsc.Org/Helpers/ErrorViewHelpers.cs | 52 +++++++++++++++++
src/Yavsc.Server/Models/ErrorViewModel.cs | 1 +
8 files changed, 126 insertions(+), 45 deletions(-)
create mode 100644 src/Yavsc.Org/Helpers/ErrorViewHelpers.cs
diff --git a/src/Yavsc.Org/Controllers/Accounting/AccountController.cs b/src/Yavsc.Org/Controllers/Accounting/AccountController.cs
index e8a63163..43565442 100644
--- a/src/Yavsc.Org/Controllers/Accounting/AccountController.cs
+++ b/src/Yavsc.Org/Controllers/Accounting/AccountController.cs
@@ -791,12 +791,12 @@ IHtmlLocalizerFactory htmlLocalizerFactory,
{
if (userId == null || code == null)
{
- return View("Error");
+ return this.ErrorView("Error: userId or code is null.");
}
var user = await _userManager.FindByIdAsync(userId);
if (user == null)
{
- return View("Error");
+ return this.ErrorView("Error: user not found.");
}
IdentityResult result = null;
try
@@ -819,12 +819,12 @@ IHtmlLocalizerFactory htmlLocalizerFactory,
{
if (userId == null || code == null)
{
- return View("Error");
+ return this.ErrorView("Error: userId or code is null.");
}
var user = await _userManager.FindByIdAsync(userId);
if (user == null)
{
- return View("Error");
+ return this.ErrorView("Error: user not found.");
}
bool result = false;
try
@@ -837,7 +837,7 @@ IHtmlLocalizerFactory htmlLocalizerFactory,
_logger.LogError(ex.StackTrace);
_logger.LogError(ex.Message);
}
- return View(result ? "EmailConfirmed" : "Error");
+ return result ? View("EmailConfirmed") : this.ErrorView("Error confirming two factor token.");
}
//
diff --git a/src/Yavsc.Org/Controllers/Accounting/ManageController.cs b/src/Yavsc.Org/Controllers/Accounting/ManageController.cs
index 7419c889..43e11cd2 100644
--- a/src/Yavsc.Org/Controllers/Accounting/ManageController.cs
+++ b/src/Yavsc.Org/Controllers/Accounting/ManageController.cs
@@ -1,6 +1,5 @@
using System.Security.Claims;
-using System.IO;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
@@ -490,12 +489,7 @@ namespace Yavsc.Controllers
: message == ManageMessageId.Error ? "An error has occurred."
: "";
var user = await GetCurrentUserAsync();
- if (user == null)
- {
- return View("Error");
- }
var userLogins = await _userManager.GetLoginsAsync(user);
-
ViewBag.ShowRemoveButton = user.PasswordHash != null || userLogins.Count > 1;
return View(new ManageLoginsViewModel
@@ -522,15 +516,7 @@ namespace Yavsc.Controllers
public async Task LinkLoginCallback()
{
var user = await GetCurrentUserAsync();
- if (user == null)
- {
- return View("Error");
- }
var info = await _signInManager.GetExternalLoginInfoAsync(User.GetUserId());
- if (info == null)
- {
- return RedirectToAction(nameof(ManageLogins), new { Message = ManageMessageId.Error });
- }
var result = await _userManager.AddLoginAsync(user, info);
var message = result.Succeeded ? ManageMessageId.AddLoginSuccess : ManageMessageId.Error;
return RedirectToAction(nameof(ManageLogins), new { Message = message });
diff --git a/src/Yavsc.Org/Controllers/Consent/ConsentController.cs b/src/Yavsc.Org/Controllers/Consent/ConsentController.cs
index 15c4a93b..b7b6eff8 100644
--- a/src/Yavsc.Org/Controllers/Consent/ConsentController.cs
+++ b/src/Yavsc.Org/Controllers/Consent/ConsentController.cs
@@ -16,6 +16,7 @@ using System.Collections.Generic;
using System;
using Yavsc;
using Yavsc.Extensions;
+using Yavsc.Models;
namespace IdentityServerHost.Quickstart.UI
{
@@ -53,10 +54,11 @@ namespace IdentityServerHost.Quickstart.UI
{
return View("Index", vm);
}
-
- return View("Error");
+ return this.ErrorView("No consent request matching request: " + returnUrl);
}
+
+
///
/// Handles the consent screen postback
///
@@ -88,8 +90,8 @@ namespace IdentityServerHost.Quickstart.UI
{
return View("Index", result.ViewModel);
}
-
- return View("Error");
+ return this.ErrorView($"ReturnUrl: {model}, result: {result}" );
+
}
/*****************************************/
@@ -170,11 +172,6 @@ namespace IdentityServerHost.Quickstart.UI
{
return CreateConsentViewModel(model, returnUrl, request);
}
- else
- {
- _logger.LogError("No consent request matching request: {0}", returnUrl);
- }
-
return null;
}
@@ -199,7 +196,7 @@ namespace IdentityServerHost.Quickstart.UI
vm.IdentityScopes = request.ValidatedResources.Resources.IdentityResources.Select(x => CreateScopeViewModel(x, vm.ScopesConsented.Contains(x.Name) || model == null)).ToArray();
var apiScopes = new List();
- foreach(var parsedScope in request.ValidatedResources.ParsedScopes)
+ foreach (var parsedScope in request.ValidatedResources.ParsedScopes)
{
var apiScope = request.ValidatedResources.Resources.FindApiScope(parsedScope.ParsedName);
if (apiScope != null)
diff --git a/src/Yavsc.Org/Controllers/Device/DeviceController.cs b/src/Yavsc.Org/Controllers/Device/DeviceController.cs
index 5e0c7780..2e516aa6 100644
--- a/src/Yavsc.Org/Controllers/Device/DeviceController.cs
+++ b/src/Yavsc.Org/Controllers/Device/DeviceController.cs
@@ -16,6 +16,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
+using Yavsc.Models;
using Yavsc.Models.Access;
namespace Yavsc.Controllers
@@ -49,7 +50,7 @@ namespace Yavsc.Controllers
if (string.IsNullOrWhiteSpace(userCode)) return View("UserCodeCapture");
var vm = await BuildViewModelAsync(userCode);
- if (vm == null) return View("Error");
+ if (vm == null) return this.ErrorView($"ViewModel is null! userCodeParamName: {userCodeParamName}, userCode: {userCode}" );;
vm.ConfirmUserCode = true;
return View("UserCodeConfirmation", vm);
@@ -60,7 +61,7 @@ namespace Yavsc.Controllers
public async Task UserCodeCapture(string userCode)
{
var vm = await BuildViewModelAsync(userCode);
- if (vm == null) return View("Error");
+ if (vm == null) return this.ErrorView($"UserCodeCapture: ViewModel is null! userCode: {userCode}" );
return View("UserCodeConfirmation", vm);
}
@@ -72,7 +73,20 @@ namespace Yavsc.Controllers
if (model == null) throw new ArgumentNullException(nameof(model));
var result = await ProcessConsent(model);
- if (result.HasValidationError) return View("Error");
+ if (result.HasValidationError)
+ {
+ if (HttpContext.RequestServices.GetRequiredService().IsDevelopment())
+ {
+ throw new InvalidOperationException("Device Authorization Input validation error: " + result.ValidationError);
+ }
+
+ return View("Error",
+ new ErrorViewModel {
+ RequestId = HttpContext.TraceIdentifier,
+ Description = "Device Authorization Input validation error: " + result.ValidationError
+ }
+ );
+ }
return View("Success");
}
diff --git a/src/Yavsc.Org/Controllers/HomeController.cs b/src/Yavsc.Org/Controllers/HomeController.cs
index 2602a1a3..67c19fed 100644
--- a/src/Yavsc.Org/Controllers/HomeController.cs
+++ b/src/Yavsc.Org/Controllers/HomeController.cs
@@ -15,18 +15,24 @@ namespace Yavsc.Controllers
public class HomeController : Controller
{
readonly ApplicationDbContext _dbContext;
-
+ readonly ILogger _logger;
+ private readonly bool _isDevelopment;
readonly IHtmlLocalizer _localizer;
private SiteSettings siteSettings;
public HomeController(ILogger logger,
IHtmlLocalizer localizer,
ApplicationDbContext context,
- IOptions settingsOptions)
+ IOptions settingsOptions,
+ IWebHostEnvironment env
+ )
{
_localizer = localizer;
_dbContext = context;
siteSettings = settingsOptions.Value;
+ _logger = logger;
+ _isDevelopment = env.IsDevelopment();
+
}
public async Task Index(string id)
@@ -99,18 +105,44 @@ namespace Yavsc.Controllers
public IActionResult Error()
{
- var feature = this.HttpContext.Features.Get();
- if (feature == null) return View();
- var errorType = feature?.Error;
- if (errorType == null) return View();
- if (errorType is NotSupportedException notSupported)
+ if (_isDevelopment)
{
- return View(new ErrorViewModel {
- Description = notSupported.Message,
- RequestId = this.HttpContext.TraceIdentifier
- });
+ _logger.LogInformation(
+ "Home/Error requested in Development. This endpoint is disabled because DeveloperExceptionPage should handle unhandled exceptions.");
+
+ return NotFound(
+ "In Development, /Home/Error is disabled. Unhandled exceptions are rendered by DeveloperExceptionPage.");
}
- return View("~/Views/Shared/Error.cshtml", feature?.Error);
+
+ var errorViewModel = new ErrorViewModel
+ {
+ RequestId = HttpContext.TraceIdentifier
+ };
+
+ var exceptionHandlerPathFeature =
+ HttpContext.Features.Get();
+
+ if (exceptionHandlerPathFeature is null)
+ {
+ _logger.LogWarning(
+ "Home/Error called without IExceptionHandlerPathFeature in non-development environment.");
+
+ return View("~/Views/Shared/Error.cshtml", errorViewModel);
+ }
+
+ if (exceptionHandlerPathFeature?.Error is FileNotFoundException)
+ {
+ errorViewModel.Description = "The file was not found.";
+ }
+
+ if (exceptionHandlerPathFeature?.Path == "/")
+ {
+ errorViewModel.Description ??= string.Empty;
+ errorViewModel.Description += " Page: Home.";
+ }
+
+
+ return View("~/Views/Shared/Error.cshtml", errorViewModel);
}
public IActionResult Status(int id)
{
diff --git a/src/Yavsc.Org/Extensions/HostingExtensions.cs b/src/Yavsc.Org/Extensions/HostingExtensions.cs
index 74a1a53e..b3f90639 100644
--- a/src/Yavsc.Org/Extensions/HostingExtensions.cs
+++ b/src/Yavsc.Org/Extensions/HostingExtensions.cs
@@ -967,7 +967,6 @@ public static class HostingExtensions
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
- await app.MigrateDatabaseAsync();
}
else
{
diff --git a/src/Yavsc.Org/Helpers/ErrorViewHelpers.cs b/src/Yavsc.Org/Helpers/ErrorViewHelpers.cs
new file mode 100644
index 00000000..3d038c3f
--- /dev/null
+++ b/src/Yavsc.Org/Helpers/ErrorViewHelpers.cs
@@ -0,0 +1,52 @@
+using Microsoft.AspNetCore.Mvc;
+using Yavsc.Models;
+
+public static class ErrorViewHelpers
+{
+ public static IActionResult ErrorView(this Controller controller, string message)
+ {
+ var logger = controller.HttpContext.RequestServices.GetRequiredService()
+ .CreateLogger();
+
+ logger.LogError(message);
+ Dictionary dictionary = new Dictionary();
+
+ if (!controller.ModelState.IsValid)
+ {
+ foreach (var modelState in controller.ModelState.Values)
+ {
+ foreach (var error in modelState.Errors)
+ {
+ logger.LogError("ModelState error: {0}", error.ErrorMessage);
+ foreach (var key in controller.ModelState.Keys)
+ {
+ logger.LogError("ModelState key: {0}", key);
+ dictionary.Add(key,
+ string.Join("\n",
+ controller.ModelState[key].Errors.Select( e => e.ErrorMessage).ToArray()));
+ }
+ }
+ }
+ }
+
+ if (controller.HttpContext.Request.Headers.ContainsKey("Accept")
+ && controller.HttpContext.Request.Headers["Accept"].ToString().Contains("application/json"))
+ {
+ return controller.Json(new
+ {
+ RequestId = controller.HttpContext.TraceIdentifier,
+ Description = message,
+ ModelErrors = dictionary
+ });
+ }
+
+ return controller.View("Error",
+ new ErrorViewModel
+ {
+ RequestId = controller.HttpContext.TraceIdentifier,
+ Description = message,
+ ModelErrors = dictionary
+ }
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/Yavsc.Server/Models/ErrorViewModel.cs b/src/Yavsc.Server/Models/ErrorViewModel.cs
index 1b779ead..17819476 100644
--- a/src/Yavsc.Server/Models/ErrorViewModel.cs
+++ b/src/Yavsc.Server/Models/ErrorViewModel.cs
@@ -7,4 +7,5 @@ public class ErrorViewModel
public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
+ public Dictionary ModelErrors { get; set; }
}
From 286c29f4e3594123d286fba7da90a4db9d1d97a8 Mon Sep 17 00:00:00 2001
From: Paul Schneider
Date: Sun, 2 Aug 2026 21:08:25 +0100
Subject: [PATCH 007/128] Navigate to Main Page
---
src/PostIt/PostIt/App.axaml.cs | 22 +++++++++----------
.../PostIt/ViewModels/HomePageViewModel.cs | 5 +++--
src/PostIt/PostIt/Views/HomePage.axaml | 8 +++++++
src/PostIt/PostIt/Views/InitialPage.cs | 0
src/PostIt/PostIt/Views/MainPage.axaml | 3 +--
src/PostIt/PostIt/Views/MainWindow.axaml | 2 +-
6 files changed, 24 insertions(+), 16 deletions(-)
delete mode 100644 src/PostIt/PostIt/Views/InitialPage.cs
diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs
index ca7e051d..1e0afeaf 100644
--- a/src/PostIt/PostIt/App.axaml.cs
+++ b/src/PostIt/PostIt/App.axaml.cs
@@ -25,7 +25,7 @@ public partial class App : Application
/// DataValidationErrors.SetErrors.
///
public IServiceProvider? Services { get; private set; }
-
+ private MainWindow window;
public App()
{
}
@@ -137,7 +137,7 @@ public partial class App : Application
var homePage = provider.GetRequiredService();
homePage.DataContext = provider.GetRequiredService();
- var window = new MainWindow();
+ window = new MainWindow();
window.SessionBanner.DataContext = sessionStatus;
// Build the navigation stack from scratch: HomePage is the
@@ -165,7 +165,7 @@ public partial class App : Application
sessionStatus.LoginSucceeded += () =>
{
var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!;
- _ = PushMainPageAsync(provider, w);
+ _ = PushMainPageAsync();
};
// When the user clicks the "Paramètres" button on the
@@ -196,7 +196,7 @@ public partial class App : Application
_ = w.NavRoot.PushAsync(settingsPage);
};
- window.Opened += async (_, _) => await BootAsync(provider, api, window);
+ window.Opened += async (_, _) => await BootAsync(provider, api);
}
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView)
{
@@ -223,15 +223,14 @@ public partial class App : Application
///
private static async Task BootAsync(
IServiceProvider provider,
- YavscApiClient api,
- MainWindow window)
+ YavscApiClient api)
{
var refreshed = await api.TrySilentLoginAsync().ConfigureAwait(true);
var sessionStatus = provider.GetRequiredService();
sessionStatus.Refresh();
if (!refreshed) return;
- await PushMainPageAsync(provider, window).ConfigureAwait(true);
+ await PushMainPageAsync().ConfigureAwait(true);
}
///
@@ -241,12 +240,13 @@ public partial class App : Application
/// (interactive login from the banner). Pulled out as a helper so
/// the two callers can't drift apart.
///
- private static async Task PushMainPageAsync(IServiceProvider provider, MainWindow window)
+ public static async Task PushMainPageAsync()
{
- var mainVm = provider.GetRequiredService();
- var mainPage = provider.GetRequiredService();
+ var app = (App)Current;
+ var mainVm = app.Services.GetRequiredService();
+ var mainPage = app.Services.GetRequiredService();
mainPage.DataContext = mainVm;
- await window.NavRoot.PushAsync(mainPage).ConfigureAwait(true);
+ await app.window.FindControl("NavRoot").PushAsync(mainPage).ConfigureAwait(true);
}
private bool TryHandOffCustomSchemeUrl()
diff --git a/src/PostIt/PostIt/ViewModels/HomePageViewModel.cs b/src/PostIt/PostIt/ViewModels/HomePageViewModel.cs
index 8d5b3a17..c11e396a 100644
--- a/src/PostIt/PostIt/ViewModels/HomePageViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/HomePageViewModel.cs
@@ -1,6 +1,7 @@
+using CommunityToolkit.Mvvm.Input;
using PostIt;
using PostIt.Services;
-using PostIt.ViewModels;
+namespace PostIt.ViewModels;
public class HomePageViewModel : ViewModelBase
{
@@ -22,7 +23,7 @@ public class HomePageViewModel : ViewModelBase
Api = api;
Settings = settings;
}
-
+ public RelayCommand OpenBlogs { get; set; } = new RelayCommand(() => App.PushMainPageAsync());
///
/// Avalonia designer constructor. Builds a self-contained VM
/// with a freshly-constructed Settings so the XAML preview can
diff --git a/src/PostIt/PostIt/Views/HomePage.axaml b/src/PostIt/PostIt/Views/HomePage.axaml
index 82473542..7eb6dbb3 100644
--- a/src/PostIt/PostIt/Views/HomePage.axaml
+++ b/src/PostIt/PostIt/Views/HomePage.axaml
@@ -1,7 +1,12 @@
+
+
+
@@ -9,5 +14,8 @@
FontSize="22"
FontWeight="SemiBold"
HorizontalAlignment="Center"/>
+
diff --git a/src/PostIt/PostIt/Views/InitialPage.cs b/src/PostIt/PostIt/Views/InitialPage.cs
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/PostIt/PostIt/Views/MainPage.axaml b/src/PostIt/PostIt/Views/MainPage.axaml
index e2b178e8..5c42e27c 100644
--- a/src/PostIt/PostIt/Views/MainPage.axaml
+++ b/src/PostIt/PostIt/Views/MainPage.axaml
@@ -27,7 +27,6 @@
-
@@ -93,4 +92,4 @@
-
\ No newline at end of file
+
diff --git a/src/PostIt/PostIt/Views/MainWindow.axaml b/src/PostIt/PostIt/Views/MainWindow.axaml
index 32dd038a..a261c5a3 100644
--- a/src/PostIt/PostIt/Views/MainWindow.axaml
+++ b/src/PostIt/PostIt/Views/MainWindow.axaml
@@ -20,7 +20,7 @@
-->
+ DockPanel.Dock="Bottom"/>
From 3b21a12c20ff5a393cdcf9e59110c1e17cceb96d Mon Sep 17 00:00:00 2001
From: Paul Schneider
Date: Sun, 2 Aug 2026 23:02:54 +0100
Subject: [PATCH 008/128] =?UTF-8?q?Le=20cr=C3=A9ateur=20vient=20de=20l'aut?=
=?UTF-8?q?hentification,=20donc=20on=20ne=20le=20prend=20pas=20du=20post?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.vscode/tasks.json | 27 ++++++++++++-------
.../Controllers/BlogApiController.cs | 3 ++-
src/Yavsc.Server/Services/BlogSpotService.cs | 6 +++--
3 files changed, 23 insertions(+), 13 deletions(-)
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index c384ec79..e45a9921 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -13,16 +13,17 @@
"isBackground": true
},
{
- "label": "build-web",
+ "label": "test blogs backend",
"type": "process",
"problemMatcher": ["$msCompile"],
"command": "dotnet",
- "args": ["build"],
+ "args": ["test"],
"options": {
- "cwd": "src/Yavsc.Org"
+ "cwd": "src/Yavsc.Blogs.Tests"
},
"group": {
- "kind": "build"
+ "kind": "test",
+ "isDefault": false
}
},
{
@@ -40,17 +41,23 @@
"isBackground": true
},
{
- "label": "build-web",
+ "label": "test blogs",
"type": "process",
"problemMatcher": ["$msCompile"],
"command": "dotnet",
- "args": ["build"],
- "runOptions": {},
+ "args": ["test"],
+ "runOptions": {
+ "instanceLimit": 1
+ },
"options": {
- "cwd": "src/Yavsc.Web"
+ "cwd": "src/Yavsc.Blogs",
+ "env": {
+ "DOTNET_CLI_UI_LANGUAGE": "en-US",
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
},
"group": {
- "kind": "build"
+ "kind": "test"
},
"isBackground": true,
"presentation": {
@@ -82,7 +89,7 @@
],
"problemMatcher": "$msCompile",
"runOptions": {
-
+
}
}
diff --git a/src/Yavsc.Blogs/Controllers/BlogApiController.cs b/src/Yavsc.Blogs/Controllers/BlogApiController.cs
index 6a41b4ee..69b37e60 100644
--- a/src/Yavsc.Blogs/Controllers/BlogApiController.cs
+++ b/src/Yavsc.Blogs/Controllers/BlogApiController.cs
@@ -114,7 +114,8 @@ namespace Yavsc.Blogs.Controllers
var files = Request.HasFormContentType
? Request.Form.Files
: (IFormFileCollection)new FormFileCollection();
- var post = blogSpotService.Create(User.GetUserId(), blog, files);
+ var uid = User.GetUserId();
+ var post = blogSpotService.Create(uid, blog, files);
return CreatedAtRoute("GetBlog", new { id = post.Id }, post);
}
diff --git a/src/Yavsc.Server/Services/BlogSpotService.cs b/src/Yavsc.Server/Services/BlogSpotService.cs
index 8dfa9e33..a8b52d0d 100644
--- a/src/Yavsc.Server/Services/BlogSpotService.cs
+++ b/src/Yavsc.Server/Services/BlogSpotService.cs
@@ -29,6 +29,8 @@ public class BlogSpotService
public BlogPost Create(string userId, BlogPost post, IFormFileCollection files)
{
// Sauvegarder le post d'abord pour obtenir son ID
+ // Le créateur vient de l'authentification, donc on ne le prend pas du post
+ post.AuthorId = userId;
_context.BlogSpot.Add(post);
_context.SaveChanges(userId);
@@ -94,9 +96,9 @@ public class BlogSpotService
if (!auth.Succeeded)
{
throw new AuthorizationFailureException(auth);
- }
+ }
var pub = await _context.blogSpotPublications.AnyAsync(x => x.BlogpostId == blog.Id);
-
+
return new BlogPostEditViewModel(blog, pub);
}
From 23262bcc17890383d62dcb079f86148301cbc8fc Mon Sep 17 00:00:00 2001
From: Paul Schneider
Date: Sun, 2 Aug 2026 23:23:00 +0100
Subject: [PATCH 009/128] ci: publish APK to GitHub release on v* tag
Adds a publish-release job that triggers only on tag pushes (refs/tags/v*).
It reuses the APK artifact uploaded by apk-deploy, publishes a GitHub
release via softprops/action-gh-release, and attaches the APK.
Result: a stable permalink to the latest APK at
https://github.com///releases/latest/download/PostIt.Android.apk
---
.github/workflows/docker-publish-android.yml | 31 ++++++++++++++++++++
1 file changed, 31 insertions(+)
diff --git a/.github/workflows/docker-publish-android.yml b/.github/workflows/docker-publish-android.yml
index 25c3aa2d..98237136 100644
--- a/.github/workflows/docker-publish-android.yml
+++ b/.github/workflows/docker-publish-android.yml
@@ -4,8 +4,15 @@ on:
push:
branches:
- main
+ tags:
+ - 'v*'
workflow_dispatch:
+# softprops/action-gh-release a besoin de contents: write
+# pour publier une release + uploader un asset.
+permissions:
+ contents: write
+
jobs:
apk-deploy:
runs-on: ubuntu-latest
@@ -34,3 +41,27 @@ jobs:
path: ./PostIt.Android.apk
retention-days: 7
+ publish-release:
+ # Uniquement déclenché par un tag v*. Le job apk-deploy tourne en
+ # parallèle, on partage l'artefact entre jobs.
+ if: startsWith(github.ref, 'refs/tags/v')
+ needs: apk-deploy
+ runs-on: ubuntu-latest
+ steps:
+ - name: Récupérer l'APK depuis l'artefact
+ uses: actions/download-artifact@v7
+ with:
+ name: application-apk-release
+ path: ./
+
+ - name: Publier la release GitHub et uploader l'APK
+ uses: softprops/action-gh-release@v2
+ with:
+ # Le nom de fichier final dans la release. C'est ce qui
+ # apparaîtra dans l'asset et donc dans le permalink :
+ # https://github.com///releases/latest/download/PostIt.Android.apk
+ files: ./PostIt.Android.apk
+ # generate_release_notes: true -> évite d'avoir à maintenir
+ # le corps de release à la main. Décommente si tu veux.
+ # generate_release_notes: true
+
From b25e0e842e8dd9fac4d79d759e2eaebcb3a246bd Mon Sep 17 00:00:00 2001
From: Paul Schneider
Date: Mon, 3 Aug 2026 01:32:59 +0100
Subject: [PATCH 010/128] refacto blogPost
---
src/PostIt.Tests/BlogApiTestFakes.cs | 1 +
src/PostIt/PostIt/App.axaml.cs | 30 +++++++-------
src/PostIt/PostIt/Models/BlogPost.cs | 41 ++++++++++++++-----
.../PostIt/ViewModels/HomePageViewModel.cs | 11 ++++-
src/PostIt/PostIt/Views/MainPage.axaml.cs | 2 +-
src/Yavsc.Abstract/Blogspot/IBlog.cs | 19 ---------
src/Yavsc.Abstract/Blogspot/IBlogPost.cs | 15 +++++++
.../Blogspot/IBlogPostPayLoad.cs | 9 ++++
.../Controllers/BlogApiController.cs | 1 +
src/Yavsc.Server/Models/Blog/BlogPost.cs | 7 ++--
src/Yavsc.Server/Services/BlogSpotService.cs | 1 +
11 files changed, 85 insertions(+), 52 deletions(-)
delete mode 100644 src/Yavsc.Abstract/Blogspot/IBlog.cs
create mode 100644 src/Yavsc.Abstract/Blogspot/IBlogPost.cs
create mode 100644 src/Yavsc.Abstract/Blogspot/IBlogPostPayLoad.cs
diff --git a/src/PostIt.Tests/BlogApiTestFakes.cs b/src/PostIt.Tests/BlogApiTestFakes.cs
index 9ec10f89..755ce105 100644
--- a/src/PostIt.Tests/BlogApiTestFakes.cs
+++ b/src/PostIt.Tests/BlogApiTestFakes.cs
@@ -1,6 +1,7 @@
using PostIt.Models;
using PostIt.Services;
using PostIt.ViewModels;
+using Yavsc.Models;
namespace PostIt.Tests;
diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs
index 1e0afeaf..b5740f2f 100644
--- a/src/PostIt/PostIt/App.axaml.cs
+++ b/src/PostIt/PostIt/App.axaml.cs
@@ -24,7 +24,7 @@ public partial class App : Application
/// binding sink with a cross-thread exception inside
/// DataValidationErrors.SetErrors.
///
- public IServiceProvider? Services { get; private set; }
+ public IServiceProvider? ServiceProvider { get; private set; }
private MainWindow window;
public App()
{
@@ -91,19 +91,17 @@ public partial class App : Application
services.AddSingleton(sessionStatus);
services.AddTransient();
- var provider = services.BuildServiceProvider();
+ ServiceProvider = services.BuildServiceProvider();
// Bind the canonical Settings to the static accessor so any
// code path that can't easily take a constructor parameter
// (designer surfaces, Avalonia data templates) still gets
// the same instance the rest of the app is using. Idempotent:
// re-binding from a second App boot (tests) is a no-op.
- Settings.BindToServiceProvider(provider);
-
- Services = provider;
+ Settings.BindToServiceProvider(ServiceProvider);
DataTemplates.Clear();
- DataTemplates.Add(new ViewLocator(provider));
+ DataTemplates.Add(new ViewLocator(ServiceProvider));
// Wire the Settings singleton onto the SettingsPage singleton
// once, at composition time. The page is registered as a
@@ -113,7 +111,7 @@ public partial class App : Application
// DataContext, and the TwoWay bindings inside the page keep
// mutating the same in-memory Settings instance that the rest
// of the app reads (OidcClientOptions construction, etc.).
- provider.GetRequiredService().DataContext = settings;
+ ServiceProvider.GetRequiredService().DataContext = settings;
// Settings.DarkMode was previously a dead field: it round-
// tripped through the settings file and the SettingsPage
@@ -134,8 +132,8 @@ public partial class App : Application
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
- var homePage = provider.GetRequiredService();
- homePage.DataContext = provider.GetRequiredService();
+ var homePage = ServiceProvider.GetRequiredService();
+ homePage.DataContext = ServiceProvider.GetRequiredService();
window = new MainWindow();
window.SessionBanner.DataContext = sessionStatus;
@@ -155,8 +153,8 @@ public partial class App : Application
{
var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!;
var nav = w.NavRoot;
- var hp = provider.GetRequiredService();
- hp.DataContext = provider.GetRequiredService();
+ var hp = ServiceProvider.GetRequiredService();
+ hp.DataContext = ServiceProvider.GetRequiredService();
_ = nav.PopToRootAsync();
};
@@ -187,7 +185,7 @@ public partial class App : Application
sessionStatus.OpenSettingsRequested += () =>
{
var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!;
- var settingsPage = provider.GetRequiredService();
+ var settingsPage = ServiceProvider.GetRequiredService();
var stack = w.NavRoot.NavigationStack;
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], settingsPage))
{
@@ -196,13 +194,13 @@ public partial class App : Application
_ = w.NavRoot.PushAsync(settingsPage);
};
- window.Opened += async (_, _) => await BootAsync(provider, api);
+ window.Opened += async (_, _) => await BootAsync(ServiceProvider, api);
}
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView)
{
singleView.MainView = new MainWindow
{
- DataContext = provider.GetRequiredService()
+ DataContext = ServiceProvider.GetRequiredService()
};
}
}
@@ -243,8 +241,8 @@ public partial class App : Application
public static async Task PushMainPageAsync()
{
var app = (App)Current;
- var mainVm = app.Services.GetRequiredService();
- var mainPage = app.Services.GetRequiredService();
+ var mainVm = app.ServiceProvider.GetRequiredService();
+ var mainPage = app.ServiceProvider.GetRequiredService();
mainPage.DataContext = mainVm;
await app.window.FindControl("NavRoot").PushAsync(mainPage).ConfigureAwait(true);
}
diff --git a/src/PostIt/PostIt/Models/BlogPost.cs b/src/PostIt/PostIt/Models/BlogPost.cs
index 7867eb02..e62fcea2 100644
--- a/src/PostIt/PostIt/Models/BlogPost.cs
+++ b/src/PostIt/PostIt/Models/BlogPost.cs
@@ -1,16 +1,37 @@
using System;
+using Yavsc.Abstract.Identity;
+using Yavsc.Abstract.Identity.Security;
+using Yavsc.Blogspot;
namespace PostIt.Models;
-public class BlogPost
+public class BlogPost : IBlogPost
{
- public long Id { get; set; }
- public string Title { get; set; } = string.Empty;
- public string? Article { get; set; }
- public string? Photo { get; set; }
- public string? AuthorId { get; set; }
- public DateTime DateCreated { get; set; }
- public string? UserCreated { get; set; }
- public DateTime DateModified { get; set; }
- public string? UserModified { get; set; }
+ public string AuthorId { get; set; }
+
+ public IApplicationUser Author { get; set; }
+
+ public string Article { get; set ; }
+ public string Photo { get; set ; }
+ public long Id { get; set ; }
+ public DateTime DateCreated { get; set ; }
+ public string UserCreated { get; set ; }
+ public DateTime DateModified { get; set ; }
+ public string UserModified { get; set ; }
+ public string Title { get; set ; }
+
+ public bool AuthorizeCircle(long circleId)
+ {
+ throw new NotImplementedException();
+ }
+
+ public ICircleAuthorization[] GetACL()
+ {
+ throw new NotImplementedException();
+ }
+
+ public string[] GetTags()
+ {
+ throw new NotImplementedException();
+ }
}
diff --git a/src/PostIt/PostIt/ViewModels/HomePageViewModel.cs b/src/PostIt/PostIt/ViewModels/HomePageViewModel.cs
index c11e396a..876f862c 100644
--- a/src/PostIt/PostIt/ViewModels/HomePageViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/HomePageViewModel.cs
@@ -1,4 +1,5 @@
using CommunityToolkit.Mvvm.Input;
+using Microsoft.Extensions.DependencyInjection;
using PostIt;
using PostIt.Services;
namespace PostIt.ViewModels;
@@ -7,6 +8,7 @@ public class HomePageViewModel : ViewModelBase
{
public YavscApiClient Api { get; }
public Settings Settings { get; }
+ public SessionStatusViewModel SessionStatus { get; }
private string _welcomeText = "Welcome to PostIt!";
public string WelcomeText
@@ -18,10 +20,12 @@ public class HomePageViewModel : ViewModelBase
public override bool CanNavigateNext { get => true; protected set => throw new System.NotImplementedException(); }
public override bool CanNavigatePrevious { get => false; protected set => throw new System.NotImplementedException(); }
- public HomePageViewModel(YavscApiClient api, Settings settings)
+ public HomePageViewModel(YavscApiClient api, Settings settings, SessionStatusViewModel sessionStatus)
{
Api = api;
Settings = settings;
+ SessionStatus = sessionStatus;
+
}
public RelayCommand OpenBlogs { get; set; } = new RelayCommand(() => App.PushMainPageAsync());
///
@@ -33,5 +37,8 @@ public class HomePageViewModel : ViewModelBase
/// (thread-safe dispatcher marshalling on PropertyChanged) — a
/// designer-only duplicate instance is therefore harmless.
///
- public HomePageViewModel() : this(null!, new Settings()) { }
+ public HomePageViewModel() : this(null!, new Settings(), new SessionStatusViewModel())
+ {
+
+ }
}
diff --git a/src/PostIt/PostIt/Views/MainPage.axaml.cs b/src/PostIt/PostIt/Views/MainPage.axaml.cs
index 1535d2f4..6a907a01 100644
--- a/src/PostIt/PostIt/Views/MainPage.axaml.cs
+++ b/src/PostIt/PostIt/Views/MainPage.axaml.cs
@@ -28,7 +28,7 @@ public partial class MainPage : ContentPage
// Resolve via the App's DI container so the page gets
// the canonical services (Api client, settings, ...).
var app = Application.Current as App;
- var services = app?.Services;
+ var services = app?.ServiceProvider;
if (services is null) return;
var page = services.GetRequiredService();
diff --git a/src/Yavsc.Abstract/Blogspot/IBlog.cs b/src/Yavsc.Abstract/Blogspot/IBlog.cs
deleted file mode 100644
index 86efc8e3..00000000
--- a/src/Yavsc.Abstract/Blogspot/IBlog.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-
-
-
-using Yavsc.Abstract.Identity;
-
-namespace Yavsc
-{
- public interface IBlogPostPayLoad
- {
- string Article { get; set; }
- string Photo { get; set; }
-
- }
- public interface IBlogPost : IBlogPostPayLoad, ITrackedEntity, IIdentified, ITitle
- {
- string AuthorId { get; set; }
- IApplicationUser Author { get; }
- }
-}
diff --git a/src/Yavsc.Abstract/Blogspot/IBlogPost.cs b/src/Yavsc.Abstract/Blogspot/IBlogPost.cs
new file mode 100644
index 00000000..691e03f2
--- /dev/null
+++ b/src/Yavsc.Abstract/Blogspot/IBlogPost.cs
@@ -0,0 +1,15 @@
+
+
+
+using Yavsc.Abstract.Identity;
+using Yavsc.Abstract.Identity.Security;
+using Yavsc.Interfaces;
+
+namespace Yavsc.Blogspot
+{
+ public interface IBlogPost : IBlogPostPayLoad, ICircleAuthorized, ITaggable, ITrackedEntity, IIdentified, ITitle
+ {
+ string AuthorId { get; set; }
+ IApplicationUser Author { get; }
+ }
+}
diff --git a/src/Yavsc.Abstract/Blogspot/IBlogPostPayLoad.cs b/src/Yavsc.Abstract/Blogspot/IBlogPostPayLoad.cs
new file mode 100644
index 00000000..d8aeb4fe
--- /dev/null
+++ b/src/Yavsc.Abstract/Blogspot/IBlogPostPayLoad.cs
@@ -0,0 +1,9 @@
+namespace Yavsc.Blogspot
+{
+ public interface IBlogPostPayLoad
+ {
+ string Article { get; set; }
+ string Photo { get; set; }
+
+ }
+}
diff --git a/src/Yavsc.Blogs/Controllers/BlogApiController.cs b/src/Yavsc.Blogs/Controllers/BlogApiController.cs
index 69b37e60..23d71742 100644
--- a/src/Yavsc.Blogs/Controllers/BlogApiController.cs
+++ b/src/Yavsc.Blogs/Controllers/BlogApiController.cs
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
+using Yavsc.Blogspot;
using Yavsc.Models.Blog;
using Yavsc.Server.Exceptions;
using Yavsc.Server.Helpers;
diff --git a/src/Yavsc.Server/Models/Blog/BlogPost.cs b/src/Yavsc.Server/Models/Blog/BlogPost.cs
index eb58cb62..e1b44603 100644
--- a/src/Yavsc.Server/Models/Blog/BlogPost.cs
+++ b/src/Yavsc.Server/Models/Blog/BlogPost.cs
@@ -3,15 +3,14 @@ using System.ComponentModel.DataAnnotations.Schema;
using Newtonsoft.Json;
using Yavsc.Abstract.Identity;
using Yavsc.Abstract.Identity.Security;
-using Yavsc.Interfaces;
using Yavsc.Models.Access;
using Yavsc.Models.Relationship;
+using Yavsc.Blogspot;
namespace Yavsc.Models.Blog
{
-
- public class BlogPost :
- IBlogPost, ICircleAuthorized, ITaggable
+
+ public class BlogPost : IBlogPost
{
[Key(), DatabaseGenerated(DatabaseGeneratedOption.Identity)]
[Display(Name = "Identifiant du post")]
diff --git a/src/Yavsc.Server/Services/BlogSpotService.cs b/src/Yavsc.Server/Services/BlogSpotService.cs
index a8b52d0d..dc02cde2 100644
--- a/src/Yavsc.Server/Services/BlogSpotService.cs
+++ b/src/Yavsc.Server/Services/BlogSpotService.cs
@@ -10,6 +10,7 @@ using Yavsc.Server.Helpers;
using Yavsc.Services;
using Yavsc.ViewModels.Auth;
using Microsoft.AspNetCore.Http;
+using Yavsc.Blogspot;
public class BlogSpotService
{
From 3744d9ae9cc9459bea2cfe57eca9e631547a827b Mon Sep 17 00:00:00 2001
From: Paul Schneider
Date: Mon, 3 Aug 2026 01:48:15 +0100
Subject: [PATCH 011/128] Enable blogs on connected status
---
src/PostIt/PostIt/Views/HomePage.axaml | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/PostIt/PostIt/Views/HomePage.axaml b/src/PostIt/PostIt/Views/HomePage.axaml
index 7eb6dbb3..16e66cc0 100644
--- a/src/PostIt/PostIt/Views/HomePage.axaml
+++ b/src/PostIt/PostIt/Views/HomePage.axaml
@@ -16,6 +16,7 @@
HorizontalAlignment="Center"/>
+ HorizontalAlignment="Center"
+ IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
From 7d1cca9df0eb204ba587b50bf1d22ff30ea724e9 Mon Sep 17 00:00:00 2001
From: Paul Schneider
Date: Mon, 3 Aug 2026 02:16:57 +0100
Subject: [PATCH 012/128] build
---
.../Communicating/BlogspotController.cs | 1 +
src/Yavsc.Org/Services/BlogSpotService.cs | 1 +
src/Yavsc.Org/Views/Blogspot/Index.cshtml | 27 ++++++++++---------
src/Yavsc.Org/Views/_ViewImports.cshtml | 1 +
4 files changed, 17 insertions(+), 13 deletions(-)
diff --git a/src/Yavsc.Org/Controllers/Communicating/BlogspotController.cs b/src/Yavsc.Org/Controllers/Communicating/BlogspotController.cs
index f59b8b82..de0c3c2d 100644
--- a/src/Yavsc.Org/Controllers/Communicating/BlogspotController.cs
+++ b/src/Yavsc.Org/Controllers/Communicating/BlogspotController.cs
@@ -7,6 +7,7 @@ using Yavsc.Models.Blog;
using Microsoft.Extensions.Options;
using Yavsc.Server.Exceptions;
using Yavsc.Server.Helpers;
+using Yavsc.Blogspot;
// For more information on enabling Web API for empty projects, visit http://go.microsoft.com/fwlink/?LinkID=397860
diff --git a/src/Yavsc.Org/Services/BlogSpotService.cs b/src/Yavsc.Org/Services/BlogSpotService.cs
index 3700e04f..76858c9c 100644
--- a/src/Yavsc.Org/Services/BlogSpotService.cs
+++ b/src/Yavsc.Org/Services/BlogSpotService.cs
@@ -3,6 +3,7 @@ using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore;
using Yavsc;
+using Yavsc.Blogspot;
using Yavsc.Models;
using Yavsc.Models.Blog;
using Yavsc.Server.Exceptions;
diff --git a/src/Yavsc.Org/Views/Blogspot/Index.cshtml b/src/Yavsc.Org/Views/Blogspot/Index.cshtml
index 1e7321aa..d5dc27c9 100644
--- a/src/Yavsc.Org/Views/Blogspot/Index.cshtml
+++ b/src/Yavsc.Org/Views/Blogspot/Index.cshtml
@@ -1,3 +1,4 @@
+
@model IEnumerable
@{
ViewBag.Title = "Blogs, l'index";
@@ -43,20 +44,20 @@
Create a new article
}
-
+
@{
int maxTextLen = 75;
foreach (var post in Model) {
-
-
+
+
@post.Title
-
+
@post.Article
@Html.DisplayFor(m => post.Author)
@@ -67,20 +68,20 @@
- @if ((await AuthorizationService.AuthorizeAsync(User, post, new ReadPermission())).Succeeded)
+ @if ((await AuthorizationService.AuthorizeAsync(User, post, new ReadPermission())).Succeeded)
{
-
Details
+
Details
}
- else
+ else
{
-
Details
+
Details
}
- @if ((await AuthorizationService.AuthorizeAsync(User, post, new EditPermission())).Succeeded)
+ @if ((await AuthorizationService.AuthorizeAsync(User, post, new EditPermission())).Succeeded)
{
-
Edit
-
-
Delete
-
+
Edit
+
+
Delete
+
}
diff --git a/src/Yavsc.Org/Views/_ViewImports.cshtml b/src/Yavsc.Org/Views/_ViewImports.cshtml
index dd88dabc..79811042 100755
--- a/src/Yavsc.Org/Views/_ViewImports.cshtml
+++ b/src/Yavsc.Org/Views/_ViewImports.cshtml
@@ -1,5 +1,6 @@
@using Microsoft.AspNetCore.Mvc.Localization
@using Yavsc
+@using Yavsc.Blogspot
@using Yavsc.Models
@using Yavsc.Models.Musical;
@using Yavsc.Models.Drawing;
From c8b05a8950c483ee4844c634304c9b9867255945 Mon Sep 17 00:00:00 2001
From: Paul Schneider
Date: Mon, 3 Aug 2026 02:35:50 +0100
Subject: [PATCH 013/128] publish android
---
.github/workflows/docker-publish-android.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/docker-publish-android.yml b/.github/workflows/docker-publish-android.yml
index 98237136..317cda80 100644
--- a/.github/workflows/docker-publish-android.yml
+++ b/.github/workflows/docker-publish-android.yml
@@ -44,7 +44,7 @@ jobs:
publish-release:
# Uniquement déclenché par un tag v*. Le job apk-deploy tourne en
# parallèle, on partage l'artefact entre jobs.
- if: startsWith(github.ref, 'refs/tags/v')
+ if: startsWith(github.ref, 'refs/tags/')
needs: apk-deploy
runs-on: ubuntu-latest
steps:
From 64547840e4a8e4b814a2eda73eda08dd20032d94 Mon Sep 17 00:00:00 2001
From: Paul Schneider
Date: Wed, 5 Aug 2026 21:05:14 +0100
Subject: [PATCH 014/128] refacto BlogPost serialization
---
src/Yavsc.Abstract/Blogspot/IBlogPost.cs | 3 +--
.../Identity/Security/ICircleAuthorized.cs | 8 ++++++--
src/Yavsc.Abstract/Interfaces/Models/ITaggable.cs | 4 ++--
src/Yavsc.Blogs.Tests/BlogApiTests.cs | 3 ++-
src/Yavsc.Blogs/Controllers/BlogApiController.cs | 4 ++--
src/Yavsc.Org.Tests/appsettings.json | 1 +
src/Yavsc.Org/Views/Blogspot/Index.cshtml | 12 ++++++------
src/Yavsc.Server/Models/Blog/BlogPost.cs | 4 ++--
8 files changed, 22 insertions(+), 17 deletions(-)
diff --git a/src/Yavsc.Abstract/Blogspot/IBlogPost.cs b/src/Yavsc.Abstract/Blogspot/IBlogPost.cs
index 691e03f2..5287685d 100644
--- a/src/Yavsc.Abstract/Blogspot/IBlogPost.cs
+++ b/src/Yavsc.Abstract/Blogspot/IBlogPost.cs
@@ -7,9 +7,8 @@ using Yavsc.Interfaces;
namespace Yavsc.Blogspot
{
- public interface IBlogPost : IBlogPostPayLoad, ICircleAuthorized, ITaggable, ITrackedEntity, IIdentified, ITitle
+ public interface IBlogPost : IBlogPostPayLoad, ICircleAuthorized, ITrackedEntity, ITitle
{
- string AuthorId { get; set; }
IApplicationUser Author { get; }
}
}
diff --git a/src/Yavsc.Abstract/Identity/Security/ICircleAuthorized.cs b/src/Yavsc.Abstract/Identity/Security/ICircleAuthorized.cs
index cc108d2e..25c21961 100644
--- a/src/Yavsc.Abstract/Identity/Security/ICircleAuthorized.cs
+++ b/src/Yavsc.Abstract/Identity/Security/ICircleAuthorized.cs
@@ -1,10 +1,14 @@
+using Yavsc.Interfaces;
+
namespace Yavsc.Abstract.Identity.Security
{
- public interface ICircleAuthorized
+ public interface ICircleAuthorized : ITaggable
{
- long Id { get; set; }
+
string AuthorId { get; }
+
bool AuthorizeCircle(long circleId);
+
ICircleAuthorization [] GetACL();
}
diff --git a/src/Yavsc.Abstract/Interfaces/Models/ITaggable.cs b/src/Yavsc.Abstract/Interfaces/Models/ITaggable.cs
index 89a51d30..eb325b6f 100644
--- a/src/Yavsc.Abstract/Interfaces/Models/ITaggable.cs
+++ b/src/Yavsc.Abstract/Interfaces/Models/ITaggable.cs
@@ -1,9 +1,9 @@
namespace Yavsc.Interfaces
{
- public interface ITaggable
+ public interface ITaggable : IIdentified
{
string [] GetTags();
K Id { get; }
}
-}
\ No newline at end of file
+}
diff --git a/src/Yavsc.Blogs.Tests/BlogApiTests.cs b/src/Yavsc.Blogs.Tests/BlogApiTests.cs
index fd64555a..35d67fbe 100644
--- a/src/Yavsc.Blogs.Tests/BlogApiTests.cs
+++ b/src/Yavsc.Blogs.Tests/BlogApiTests.cs
@@ -239,7 +239,8 @@ public sealed class BlogApiTests : IClassFixture
// The list should now be empty.
var listResponse = await http.GetAsync("/api/v1/blog");
- using var doc = JsonDocument.Parse(await listResponse.Content.ReadAsStringAsync());
+ String response = await listResponse.Content.ReadAsStringAsync();
+ using var doc = JsonDocument.Parse(response);
Assert.Equal(0, doc.RootElement.GetArrayLength());
}
diff --git a/src/Yavsc.Blogs/Controllers/BlogApiController.cs b/src/Yavsc.Blogs/Controllers/BlogApiController.cs
index 23d71742..ac7b59df 100644
--- a/src/Yavsc.Blogs/Controllers/BlogApiController.cs
+++ b/src/Yavsc.Blogs/Controllers/BlogApiController.cs
@@ -22,9 +22,9 @@ namespace Yavsc.Blogs.Controllers
// GET: api/BlogApi
[HttpGet]
- public async Task> GetBlogspot(int start = 0, int take = 25)
+ public async Task> GetBlogspot(int start = 0, int take = 25)
{
- return await blogSpotService.Index(User, null, start, take);
+ return (await blogSpotService.Index(User, null, start, take)).Cast();
}
// GET: api/BlogApi/5
diff --git a/src/Yavsc.Org.Tests/appsettings.json b/src/Yavsc.Org.Tests/appsettings.json
index 5f353cd8..ef95fef6 100644
--- a/src/Yavsc.Org.Tests/appsettings.json
+++ b/src/Yavsc.Org.Tests/appsettings.json
@@ -1,6 +1,7 @@
{
"Site": {
"Authority": "https://localhost:5101",
+ "Audience": ["blogs"],
"Title": "Yavsc dev",
"Slogan": "Yavsc : WIP.",
"Banner": "/images/yavsc.png",
diff --git a/src/Yavsc.Org/Views/Blogspot/Index.cshtml b/src/Yavsc.Org/Views/Blogspot/Index.cshtml
index d5dc27c9..52cf3b88 100644
--- a/src/Yavsc.Org/Views/Blogspot/Index.cshtml
+++ b/src/Yavsc.Org/Views/Blogspot/Index.cshtml
@@ -52,19 +52,19 @@
-
+
@post.Title
-
+
@post.Article
@Html.DisplayFor(m => post.Author)
posté le @post.DateCreated.ToString("dddd d MMM yyyy à H:mm")
@if ((post.DateModified - post.DateCreated).Minutes > 0){
@:- Modifié le @post.DateModified.ToString("dddd d MMM yyyy à H:mm")
- })
+ }
@@ -74,13 +74,13 @@
}
else
{
-
Details
+
Details
}
@if ((await AuthorizationService.AuthorizeAsync(User, post, new EditPermission())).Succeeded)
{
-
Edit
+
Edit
-
Delete
+
Delete
}
diff --git a/src/Yavsc.Server/Models/Blog/BlogPost.cs b/src/Yavsc.Server/Models/Blog/BlogPost.cs
index e1b44603..442cbb1f 100644
--- a/src/Yavsc.Server/Models/Blog/BlogPost.cs
+++ b/src/Yavsc.Server/Models/Blog/BlogPost.cs
@@ -35,7 +35,7 @@ namespace Yavsc.Models.Blog
public string? AuthorId { get; set; }
[Display(Name = "Auteur")]
- public virtual ApplicationUser? Author { set; get; }
+ public virtual ApplicationUser Author { set; get; }
[Display(Name = "Date de création")]
@@ -95,6 +95,6 @@ namespace Yavsc.Models.Blog
[InverseProperty("Post")]
public virtual List
Comments { get; set; }
- IApplicationUser IBlogPost.Author { get => this.Author; }
+ IApplicationUser IBlogPost.Author => Author;
}
}
From cd03b04755cffda61179cded29ff74759c3a50f8 Mon Sep 17 00:00:00 2001
From: Paul Schneider
Date: Wed, 5 Aug 2026 21:11:22 +0100
Subject: [PATCH 015/128] re-refacto BlogPost serialization
---
src/Yavsc.Abstract/Interfaces/Models/ITaggable.cs | 2 --
src/Yavsc.Blogs/Controllers/BlogApiController.cs | 4 ++--
2 files changed, 2 insertions(+), 4 deletions(-)
diff --git a/src/Yavsc.Abstract/Interfaces/Models/ITaggable.cs b/src/Yavsc.Abstract/Interfaces/Models/ITaggable.cs
index eb325b6f..bb4020a8 100644
--- a/src/Yavsc.Abstract/Interfaces/Models/ITaggable.cs
+++ b/src/Yavsc.Abstract/Interfaces/Models/ITaggable.cs
@@ -3,7 +3,5 @@ namespace Yavsc.Interfaces
public interface ITaggable : IIdentified
{
string [] GetTags();
-
- K Id { get; }
}
}
diff --git a/src/Yavsc.Blogs/Controllers/BlogApiController.cs b/src/Yavsc.Blogs/Controllers/BlogApiController.cs
index ac7b59df..23d71742 100644
--- a/src/Yavsc.Blogs/Controllers/BlogApiController.cs
+++ b/src/Yavsc.Blogs/Controllers/BlogApiController.cs
@@ -22,9 +22,9 @@ namespace Yavsc.Blogs.Controllers
// GET: api/BlogApi
[HttpGet]
- public async Task> GetBlogspot(int start = 0, int take = 25)
+ public async Task> GetBlogspot(int start = 0, int take = 25)
{
- return (await blogSpotService.Index(User, null, start, take)).Cast();
+ return await blogSpotService.Index(User, null, start, take);
}
// GET: api/BlogApi/5
From 44b391d496de0aa7bbb3521bbba13c5c1526791b Mon Sep 17 00:00:00 2001
From: Paul Schneider
Date: Mon, 10 Aug 2026 18:12:59 +0100
Subject: [PATCH 016/128] Activity protection
---
Directory.Build.props | 1 +
.../Business/ActivityApiController.cs | 3 +-
.../NativeConfidentialController.cs | 8 ++---
src/Yavsc.Blogs.Tests/BlogApiTests.cs | 32 +++++++++++++++++++
src/Yavsc.Org/Extensions/HostingExtensions.cs | 3 ++
.../Services/GoogleApis/CalendarManager.cs | 2 +-
6 files changed, 42 insertions(+), 7 deletions(-)
diff --git a/Directory.Build.props b/Directory.Build.props
index 873845c4..aec8c990 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -11,5 +11,6 @@
from without conflicting names.
-->
true
+ NU1701, NU1901, NU1902
diff --git a/src/Yavsc.Api/Controllers/Business/ActivityApiController.cs b/src/Yavsc.Api/Controllers/Business/ActivityApiController.cs
index f6b215e7..d2da2ea7 100644
--- a/src/Yavsc.Api/Controllers/Business/ActivityApiController.cs
+++ b/src/Yavsc.Api/Controllers/Business/ActivityApiController.cs
@@ -15,7 +15,6 @@ namespace Yavsc.Controllers
{
[Produces("application/json")]
[Route("api/activity")]
- [AllowAnonymous]
public class ActivityApiController : Controller
{
private ApplicationDbContext _context;
@@ -88,7 +87,7 @@ namespace Yavsc.Controllers
}
// POST: api/ActivityApi
- [HttpPost,Authorize("AdministratorOnly")]
+ [HttpPost, Authorize("AdministratorOnly")]
public async Task PostActivity([FromBody] Activity activity)
{
if (!ModelState.IsValid)
diff --git a/src/Yavsc.Api/Controllers/NativeConfidentialController.cs b/src/Yavsc.Api/Controllers/NativeConfidentialController.cs
index 01cd8478..4e771830 100644
--- a/src/Yavsc.Api/Controllers/NativeConfidentialController.cs
+++ b/src/Yavsc.Api/Controllers/NativeConfidentialController.cs
@@ -1,15 +1,15 @@
-using System;
-using System.Linq;
+
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
-using Microsoft.Extensions.Logging;
-using Yavsc.Helpers;
+
using Yavsc.Models;
using Yavsc.Models.Identity;
using Yavsc.Server.Helpers;
+#nullable enable
+
[Authorize, Route("~/api/gcm")]
public class NativeConfidentialController : Controller
{
diff --git a/src/Yavsc.Blogs.Tests/BlogApiTests.cs b/src/Yavsc.Blogs.Tests/BlogApiTests.cs
index 35d67fbe..bf0758c6 100644
--- a/src/Yavsc.Blogs.Tests/BlogApiTests.cs
+++ b/src/Yavsc.Blogs.Tests/BlogApiTests.cs
@@ -148,6 +148,38 @@ public sealed class BlogApiTests : IClassFixture
Assert.Equal(created.Id, doc.RootElement[0].GetProperty("id").GetInt64());
}
+ [Fact]
+ public async Task PostBlog_sets_AuthorId_on_created_post_and_list_entry()
+ {
+ ResetDatabase();
+ using var http = NewClient(subject: "tester");
+
+ var draft = new BlogPost
+ {
+ Id = 0,
+ Title = "Billet avec auteur",
+ AuthorId = "payload-attacker",
+ Article = "Contenu de test.",
+ DateCreated = DateTime.UtcNow,
+ DateModified = DateTime.UtcNow
+ };
+
+ var postResponse = await http.PostAsJsonAsync("/api/v1/blog", draft);
+ Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
+
+ var created = await postResponse.Content.ReadFromJsonAsync();
+ Assert.NotNull(created);
+ Assert.Equal("tester", created!.AuthorId);
+
+ var listResponse = await http.GetAsync("/api/v1/blog");
+ Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
+
+ using var doc = JsonDocument.Parse(await listResponse.Content.ReadAsStringAsync());
+ Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
+ Assert.Equal(1, doc.RootElement.GetArrayLength());
+ Assert.Equal("tester", doc.RootElement[0].GetProperty("authorId").GetString());
+ }
+
[Fact]
public async Task GetBlog_returns_401_when_no_token_is_provided()
{
diff --git a/src/Yavsc.Org/Extensions/HostingExtensions.cs b/src/Yavsc.Org/Extensions/HostingExtensions.cs
index b3f90639..0cae23a7 100644
--- a/src/Yavsc.Org/Extensions/HostingExtensions.cs
+++ b/src/Yavsc.Org/Extensions/HostingExtensions.cs
@@ -1189,6 +1189,8 @@ ADD COLUMN IF NOT EXISTS ""Moderated"" boolean NOT NULL DEFAULT FALSE;");
}
}
+#nullable enable
+
static void LoadGoogleConfig(IConfigurationRoot configuration)
{
string? googleClientFile = configuration["Authentication:Google:GoogleWebClientJson"];
@@ -1204,6 +1206,7 @@ ADD COLUMN IF NOT EXISTS ""Moderated"" boolean NOT NULL DEFAULT FALSE;");
Config.GServiceAccount = JsonConvert.DeserializeObject(safile.OpenText().ReadToEnd());
}
}
+#nullable disable
public static IApplicationBuilder ConfigureFileServerApp(this IApplicationBuilder app,
bool enableDirectoryBrowsing = false)
diff --git a/src/Yavsc.Server/Services/GoogleApis/CalendarManager.cs b/src/Yavsc.Server/Services/GoogleApis/CalendarManager.cs
index 00f5a9b2..f91b3393 100644
--- a/src/Yavsc.Server/Services/GoogleApis/CalendarManager.cs
+++ b/src/Yavsc.Server/Services/GoogleApis/CalendarManager.cs
@@ -197,7 +197,7 @@ namespace Yavsc.Services
if (credential.IsCreateScopedRequired)
{
credential = credential.CreateScoped(scopesCalendar);
- }/*
+ }/*
var credential = await GoogleHelpers.GetCredentialForApi(new string [] { scopeCalendar });
if (credential.IsCreateScopedRequired)
{
From 0d3fbf22c3e8e474b3464a39330522f80162781c Mon Sep 17 00:00:00 2001
From: Paul Schneider
Date: Mon, 10 Aug 2026 18:34:01 +0100
Subject: [PATCH 017/128] GetUserId_reads_NameIdentifier_when_sub_was_mapped
---
.../BlogApiMappedClaimsTests.cs | 156 ++++++++++++++++++
src/Yavsc.Blogs.Tests/BlogApiTests.cs | 14 ++
.../JwtClaimMappingCollection.cs | 8 +
.../MappedClaimsBlogsWebServerFixture.cs | 109 ++++++++++++
src/Yavsc.Server/Helpers/UserHelpers.cs | 4 +-
5 files changed, 290 insertions(+), 1 deletion(-)
create mode 100644 src/Yavsc.Blogs.Tests/BlogApiMappedClaimsTests.cs
create mode 100644 src/Yavsc.Blogs.Tests/JwtClaimMappingCollection.cs
create mode 100644 src/Yavsc.Blogs.Tests/MappedClaimsBlogsWebServerFixture.cs
diff --git a/src/Yavsc.Blogs.Tests/BlogApiMappedClaimsTests.cs b/src/Yavsc.Blogs.Tests/BlogApiMappedClaimsTests.cs
new file mode 100644
index 00000000..f02a1b99
--- /dev/null
+++ b/src/Yavsc.Blogs.Tests/BlogApiMappedClaimsTests.cs
@@ -0,0 +1,156 @@
+using System.IdentityModel.Tokens.Jwt;
+using System.Net;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Security.Claims;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.IdentityModel.Tokens;
+using Yavsc.Models;
+using Yavsc.Models.Blog;
+using Yavsc.Tests.Shared;
+
+namespace Yavsc.Blogs.Tests;
+
+[Collection("JwtClaimMapping")]
+public sealed class BlogApiMappedClaimsTests : IClassFixture
+{
+ private readonly MappedClaimsBlogsWebServerFixture _fixture;
+
+ public BlogApiMappedClaimsTests(MappedClaimsBlogsWebServerFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ private void ResetDatabase()
+ {
+ using var scope = _fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.Database.EnsureDeleted();
+ db.Database.EnsureCreated();
+ }
+
+ private HttpClient NewClient(string subject = "tester")
+ {
+ var http = new HttpClient
+ {
+ BaseAddress = new Uri(_fixture.Addresses.First())
+ };
+ http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
+ "Bearer",
+ IssueMappedClaimsToken(subject));
+ return http;
+ }
+
+ private static string IssueMappedClaimsToken(string subject)
+ {
+ var now = DateTime.UtcNow;
+ var claims = new List
+ {
+ new("sub", subject),
+ new("scope", "blogs"),
+ };
+
+ var token = new JwtSecurityToken(
+ issuer: TestTokenIssuer.Issuer,
+ audience: TestTokenIssuer.Audience,
+ claims: claims,
+ notBefore: now,
+ expires: now.AddHours(1),
+ signingCredentials: new SigningCredentials(
+ TestTokenIssuer.SigningKey,
+ SecurityAlgorithms.HmacSha256));
+
+ return new JwtSecurityTokenHandler().WriteToken(token);
+ }
+
+ [Fact]
+ public async Task PostBlog_with_mapped_sub_claim_sets_AuthorId_from_authenticated_user()
+ {
+ ResetDatabase();
+ using var http = NewClient(subject: "mapped-user");
+
+ var draft = new BlogPost
+ {
+ Id = 0,
+ Title = "Billet JWT remappe",
+ AuthorId = "payload-attacker",
+ Article = "Contenu de test.",
+ DateCreated = DateTime.UtcNow,
+ DateModified = DateTime.UtcNow
+ };
+
+ var response = await http.PostAsJsonAsync("/api/v1/blog", draft);
+ Assert.Equal(HttpStatusCode.Created, response.StatusCode);
+
+ var created = await response.Content.ReadFromJsonAsync();
+ Assert.NotNull(created);
+ Assert.Equal("mapped-user", created!.AuthorId);
+ }
+
+ [Fact]
+ public async Task PutBlog_with_mapped_sub_claim_allows_owner_to_update()
+ {
+ ResetDatabase();
+ using var http = NewClient(subject: "mapped-owner");
+
+ var createdResponse = await http.PostAsJsonAsync("/api/v1/blog", new BlogPost
+ {
+ Id = 0,
+ Title = "Billet à modifier",
+ AuthorId = "payload-attacker",
+ Article = "Contenu initial.",
+ DateCreated = DateTime.UtcNow,
+ DateModified = DateTime.UtcNow
+ });
+
+ Assert.Equal(HttpStatusCode.Created, createdResponse.StatusCode);
+ var created = await createdResponse.Content.ReadFromJsonAsync();
+ Assert.NotNull(created);
+
+ var updateResponse = await http.PutAsJsonAsync($"/api/v1/blog/{created!.Id}", new BlogPost
+ {
+ Id = created.Id,
+ Title = "Billet modifié",
+ AuthorId = created.AuthorId,
+ Article = "Contenu mis à jour.",
+ DateCreated = created.DateCreated,
+ DateModified = DateTime.UtcNow
+ });
+
+ Assert.Equal(HttpStatusCode.NoContent, updateResponse.StatusCode);
+ }
+
+ [Fact]
+ public async Task PutBlog_with_mapped_sub_claim_rejects_non_owner()
+ {
+ ResetDatabase();
+ using var ownerHttp = NewClient(subject: "mapped-owner");
+
+ var createdResponse = await ownerHttp.PostAsJsonAsync("/api/v1/blog", new BlogPost
+ {
+ Id = 0,
+ Title = "Billet protégé",
+ AuthorId = "payload-attacker",
+ Article = "Contenu initial.",
+ DateCreated = DateTime.UtcNow,
+ DateModified = DateTime.UtcNow
+ });
+
+ Assert.Equal(HttpStatusCode.Created, createdResponse.StatusCode);
+ var created = await createdResponse.Content.ReadFromJsonAsync();
+ Assert.NotNull(created);
+
+ using var otherHttp = NewClient(subject: "mapped-other");
+ var updateResponse = await otherHttp.PutAsJsonAsync($"/api/v1/blog/{created!.Id}", new BlogPost
+ {
+ Id = created.Id,
+ Title = "Tentative de modification",
+ AuthorId = created.AuthorId,
+ Article = "Contenu non autorisé.",
+ DateCreated = created.DateCreated,
+ DateModified = DateTime.UtcNow
+ });
+
+ Assert.Equal(HttpStatusCode.Unauthorized, updateResponse.StatusCode);
+ }
+}
diff --git a/src/Yavsc.Blogs.Tests/BlogApiTests.cs b/src/Yavsc.Blogs.Tests/BlogApiTests.cs
index bf0758c6..7e725e4f 100644
--- a/src/Yavsc.Blogs.Tests/BlogApiTests.cs
+++ b/src/Yavsc.Blogs.Tests/BlogApiTests.cs
@@ -1,10 +1,12 @@
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
+using System.Security.Claims;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Models;
using Yavsc.Models.Blog;
+using Yavsc.Server.Helpers;
using Yavsc.Tests.Shared;
namespace Yavsc.Blogs.Tests;
@@ -20,6 +22,7 @@ namespace Yavsc.Blogs.Tests;
/// header (or sending a token signed with the wrong key) gets a
/// 401 back from the framework.
///
+[Collection("JwtClaimMapping")]
public sealed class BlogApiTests : IClassFixture
{
private readonly BlogsWebServerFixture _fixture;
@@ -180,6 +183,17 @@ public sealed class BlogApiTests : IClassFixture
Assert.Equal("tester", doc.RootElement[0].GetProperty("authorId").GetString());
}
+ [Fact]
+ public void GetUserId_reads_NameIdentifier_when_sub_was_mapped()
+ {
+ var principal = new ClaimsPrincipal(
+ new ClaimsIdentity(
+ [new Claim(ClaimTypes.NameIdentifier, "tester")],
+ authenticationType: "Bearer"));
+
+ Assert.Equal("tester", principal.GetUserId());
+ }
+
[Fact]
public async Task GetBlog_returns_401_when_no_token_is_provided()
{
diff --git a/src/Yavsc.Blogs.Tests/JwtClaimMappingCollection.cs b/src/Yavsc.Blogs.Tests/JwtClaimMappingCollection.cs
new file mode 100644
index 00000000..e141c0f3
--- /dev/null
+++ b/src/Yavsc.Blogs.Tests/JwtClaimMappingCollection.cs
@@ -0,0 +1,8 @@
+using Xunit;
+
+namespace Yavsc.Blogs.Tests;
+
+[CollectionDefinition("JwtClaimMapping", DisableParallelization = true)]
+public sealed class JwtClaimMappingCollection
+{
+}
diff --git a/src/Yavsc.Blogs.Tests/MappedClaimsBlogsWebServerFixture.cs b/src/Yavsc.Blogs.Tests/MappedClaimsBlogsWebServerFixture.cs
new file mode 100644
index 00000000..c9d95774
--- /dev/null
+++ b/src/Yavsc.Blogs.Tests/MappedClaimsBlogsWebServerFixture.cs
@@ -0,0 +1,109 @@
+using System.IdentityModel.Tokens.Jwt;
+using System.Security.Claims;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Storage;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.IdentityModel.Tokens;
+using Yavsc.Blogs.Controllers;
+using Yavsc.Models;
+using Yavsc.Services;
+using Yavsc.Tests.Shared;
+
+namespace Yavsc.Blogs.Tests;
+
+///
+/// Dedicated integration-test host that mirrors the production JWT
+/// remapping behavior: MapInboundClaims remains enabled and the
+/// default inbound map rewrites "sub" to ClaimTypes.NameIdentifier.
+/// This is the closest in-process reproduction of the production
+/// authentication surface for the blog API.
+///
+public sealed class MappedClaimsBlogsWebServerFixture : IDisposable
+{
+ private readonly InMemoryDatabaseRoot _inMemoryRoot = new();
+ private readonly Dictionary _savedInboundMap;
+ private readonly WebApplication _app;
+
+ public MappedClaimsBlogsWebServerFixture()
+ {
+ _savedInboundMap = new Dictionary(JwtSecurityTokenHandler.DefaultInboundClaimTypeMap);
+ JwtSecurityTokenHandler.DefaultInboundClaimTypeMap["sub"] = ClaimTypes.NameIdentifier;
+
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseUrls("http://127.0.0.1:5104");
+
+ builder.Services.AddDbContext(opt =>
+ opt.UseInMemoryDatabase("Yavsc.Blogs.Tests.MappedClaims", _inMemoryRoot));
+
+ builder.Services.AddSingleton(new NoopFileSystemAuthManager());
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddControllers()
+ .AddApplicationPart(typeof(BlogApiController).Assembly);
+ builder.Services.AddAuthorization(opt =>
+ {
+ opt.AddPolicy("BlogScope", policy =>
+ {
+ policy.RequireAuthenticatedUser()
+ .RequireClaim("scope", "blogs");
+ });
+ });
+ builder.Services.AddAuthentication("Bearer")
+ .AddJwtBearer("Bearer", options =>
+ {
+ options.IncludeErrorDetails = true;
+ options.MapInboundClaims = true;
+ options.TokenValidationParameters = new TokenValidationParameters
+ {
+ ValidateIssuer = true,
+ ValidIssuer = TestTokenIssuer.Issuer,
+ ValidateAudience = false,
+ ValidateLifetime = true,
+ ValidateIssuerSigningKey = true,
+ IssuerSigningKey = TestTokenIssuer.SigningKey,
+ RoleClaimType = YavscConstants.RoleClaimType,
+ NameClaimType = YavscConstants.NameClaimType,
+ };
+ });
+
+ _app = builder.Build();
+ _app.UseRouting();
+ _app.UseAuthentication();
+ _app.UseAuthorization();
+ _app.MapControllers();
+ _app.StartAsync().GetAwaiter().GetResult();
+
+ Addresses = ["http://127.0.0.1:5104"];
+ Services = _app.Services;
+ }
+
+ public IReadOnlyList Addresses { get; }
+
+ public IServiceProvider Services { get; }
+
+ public void Dispose()
+ {
+ _app.StopAsync().GetAwaiter().GetResult();
+ _app.DisposeAsync().AsTask().GetAwaiter().GetResult();
+
+ JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
+ foreach (var kvp in _savedInboundMap)
+ {
+ JwtSecurityTokenHandler.DefaultInboundClaimTypeMap[kvp.Key] = kvp.Value;
+ }
+ }
+
+ private sealed class NoopFileSystemAuthManager : IFileSystemAuthManager
+ {
+ public FileAccessRight GetFilePathAccess(System.Security.Claims.ClaimsPrincipal user, string fileRelativePath)
+ => FileAccessRight.None;
+
+ public void SetAccess(long circleId, string normalizedFullPath, FileAccessRight access)
+ {
+ }
+ }
+}
diff --git a/src/Yavsc.Server/Helpers/UserHelpers.cs b/src/Yavsc.Server/Helpers/UserHelpers.cs
index 105c1beb..c3ee708d 100644
--- a/src/Yavsc.Server/Helpers/UserHelpers.cs
+++ b/src/Yavsc.Server/Helpers/UserHelpers.cs
@@ -32,7 +32,9 @@ namespace Yavsc.Server.Helpers
public static string GetUserId(this ClaimsPrincipal user)
{
- return user.FindFirstValue("sub");
+ return user.FindFirstValue("sub")
+ ?? user.FindFirstValue(ClaimTypes.NameIdentifier)
+ ?? user.FindFirstValue("nameid");
}
public static string GetUserName(this ClaimsPrincipal user)
From 0fd9e40d67db41055f16c192e930aafa0bad2fe5 Mon Sep 17 00:00:00 2001
From: Paul Schneider
Date: Mon, 10 Aug 2026 21:48:03 +0100
Subject: [PATCH 018/128] Fix blog comment endpoint path and add regression
test
---
src/Yavsc.Blogs.Tests/BlogApiTests.cs | 36 +++++++++++++++++++
.../Communicating/BlogspotController.cs | 2 +-
.../ViewComponents/CommentViewComponent.cs | 2 +-
src/Yavsc.Org/Views/Blogspot/Details.cshtml | 6 ++--
4 files changed, 41 insertions(+), 5 deletions(-)
diff --git a/src/Yavsc.Blogs.Tests/BlogApiTests.cs b/src/Yavsc.Blogs.Tests/BlogApiTests.cs
index 7e725e4f..cc7aaec8 100644
--- a/src/Yavsc.Blogs.Tests/BlogApiTests.cs
+++ b/src/Yavsc.Blogs.Tests/BlogApiTests.cs
@@ -183,6 +183,42 @@ public sealed class BlogApiTests : IClassFixture
Assert.Equal("tester", doc.RootElement[0].GetProperty("authorId").GetString());
}
+ [Fact]
+ public async Task PostBlogComment_returns_201_for_existing_post()
+ {
+ ResetDatabase();
+ using var http = NewClient(subject: "tester");
+
+ var draft = new BlogPost
+ {
+ Id = 0,
+ Title = "Billet commentable",
+ AuthorId = "payload-attacker",
+ Article = "Contenu de test.",
+ DateCreated = DateTime.UtcNow,
+ DateModified = DateTime.UtcNow
+ };
+
+ var postResponse = await http.PostAsJsonAsync("/api/v1/blog", draft);
+ Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
+
+ var createdPost = await postResponse.Content.ReadFromJsonAsync();
+ Assert.NotNull(createdPost);
+
+ var commentResponse = await http.PostAsJsonAsync("/api/v1/blogcomments", new
+ {
+ Article = "Premier commentaire",
+ ReceiverId = createdPost!.Id
+ });
+
+ Assert.Equal(HttpStatusCode.Created, commentResponse.StatusCode);
+
+ using var doc = JsonDocument.Parse(await commentResponse.Content.ReadAsStringAsync());
+ Assert.True(doc.RootElement.TryGetProperty("id", out var id));
+ Assert.True(id.GetInt64() > 0);
+ Assert.True(doc.RootElement.TryGetProperty("dateCreated", out _));
+ }
+
[Fact]
public void GetUserId_reads_NameIdentifier_when_sub_was_mapped()
{
diff --git a/src/Yavsc.Org/Controllers/Communicating/BlogspotController.cs b/src/Yavsc.Org/Controllers/Communicating/BlogspotController.cs
index de0c3c2d..8e2c9e5b 100644
--- a/src/Yavsc.Org/Controllers/Communicating/BlogspotController.cs
+++ b/src/Yavsc.Org/Controllers/Communicating/BlogspotController.cs
@@ -71,7 +71,7 @@ namespace Yavsc.Org.Controllers
try
{
var blog = await blogSpotService.Details(User, id.Value);
- ViewBag.apicmtctlr = "/api/blogcomments";
+ ViewBag.apicmtctlr = "/api/v1/blogcomments";
ViewBag.moderatoFlag = User.IsInMsRole(YavscConstants.BlogModeratorGroupName);
return View(blog);
diff --git a/src/Yavsc.Org/ViewComponents/CommentViewComponent.cs b/src/Yavsc.Org/ViewComponents/CommentViewComponent.cs
index 6752de94..62d3bb7a 100644
--- a/src/Yavsc.Org/ViewComponents/CommentViewComponent.cs
+++ b/src/Yavsc.Org/ViewComponents/CommentViewComponent.cs
@@ -23,7 +23,7 @@ namespace Yavsc.ViewComponents
var comment = await context.Comment.Include(c=>c.Children).FirstOrDefaultAsync(c => c.Id==id);
if (comment == null)
throw new InvalidOperationException();
- ViewBag.apictlr = "/api/blogcomments";
+ ViewBag.apictlr = "/api/v1/blogcomments";
return View("Default", comment);
}
diff --git a/src/Yavsc.Org/Views/Blogspot/Details.cshtml b/src/Yavsc.Org/Views/Blogspot/Details.cshtml
index d7d5a791..dc0bea8a 100644
--- a/src/Yavsc.Org/Views/Blogspot/Details.cshtml
+++ b/src/Yavsc.Org/Views/Blogspot/Details.cshtml
@@ -7,7 +7,7 @@