From 64547840e4a8e4b814a2eda73eda08dd20032d94 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Wed, 5 Aug 2026 21:05:14 +0100 Subject: [PATCH 001/276] 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 002/276] 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 003/276] 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 004/276] 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 005/276] 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 @@ } diff --git a/src/Yavsc.Server/Models/Workflow/PerformerProfile.cs b/src/Yavsc.Server/Models/Workflow/PerformerProfile.cs index 80622ac8..23bd688b 100644 --- a/src/Yavsc.Server/Models/Workflow/PerformerProfile.cs +++ b/src/Yavsc.Server/Models/Workflow/PerformerProfile.cs @@ -23,7 +23,7 @@ namespace Yavsc.Models.Workflow public virtual List Activity { get; set; } [Required, Display(Name = "Country of exercise")] - [MinLength(2), MaxLength(2)] + [RegularExpression("^[A-Za-z]{2}$", ErrorMessage = "Country code must be a 2-letter code.")] public string ExerciseCountryCode { get; set; } = "fr"; [Required,YaStringLength(14),Display(Name="SIREN")] diff --git a/src/Yavsc.Tests.Shared/WebHostFixture.cs b/src/Yavsc.Tests.Shared/WebHostFixture.cs index f40da18d..b92adc09 100644 --- a/src/Yavsc.Tests.Shared/WebHostFixture.cs +++ b/src/Yavsc.Tests.Shared/WebHostFixture.cs @@ -37,6 +37,7 @@ public abstract class WebHostFixture : IBackendFixture private static readonly object _sync = new object(); private static WebApplication? _app; private static bool _isInitialized; + private static int _instanceCount; private static readonly List _sharedAddresses = new(); private static IServiceProvider? _sharedServices; @@ -67,6 +68,7 @@ public abstract class WebHostFixture : IBackendFixture InitializeAsync().GetAwaiter().GetResult(); _isInitialized = true; } + _instanceCount++; CopySharedState(); CopySpecialisedSharedState(); } @@ -75,6 +77,8 @@ public abstract class WebHostFixture : IBackendFixture private void CopySharedState() { Addresses = _sharedAddresses.ToArray(); + IsInitialized = _isInitialized; + App = _app ?? App; } /// Hook for specialisations to copy any other shared @@ -149,11 +153,25 @@ public abstract class WebHostFixture : IBackendFixture { lock (_sync) { - if (!IsInitialized) - throw new InvalidOperationException("Cannot tear down a fixture that has not been initialized."); - this.App.StopAsync().GetAwaiter().GetResult(); + if (_instanceCount > 0) + { + _instanceCount--; + } + IsInitialized = false; + if (_instanceCount > 0) + { + return; + } + + if (!_isInitialized) + { + return; + } + + _app?.StopAsync().GetAwaiter().GetResult(); + _app = null; _isInitialized = false; _sharedAddresses.Clear(); _sharedServices = null; From 9dc01382309be0f79a74a1bdfe5a400c04c01a0f Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 31 Aug 2026 13:26:03 +0100 Subject: [PATCH 260/276] fix: bump EF Core Sqlite to address NU1903 vulnerability --- Directory.Packages.props | 43 +++++++++++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 7505c851..a9a25541 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,13 +3,28 @@ true - - - - - - - + + + + + + + + + + + + + + + + + + + + + + @@ -17,14 +32,24 @@ - + + + + + + + + + + + - \ No newline at end of file + From 5b4bb983debc8e8c8b806020fb2f87a6d2058fe8 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 31 Aug 2026 13:29:12 +0100 Subject: [PATCH 261/276] chore(deps): cleanup Yavsc.Org restore package configuration --- src/Yavsc.Org/Directory.Packages.props | 24 ------------------------ src/Yavsc.Org/Yavsc.Org.csproj | 4 ---- 2 files changed, 28 deletions(-) delete mode 100644 src/Yavsc.Org/Directory.Packages.props diff --git a/src/Yavsc.Org/Directory.Packages.props b/src/Yavsc.Org/Directory.Packages.props deleted file mode 100644 index d9925e02..00000000 --- a/src/Yavsc.Org/Directory.Packages.props +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Yavsc.Org/Yavsc.Org.csproj b/src/Yavsc.Org/Yavsc.Org.csproj index dda2d10c..6823bc6a 100644 --- a/src/Yavsc.Org/Yavsc.Org.csproj +++ b/src/Yavsc.Org/Yavsc.Org.csproj @@ -34,17 +34,13 @@ - - - - From b46548a08ffc7e1c574a23367f473ae1e03600df Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 31 Aug 2026 13:37:54 +0100 Subject: [PATCH 262/276] Release 1.0.8-rc11 --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7c3209c..f14f285b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [1.0.8-rc11] - unstable + +### Added + +nothing + +### Changed + +* [Yavsc.Api.Test] Mise a jour de `Microsoft.EntityFrameworkCore.Sqlite` vers `10.0.11` afin de supprimer l'alerte NU1903 liee a `SQLitePCLRaw.lib.e_sqlite3` 2.1.11. +* [Yavsc.Org] Nettoyage de la configuration NuGet pour le restore: suppression du fichier local `Directory.Packages.props` au profit du fichier racine centralise. +* [Yavsc.Org] Suppression de references de packages redondantes dans le projet, sans impact fonctionnel attendu. + +### Fixed + +* [Yavsc.Api.Test] Le restore n'emet plus le warning de vulnerabilite `NU1903` sur `SQLitePCLRaw.lib.e_sqlite3`. +* [Yavsc.Org] Suppression d'une vulnerabilite de severite elevee sur AutoMapper apres publication et consommation de la nouvelle version candidate de `HigginsSoft.IdentityServer8`. + ## [1.0.8-rc10] - unstable ### Added From 55d305f2957d41ca8def1fb9414fd1194ee63fd0 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 31 Aug 2026 17:35:02 +0100 Subject: [PATCH 263/276] test(oidc): cover discovery regression and consume fixed IdentityServer8 rc007 Add an application-level smoke test for /.well-known/openid-configuration and a direct IResourceStore check so the IdentityServer8 mapper failure is caught from the Yavsc side. Bump HigginsSoft.IdentityServer8 packages to 8.1.0-pazofrc007, which includes the EntityFramework mapper fix removing the failing AutoMapper static initialization. This closes the 500 on OIDC discovery and restores remoting/auth flows that depend on discovery metadata. --- Directory.Packages.props | 13 +++++----- .../Smoke/AccountSmokeTests.cs | 26 +++++++++++++++++++ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index a9a25541..f1be93f9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,16 +1,17 @@ true + 8.1.0-pazofrc007 - - - - - - + + + + + + diff --git a/src/Yavsc.Org.Tests/Smoke/AccountSmokeTests.cs b/src/Yavsc.Org.Tests/Smoke/AccountSmokeTests.cs index c77a20ab..327c2db4 100644 --- a/src/Yavsc.Org.Tests/Smoke/AccountSmokeTests.cs +++ b/src/Yavsc.Org.Tests/Smoke/AccountSmokeTests.cs @@ -1,3 +1,6 @@ +using IdentityServer8.Stores; +using Microsoft.Extensions.DependencyInjection; + namespace Yavsc.Org.Tests.Smoke; /// @@ -30,4 +33,27 @@ public class AccountSmokeTests : SmokeTestBase, IClassFixture(); + + var exception = await Record.ExceptionAsync(resourceStore.GetAllResourcesAsync); + + Assert.Null(exception); + } } From 914e9486b5e605c9a426d516be599ee0b795675b Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Wed, 2 Sep 2026 14:41:05 +0100 Subject: [PATCH 264/276] Platform.TryGetCurrentLocationAsync --- src/PostIt/PostIt.Android/Application.cs | 4 + src/PostIt/PostIt.Android/MainActivity.cs | 11 ++ .../PostIt.Android/PlatformBootstrap.cs | 3 +- .../AndroidCurrentLocationProvider.cs | 130 +++++++++++++++++ .../BillingCommandPageViewModelTests.cs | 58 +++++++- .../PostIt/Services/CurrentLocationResult.cs | 28 ++++ src/PostIt/PostIt/Services/Platform.cs | 11 ++ .../ViewModels/BillingCommandPageViewModel.cs | 137 +++++++++++++++--- .../PostIt/Views/BillingCommandPage.axaml | 33 +++-- src/Yavsc.Api.Client/BillingApiClient.cs | 20 ++- .../Dtos/BillingQueryDetailsDto.cs | 6 +- 11 files changed, 395 insertions(+), 46 deletions(-) create mode 100644 src/PostIt/PostIt.Android/Services/AndroidCurrentLocationProvider.cs create mode 100644 src/PostIt/PostIt/Services/CurrentLocationResult.cs diff --git a/src/PostIt/PostIt.Android/Application.cs b/src/PostIt/PostIt.Android/Application.cs index f5a7908d..040b01ca 100644 --- a/src/PostIt/PostIt.Android/Application.cs +++ b/src/PostIt/PostIt.Android/Application.cs @@ -1,4 +1,5 @@ using Android.App; +using Android; using Android.Runtime; using Avalonia; using Avalonia.Android; @@ -9,6 +10,9 @@ using Avalonia.Controls; using Avalonia.Styling; using Yavsc.Api.Client; +[assembly: UsesPermission(Manifest.Permission.AccessFineLocation)] +[assembly: UsesPermission(Manifest.Permission.AccessCoarseLocation)] + namespace PostIt.Android { [Application] diff --git a/src/PostIt/PostIt.Android/MainActivity.cs b/src/PostIt/PostIt.Android/MainActivity.cs index ad8455ef..54910080 100644 --- a/src/PostIt/PostIt.Android/MainActivity.cs +++ b/src/PostIt/PostIt.Android/MainActivity.cs @@ -57,6 +57,17 @@ public class MainActivity : AvaloniaMainActivity } + public override void OnRequestPermissionsResult(int requestCode, string[]? permissions, Permission[]? grantResults) + { + if (PostIt.Android.Services.AndroidCurrentLocationProvider + .HandlePermissionResult(requestCode, grantResults)) + { + return; + } + + base.OnRequestPermissionsResult(requestCode, permissions, grantResults); + } + internal static class AndroidOidcCallbackSink { private static System.Threading.Tasks.TaskCompletionSource? _pending; diff --git a/src/PostIt/PostIt.Android/PlatformBootstrap.cs b/src/PostIt/PostIt.Android/PlatformBootstrap.cs index f208f9ce..5b90267f 100644 --- a/src/PostIt/PostIt.Android/PlatformBootstrap.cs +++ b/src/PostIt/PostIt.Android/PlatformBootstrap.cs @@ -14,11 +14,12 @@ internal static class PlatformBootstrap { internal static void InitPlatform() { - Platform.CreateBrowser = () => { var activity = MainActivity.Current; return activity is null ? null : new AndroidSystemBrowser(activity); }; + + Platform.TryGetCurrentLocationAsync = AndroidCurrentLocationProvider.TryGetCurrentLocationAsync; } } diff --git a/src/PostIt/PostIt.Android/Services/AndroidCurrentLocationProvider.cs b/src/PostIt/PostIt.Android/Services/AndroidCurrentLocationProvider.cs new file mode 100644 index 00000000..f710cacc --- /dev/null +++ b/src/PostIt/PostIt.Android/Services/AndroidCurrentLocationProvider.cs @@ -0,0 +1,130 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Android; +using Android.App; +using Android.Content.PM; +using Android.Locations; +using AndroidX.Core.App; +using AndroidX.Core.Content; +using PostIt.Services; + +namespace PostIt.Android.Services; + +internal static class AndroidCurrentLocationProvider +{ + public static async Task TryGetCurrentLocationAsync(CancellationToken cancellationToken) + { + var activity = MainActivity.Current; + if (activity is null) + { + return CurrentLocationResult.Unavailable("L'activité Android n'est pas encore prête."); + } + + var permissionGranted = await LocationPermissionBroker.EnsureGrantedAsync(activity, cancellationToken).ConfigureAwait(false); + if (!permissionGranted) + { + return CurrentLocationResult.PermissionDenied(); + } + + var locationManager = activity.GetSystemService(global::Android.Content.Context.LocationService) as LocationManager; + if (locationManager is null) + { + return CurrentLocationResult.Unavailable("Le service de localisation Android est indisponible."); + } + + var location = locationManager.GetProviders(enabledOnly: true)? + .Select(provider => locationManager.GetLastKnownLocation(provider)) + .Where(candidate => candidate is not null) + .OrderByDescending(candidate => candidate!.Time) + .ThenBy(candidate => candidate!.Accuracy) + .FirstOrDefault(); + + if (location is null) + { + return CurrentLocationResult.Unavailable("Aucune position n'est disponible. Activez la localisation du système puis réessayez."); + } + + return CurrentLocationResult.Success(location.Latitude, location.Longitude); + } + + public static bool HandlePermissionResult(int requestCode, Permission[]? grantResults) + => LocationPermissionBroker.HandleResult(requestCode, grantResults); + + private static class LocationPermissionBroker + { + private const int RequestCode = 4042; + private static readonly string[] RequestedPermissions = + { + Manifest.Permission.AccessFineLocation, + Manifest.Permission.AccessCoarseLocation, + }; + + private static readonly object SyncRoot = new(); + private static TaskCompletionSource? _pendingRequest; + + public static Task EnsureGrantedAsync(Activity activity, CancellationToken cancellationToken) + { + if (HasLocationPermission(activity)) + { + return Task.FromResult(true); + } + + lock (SyncRoot) + { + if (_pendingRequest is null) + { + _pendingRequest = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + ActivityCompat.RequestPermissions(activity, RequestedPermissions, RequestCode); + } + + if (!cancellationToken.CanBeCanceled) + { + return _pendingRequest.Task; + } + + return WaitAsync(_pendingRequest.Task, cancellationToken); + } + } + + public static bool HandleResult(int requestCode, Permission[]? grantResults) + { + if (requestCode != RequestCode) + { + return false; + } + + var granted = grantResults is { Length: > 0 } && grantResults.All(result => result == Permission.Granted); + TaskCompletionSource? pendingRequest; + lock (SyncRoot) + { + pendingRequest = _pendingRequest; + _pendingRequest = null; + } + + pendingRequest?.TrySetResult(granted); + return true; + } + + private static bool HasLocationPermission(Activity activity) + { + return ContextCompat.CheckSelfPermission(activity, Manifest.Permission.AccessFineLocation) == Permission.Granted + || ContextCompat.CheckSelfPermission(activity, Manifest.Permission.AccessCoarseLocation) == Permission.Granted; + } + + private static async Task WaitAsync(Task task, CancellationToken cancellationToken) + { + using var registration = cancellationToken.Register(() => + { + lock (SyncRoot) + { + _pendingRequest?.TrySetCanceled(cancellationToken); + _pendingRequest = null; + } + }); + + return await task.ConfigureAwait(false); + } + } +} diff --git a/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs b/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs index eef3fe21..d80d3195 100644 --- a/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs @@ -1,5 +1,6 @@ using System.Net.Http; using System.Text.Json; +using PostIt.Services; using PostIt.ViewModels; using Yavsc; using Yavsc.Abstract.Workflow; @@ -58,6 +59,61 @@ public class BillingCommandPageViewModelTests Assert.Contains("n'est pas encore pris en charge", vm.StatusMessage, StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task SubmitAsync_allows_missing_coordinates_and_omits_them_from_payload() + { + var api = new RecordingApi(); + var client = new BillingApiClient(api, "https://business.example/api/v1/"); + var vm = new BillingCommandPageViewModel( + new ActivityBrowseItemDto { Code = "dev", Name = "Développement" }, + new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" }, + new CommandFormSummaryDto { Id = 12, ActionName = "Rdv", Title = "Rendez-vous" }, + client) + { + EventDateText = "2026-09-02 14:30", + Reason = "Point de cadrage", + Address = "1 rue du Test", + LatitudeText = string.Empty, + LongitudeText = string.Empty, + Consent = true, + }; + + await vm.SubmitCommand.ExecuteAsync(null); + + using var json = JsonDocument.Parse(JsonSerializer.Serialize(api.LastBody)); + var location = json.RootElement.GetProperty("Location"); + Assert.Equal("1 rue du Test", location.GetProperty("Address").GetString()); + Assert.False(location.TryGetProperty("Latitude", out _)); + Assert.False(location.TryGetProperty("Longitude", out _)); + } + + [Fact] + public async Task UseCurrentLocationAsync_prefills_coordinates_from_platform_provider() + { + var original = Platform.TryGetCurrentLocationAsync; + try + { + Platform.TryGetCurrentLocationAsync = _ => Task.FromResult(CurrentLocationResult.Success(48.8566, 2.3522)); + + var api = new RecordingApi(); + var client = new BillingApiClient(api, "https://business.example/api/v1/"); + var vm = new BillingCommandPageViewModel( + new ActivityBrowseItemDto { Code = "dev", Name = "Développement" }, + new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" }, + new CommandFormSummaryDto { Id = 12, ActionName = "Rdv", Title = "Rendez-vous" }, + client); + + await vm.UseCurrentLocationCommand.ExecuteAsync(null); + + Assert.Equal("48.8566", vm.LatitudeText); + Assert.Equal("2.3522", vm.LongitudeText); + } + finally + { + Platform.TryGetCurrentLocationAsync = original; + } + } + [Fact] public async Task InitializeAsync_loads_prestations_for_brush_and_submit_posts_selected_prestation() { @@ -216,4 +272,4 @@ public class BillingCommandPageViewModelTests public ValueTask DisposeAsync() => ValueTask.CompletedTask; } -} \ No newline at end of file +} diff --git a/src/PostIt/PostIt/Services/CurrentLocationResult.cs b/src/PostIt/PostIt/Services/CurrentLocationResult.cs new file mode 100644 index 00000000..ea19da43 --- /dev/null +++ b/src/PostIt/PostIt/Services/CurrentLocationResult.cs @@ -0,0 +1,28 @@ +namespace PostIt.Services; + +public sealed class CurrentLocationResult +{ + private CurrentLocationResult(bool isSuccess, bool isPermissionDenied, double? latitude, double? longitude, string message) + { + IsSuccess = isSuccess; + IsPermissionDenied = isPermissionDenied; + Latitude = latitude; + Longitude = longitude; + Message = message; + } + + public bool IsSuccess { get; } + public bool IsPermissionDenied { get; } + public double? Latitude { get; } + public double? Longitude { get; } + public string Message { get; } + + public static CurrentLocationResult Success(double latitude, double longitude, string? message = null) + => new(true, false, latitude, longitude, message ?? "Position récupérée."); + + public static CurrentLocationResult PermissionDenied(string? message = null) + => new(false, true, null, null, message ?? "La géolocalisation n'est pas autorisée."); + + public static CurrentLocationResult Unavailable(string? message = null) + => new(false, false, null, null, message ?? "La géolocalisation n'est pas disponible sur cette plateforme."); +} diff --git a/src/PostIt/PostIt/Services/Platform.cs b/src/PostIt/PostIt/Services/Platform.cs index 8e5f7e25..2e5ac76a 100644 --- a/src/PostIt/PostIt/Services/Platform.cs +++ b/src/PostIt/PostIt/Services/Platform.cs @@ -1,4 +1,7 @@ +using System; using IdentityModel.OidcClient.Browser; +using System.Threading; +using System.Threading.Tasks; namespace PostIt.Services; @@ -37,4 +40,12 @@ public static class Platform /// public static System.Func? CreateBrowser { get; set; } = () => new CustomSchemeBrowser(CustomScheme); + + /// + /// Optional platform hook used by the shared billing form to request a + /// current device position. Platforms that do not expose a native + /// location provider can leave the default delegate in place. + /// + public static Func> TryGetCurrentLocationAsync { get; set; } = + _ => Task.FromResult(CurrentLocationResult.Unavailable()); } diff --git a/src/PostIt/PostIt/ViewModels/BillingCommandPageViewModel.cs b/src/PostIt/PostIt/ViewModels/BillingCommandPageViewModel.cs index c9050cbf..2ffeec26 100644 --- a/src/PostIt/PostIt/ViewModels/BillingCommandPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/BillingCommandPageViewModel.cs @@ -8,12 +8,12 @@ using System.Linq; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using PostIt.Services; using Yavsc; using Yavsc.Abstract.Workflow; using Yavsc.Api.Client; using Yavsc.Models.Billing; using Yavsc.Models.Haircut; -using Yavsc.Models.Relationship; namespace PostIt.ViewModels; @@ -67,6 +67,8 @@ public partial class BillingCommandPageViewModel : ViewModelBase [ObservableProperty] public partial QueryStatus CommandStatus { get; set; } = QueryStatus.Inserted; + public bool CanUseCurrentLocation => IsSupported && !IsBusy; + public string Title => Form.Title; public string PerformerLabel => Performer.UserName; public string ActivityLabel => Activity.Name; @@ -122,6 +124,12 @@ public partial class BillingCommandPageViewModel : ViewModelBase OnPropertyChanged(nameof(SubmitLabel)); } + partial void OnIsBusyChanged(bool value) + { + OnPropertyChanged(nameof(CanUseCurrentLocation)); + UseCurrentLocationCommand.NotifyCanExecuteChanged(); + } + public async Task InitializeAsync(BillingQueryDetailsDto? existingQuery = null) { if (!IsBrush && !IsMultiBrush) @@ -198,27 +206,17 @@ public partial class BillingCommandPageViewModel : ViewModelBase return; } - if (!TryParseCoordinate(LatitudeText, out var latitude)) + if (!TryParseCoordinates(out var latitude, out var longitude, out var coordinateError)) { - StatusMessage = "Latitude invalide."; - return; - } - - if (!TryParseCoordinate(LongitudeText, out var longitude)) - { - StatusMessage = "Longitude invalide."; + StatusMessage = coordinateError; return; } IsBusy = true; try { - var location = new Location - { - Address = Address.Trim(), - Latitude = latitude, - Longitude = longitude, - }; + var address = Address.Trim(); + var locationPayload = BuildLocationPayload(address, latitude, longitude); var payload = new BillingQueryDetailsDto { @@ -233,9 +231,9 @@ public partial class BillingCommandPageViewModel : ViewModelBase AdditionalInfo = string.IsNullOrWhiteSpace(AdditionalInfo) ? string.Empty : AdditionalInfo.Trim(), Location = new BillingLocationDto { - Address = location.Address, - Latitude = location.Latitude, - Longitude = location.Longitude, + Address = address, + Latitude = latitude, + Longitude = longitude, } }; @@ -253,7 +251,7 @@ public partial class BillingCommandPageViewModel : ViewModelBase PerformerId = Performer.PerformerId, Consent, EventDate = eventDate, - Location = location, + Location = locationPayload, Reason = payload.Reason, Status = payload.Status, }).ConfigureAwait(true); @@ -281,7 +279,7 @@ public partial class BillingCommandPageViewModel : ViewModelBase PerformerId = Performer.PerformerId, Consent, EventDate = (DateTime?)eventDate, - Location = location, + Location = locationPayload, PrestationId = SelectedPrestation.Id, AdditionalInfo = string.IsNullOrWhiteSpace(AdditionalInfo) ? null : AdditionalInfo.Trim(), Status = payload.Status, @@ -311,7 +309,7 @@ public partial class BillingCommandPageViewModel : ViewModelBase PerformerId = Performer.PerformerId, Consent, EventDate = eventDate, - Location = location, + Location = locationPayload, Prestations = selectedPrestations.Select(x => new { PrestationId = x.Id }).ToList(), Status = payload.Status, }).ConfigureAwait(true); @@ -336,6 +334,44 @@ public partial class BillingCommandPageViewModel : ViewModelBase } } + [RelayCommand(CanExecute = nameof(CanUseCurrentLocation))] + private async Task UseCurrentLocationAsync() + { + if (!CanUseCurrentLocation) + { + return; + } + + IsBusy = true; + try + { + var result = await Platform.TryGetCurrentLocationAsync(default).ConfigureAwait(true); + if (!result.IsSuccess || !result.Latitude.HasValue || !result.Longitude.HasValue) + { + StatusMessage = result.Message; + return; + } + + LatitudeText = result.Latitude.Value.ToString(CultureInfo.InvariantCulture); + LongitudeText = result.Longitude.Value.ToString(CultureInfo.InvariantCulture); + StatusMessage = string.IsNullOrWhiteSpace(Address) + ? "Position récupérée. Complétez l'adresse puis envoyez la commande." + : result.Message; + } + catch (OperationCanceledException) + { + StatusMessage = "La récupération de la position a été annulée."; + } + catch (Exception ex) + { + StatusMessage = $"Impossible de récupérer la position: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + private void ApplyExistingQuery(BillingQueryDetailsDto existingQuery) { ExistingQueryId = existingQuery.Id; @@ -354,8 +390,8 @@ public partial class BillingCommandPageViewModel : ViewModelBase if (existingQuery.Location is not null) { Address = existingQuery.Location.Address ?? string.Empty; - LatitudeText = existingQuery.Location.Latitude.ToString(CultureInfo.InvariantCulture); - LongitudeText = existingQuery.Location.Longitude.ToString(CultureInfo.InvariantCulture); + LatitudeText = existingQuery.Location.Latitude?.ToString(CultureInfo.InvariantCulture) ?? string.Empty; + LongitudeText = existingQuery.Location.Longitude?.ToString(CultureInfo.InvariantCulture) ?? string.Empty; } if (IsBrush && existingQuery.PrestationId is not null) @@ -396,4 +432,59 @@ public partial class BillingCommandPageViewModel : ViewModelBase return double.TryParse(text, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.CurrentCulture, out value) || double.TryParse(text, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out value); } + + private static object BuildLocationPayload(string address, double? latitude, double? longitude) + { + if (latitude.HasValue && longitude.HasValue) + { + return new + { + Address = address, + Latitude = latitude.Value, + Longitude = longitude.Value, + }; + } + + return new + { + Address = address, + }; + } + + private bool TryParseCoordinates(out double? latitude, out double? longitude, out string error) + { + latitude = null; + longitude = null; + error = string.Empty; + + var latitudeMissing = string.IsNullOrWhiteSpace(LatitudeText); + var longitudeMissing = string.IsNullOrWhiteSpace(LongitudeText); + + if (latitudeMissing && longitudeMissing) + { + return true; + } + + if (latitudeMissing != longitudeMissing) + { + error = "Latitude et longitude doivent être renseignées ensemble, ou laissées vides toutes les deux."; + return false; + } + + if (!TryParseCoordinate(LatitudeText, out var parsedLatitude)) + { + error = "Latitude invalide."; + return false; + } + + if (!TryParseCoordinate(LongitudeText, out var parsedLongitude)) + { + error = "Longitude invalide."; + return false; + } + + latitude = parsedLatitude; + longitude = parsedLongitude; + return true; + } } \ No newline at end of file diff --git a/src/PostIt/PostIt/Views/BillingCommandPage.axaml b/src/PostIt/PostIt/Views/BillingCommandPage.axaml index 2dba7336..ee8cb61d 100644 --- a/src/PostIt/PostIt/Views/BillingCommandPage.axaml +++ b/src/PostIt/PostIt/Views/BillingCommandPage.axaml @@ -5,7 +5,7 @@ x:DataType="vm:BillingCommandPageViewModel" Header="Commande billing"> - + - - + public class RequestHelper { - string WRPostMultipart(string url, Dictionary parameters, string authorizationHeader = null) - { - - string boundary = "---------------------------" + DateTime.UtcNow.Ticks.ToString("x"); - byte[] boundaryBytes = System.Text.Encoding.ASCII.GetBytes("\r\n--" + boundary + "\r\n"); - - HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url); - request.ContentType = "multipart/form-data; boundary=" + boundary; - request.Method = "POST"; - request.KeepAlive = true; - request.Credentials = System.Net.CredentialCache.DefaultCredentials; - if (authorizationHeader != null) - request.Headers["Authorization"] = authorizationHeader; - if (parameters != null && parameters.Count > 0) - { - - using (Stream requestStream = request.GetRequestStream()) - { - using (WebResponse response = request.GetResponse()) - { - - - foreach (KeyValuePair pair in parameters) - { - - requestStream.Write(boundaryBytes, 0, boundaryBytes.Length); - if (pair.Value is FormFile) - { - FormFile file = pair.Value as FormFile; - string header = "Content-Disposition: form-data; name=\"" + pair.Key + "\"; filename=\"" + file.Name + "\"\r\nContent-Type: " + file.ContentType + "\r\n\r\n"; - byte[] bytes = System.Text.Encoding.UTF8.GetBytes(header); - requestStream.Write(bytes, 0, bytes.Length); - byte[] buffer = new byte[32768]; - int bytesRead; - if (file.Stream == null) - { - // upload from file - using (FileStream fileStream = File.OpenRead(file.FilePath)) - { - while ((bytesRead = fileStream.Read(buffer, 0, buffer.Length)) != 0) - requestStream.Write(buffer, 0, bytesRead); - fileStream.Close(); - } - } - else - { - // upload from given stream - while ((bytesRead = file.Stream.Read(buffer, 0, buffer.Length)) != 0) - requestStream.Write(buffer, 0, bytesRead); - } - } - else - { - string data = "Content-Disposition: form-data; name=\"" + pair.Key + "\"\r\n\r\n" + pair.Value; - byte[] bytes = System.Text.Encoding.UTF8.GetBytes(data); - requestStream.Write(bytes, 0, bytes.Length); - } - - } - - byte[] trailer = System.Text.Encoding.ASCII.GetBytes("\r\n--" + boundary + "--\r\n"); - requestStream.Write(trailer, 0, trailer.Length); - requestStream.Close(); - - using (Stream responseStream = response.GetResponseStream()) - using (StreamReader reader = new StreamReader(responseStream)) - { - return reader.ReadToEnd(); - } - } // end WebResponse response - - } // end using requestStream - - } - else throw new ArgumentOutOfRangeException("no parameter found "); - - } - public static async Task PostMultipart(string url, FormFile[] formFiles, string access_token = null) { From 81beb0a1028c91ce8361aac358aef9ad3648ad01 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Fri, 4 Sep 2026 15:21:42 +0100 Subject: [PATCH 271/276] test the avatar failback --- .../NonRegression/AvatarFallbackTests.cs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/Yavsc.Org.Tests/NonRegression/AvatarFallbackTests.cs diff --git a/src/Yavsc.Org.Tests/NonRegression/AvatarFallbackTests.cs b/src/Yavsc.Org.Tests/NonRegression/AvatarFallbackTests.cs new file mode 100644 index 00000000..200f37fa --- /dev/null +++ b/src/Yavsc.Org.Tests/NonRegression/AvatarFallbackTests.cs @@ -0,0 +1,34 @@ +namespace Yavsc.Org.Tests.NonRegression; + +/// +/// Non-regression: avatar requests under /avatars must never return 404 +/// for missing files. The pipeline falls back to static defaults under +/// /images/Users/icon_user*.png. +/// +public class AvatarFallbackTests : IClassFixture +{ + private readonly TestWebApplicationFactory _factory; + + public AvatarFallbackTests(TestWebApplicationFactory factory) + { + _factory = factory; + } + + [Theory] + [InlineData("/avatars/user-does-not-exist.png")] + [InlineData("/avatars/user-does-not-exist.s.png")] + [InlineData("/avatars/user-does-not-exist.xs.png")] + public async Task Missing_avatar_file_returns_default_image_instead_of_404(string path) + { + using var client = _factory.CreateClient(); + var ct = TestContext.Current.CancellationToken; + + var response = await client.GetAsync(path, ct); + + Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); + Assert.Equal("image/png", response.Content.Headers.ContentType?.MediaType); + + var payload = await response.Content.ReadAsByteArrayAsync(ct); + Assert.True(payload.Length > 0, $"Expected a non-empty fallback image for {path}."); + } +} From 7a1a9ad2ee735d38a47c571074f98e53f64086ab Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Fri, 4 Sep 2026 15:50:22 +0100 Subject: [PATCH 272/276] la version impair est stable --- CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 559da62a..8aa0567d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,8 +74,8 @@ et ce projet adhère au [Semantic Versioning](https://semver.org/spec/v2.0.0.htm À 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** +- **patch pair** (ex. `1.0.0`, `1.0.2`) → **preview** +- **patch impair** (ex. `1.0.1`, `1.0.3`) → **stable** - **suffixe** (ex. `1.0.0-rc1`, `1.0.0-alpha`) → **instable** Cette convention est partagée avec le dépôt From b82034855804e8bf64cb4e21f28e9d2e1a260d71 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Fri, 4 Sep 2026 19:03:08 +0100 Subject: [PATCH 273/276] Fix set-avatar auth flow and API avatar upload stability --- .../accounting/AccountController.cs | 6 +- src/Yavsc.Api/Program.cs | 2 + src/Yavsc.Api/appsettings-api.json | 3 +- .../Accounting/AccountController.cs | 101 ------------------ .../Accounting/ManageController.cs | 40 ++++++- src/Yavsc.Org/Views/Manage/SetAvatar.cshtml | 29 ++++- src/Yavsc.Org/appsettings-org.json | 1 + src/Yavsc.Server/Helpers/FileSystemHelpers.cs | 26 ++++- src/Yavsc.Server/Helpers/ServiceExtensions.cs | 18 ++++ src/Yavsc.Server/Settings/SiteSettings.cs | 6 +- 10 files changed, 115 insertions(+), 117 deletions(-) diff --git a/src/Yavsc.Api/Controllers/accounting/AccountController.cs b/src/Yavsc.Api/Controllers/accounting/AccountController.cs index aff71013..d8ab9d33 100644 --- a/src/Yavsc.Api/Controllers/accounting/AccountController.cs +++ b/src/Yavsc.Api/Controllers/accounting/AccountController.cs @@ -10,7 +10,7 @@ using System.Diagnostics; namespace Yavsc.WebApi.Controllers { - [Route("~/api/account")] + [Route( Constants.APIPrefix + "/account")] [Authorize("ApiScope")] public class ApiAccountController : Controller { @@ -61,12 +61,12 @@ namespace Yavsc.WebApi.Controllers return Ok(new { host = Request.ForwardedFor() }); } - + /// /// Updates the avatar /// /// - [HttpPost("~/api/set-avatar")] + [HttpPost("set-avatar")] public async Task SetAvatar() { var user = await GetUserData(User.GetUserId()); diff --git a/src/Yavsc.Api/Program.cs b/src/Yavsc.Api/Program.cs index 8db29e53..5269eef1 100644 --- a/src/Yavsc.Api/Program.cs +++ b/src/Yavsc.Api/Program.cs @@ -20,6 +20,7 @@ using Yavsc.Helpers; using Yavsc.Interface; using Yavsc.Interfaces; using Yavsc.Models; +using Yavsc; using Yavsc.Server.Helpers; using Yavsc.Services; @@ -32,6 +33,7 @@ internal class Program var builder = WebApplication.CreateBuilder(args); builder.AddConfiguration("api"); + Config.SiteSetup = builder.Configuration.GetSection("Site").Get() ?? new SiteSettings(); var services = builder.Services; diff --git a/src/Yavsc.Api/appsettings-api.json b/src/Yavsc.Api/appsettings-api.json index ac626c0d..d8b295de 100644 --- a/src/Yavsc.Api/appsettings-api.json +++ b/src/Yavsc.Api/appsettings-api.json @@ -2,7 +2,8 @@ "Site": { "Authority": "https://localhost:5001", "CorsAllowedOrigins": [ - "https://localhost:5003" + "https://localhost:5003", + "https://yavsc.pschneider.fr" ] }, "Logging": { diff --git a/src/Yavsc.Org/Controllers/Accounting/AccountController.cs b/src/Yavsc.Org/Controllers/Accounting/AccountController.cs index 05cb55b5..77e0e32a 100644 --- a/src/Yavsc.Org/Controllers/Accounting/AccountController.cs +++ b/src/Yavsc.Org/Controllers/Accounting/AccountController.cs @@ -94,107 +94,6 @@ IHtmlLocalizerFactory htmlLocalizerFactory, } - public async Task SignIn(SignInModel model, [FromForm] string button) - { - if (Request.Method == "POST") // "hGbkk9B94NAae#aG" - - { - if (model.Provider == null || model.Provider == "LOCAL") - { - if (ModelState.IsValid) - { - var user = await _userManager.FindByNameAsync(model.UserName); - var context = await _interaction.GetAuthorizationContextAsync(model.ReturnUrl); - if (user != null) - { - - - var signin = await _signInManager.CheckPasswordSignInAsync(user, model.Password, true); - - // validate username/password against in-memory store - if (signin.Succeeded) - { - await _events.RaiseAsync(new UserLoginSuccessEvent(user.UserName, user.Id, user.UserName, clientId: context?.Client.ClientId)); - - // only set explicit expiration here if user chooses "remember me". - // otherwise we rely upon expiration configured in cookie middleware. - await HttpContext.SignInAsync(user, _roleManager, model.RememberMe, _dbContext); - var authResult = await HttpContext.AuthenticateAsync(); - if (!authResult.Succeeded) - { - return this.Unauthorized(); - } - String bearer = await HttpContext.GetTokenAsync("Bearer", "Bearer"); - HttpContext.Response.Cookies.Append("Bearer", bearer); - - if (context != null) - { - if (context.IsNativeClient()) - { - // The client is native, so this change in how to - // return the response is for better UX for the end user. - return this.LoadingPage("Redirect", model.ReturnUrl); - } - - // we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null - return Redirect(model.ReturnUrl); - } - - // request for a local page - if (Url.IsLocalUrl(model.ReturnUrl)) - { - return Redirect(model.ReturnUrl); - } - else if (string.IsNullOrEmpty(model.ReturnUrl)) - { - return Redirect("~/"); - } - else - { - // user might have clicked on a malicious link - should be logged - throw new Exception("invalid return URL"); - } - } - } - - await _events.RaiseAsync(new UserLoginFailureEvent(model.UserName, "invalid credentials", clientId: context?.Client.ClientId)); - ModelState.AddModelError(string.Empty, AccountOptions.InvalidCredentialsErrorMessage); - } - } - else - { - - // Note: the "provider" parameter corresponds to the external - // authentication provider choosen by the user agent. - if (string.IsNullOrEmpty(model.Provider)) - { - _logger.LogWarning("Provider not specified"); - return BadRequest(); - } - - // Instruct the middleware corresponding to the requested external identity - // provider to redirect the user agent to its own authorization endpoint. - // Note: the authenticationScheme parameter must match the value configured in Startup.cs - - // Note: the "returnUrl" parameter corresponds to the endpoint the user agent - // will be redirected to after a successful authentication and not - // the redirect_uri of the requesting client application. - if (string.IsNullOrEmpty(model.ReturnUrl)) - { - _logger.LogWarning("ReturnUrl not specified"); - return BadRequest(); - } - // Note: this still is not the redirect uri given to the third party provider, at building the challenge. - var redirectUrl = Url.Action("ExternalLoginCallback", "Account", new { model.ReturnUrl }, protocol: "https", host: Config.Authority); - var properties = _signInManager.ConfigureExternalAuthenticationProperties(model.Provider, redirectUrl); - // var properties = new AuthenticationProperties{RedirectUri=ReturnUrl}; - return new ChallengeResult(model.Provider, properties); - - } - } - return View(model); - } - /// /// Entry point into the login workflow /// diff --git a/src/Yavsc.Org/Controllers/Accounting/ManageController.cs b/src/Yavsc.Org/Controllers/Accounting/ManageController.cs index 6326726d..e09bbb5a 100644 --- a/src/Yavsc.Org/Controllers/Accounting/ManageController.cs +++ b/src/Yavsc.Org/Controllers/Accounting/ManageController.cs @@ -16,6 +16,7 @@ using Yavsc.Services; using Yavsc.ViewModels.Manage; using Microsoft.AspNetCore.Identity.UI.Services; using Microsoft.AspNetCore.Authorization; +using IdentityServer8; using Yavsc.Server.Helpers; namespace Yavsc.Controllers @@ -524,8 +525,45 @@ namespace Yavsc.Controllers } [HttpGet] - public IActionResult SetAvatar() + public async Task SetAvatar( + [FromServices] IdentityServerTools identityServerTools) { + var currentUser = await GetCurrentUserAsync(); + if (currentUser == null) + { + return Challenge(); + } + + var claims = new List + { + new("sub", currentUser.Id), + new("name", currentUser.UserName ?? currentUser.Email ?? currentUser.Id), + new("scope", "api"), + new("aud", "api") + }; + + // Short-lived token limited to avatar upload from this page. + string avatarAccessToken = string.Empty; + try + { + avatarAccessToken = await identityServerTools.IssueClientJwtAsync( + "postit", + 300, + new[] { "api" }, + new[] { "api" }, + claims); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "IssueClientJwtAsync failed for SetAvatar, trying direct IssueJwtAsync fallback."); + } + + if (string.IsNullOrWhiteSpace(avatarAccessToken)) + { + avatarAccessToken = await identityServerTools.IssueJwtAsync(300, claims); + } + + ViewData["AvatarAccessToken"] = avatarAccessToken; return View(); } diff --git a/src/Yavsc.Org/Views/Manage/SetAvatar.cshtml b/src/Yavsc.Org/Views/Manage/SetAvatar.cshtml index b9292434..a4f842a3 100644 --- a/src/Yavsc.Org/Views/Manage/SetAvatar.cshtml +++ b/src/Yavsc.Org/Views/Manage/SetAvatar.cshtml @@ -1,9 +1,16 @@ @model PerformerProfile @{ ViewBag.Title = "Edit your avatar"; } @{ + var apiBaseUrl = string.IsNullOrWhiteSpace(SiteSettings.Value.ApiUrl) + ? string.Empty + : SiteSettings.Value.ApiUrl.TrimEnd('/'); + var setAvatarUrl = string.IsNullOrEmpty(apiBaseUrl) + ? "/api/v1/account/set-avatar" + : $"{apiBaseUrl}/api/v1/account/set-avatar"; var previewAvatarSrc = string.IsNullOrWhiteSpace(User?.Identity?.Name) ? Yavsc.Constants.DefaultAvatar : $"{Yavsc.Constants.AvatarsPath}/{User.Identity.Name}.png"; + var accessToken = ViewData["AvatarAccessToken"] as string ?? string.Empty; } @section header{ @@ -11,7 +18,12 @@ } @section scripts{ } - +
diff --git a/src/Yavsc.Org/appsettings-org.json b/src/Yavsc.Org/appsettings-org.json index 931b6f17..84a7ffa0 100644 --- a/src/Yavsc.Org/appsettings-org.json +++ b/src/Yavsc.Org/appsettings-org.json @@ -18,6 +18,7 @@ "Authority": "https://[Your domaine name]", "Audience": ["blogs"], "ExternalUrl": "https://[Your domaine name]", + "ApiUrl": "https://[Your API domaine name]", "CorsAllowedOrigins": [ "*" ], diff --git a/src/Yavsc.Server/Helpers/FileSystemHelpers.cs b/src/Yavsc.Server/Helpers/FileSystemHelpers.cs index 91202727..7d9d86ed 100644 --- a/src/Yavsc.Server/Helpers/FileSystemHelpers.cs +++ b/src/Yavsc.Server/Helpers/FileSystemHelpers.cs @@ -179,7 +179,7 @@ namespace Yavsc.Server.Helpers /// /// /// - /// + /// /// /// /// @@ -244,8 +244,24 @@ namespace Yavsc.Server.Helpers public static FileReceivedInfo ReceiveAvatar(this ApplicationUser user, IFormFile formFile) { + if (user == null) throw new ArgumentNullException(nameof(user)); + if (formFile == null) throw new ArgumentNullException(nameof(formFile)); + + var avatarsRequestPath = Config.AvatarsOptions?.RequestPath.ToUriComponent(); + if (string.IsNullOrWhiteSpace(avatarsRequestPath)) + { + avatarsRequestPath = Constants.AvatarsPath; + } + + var avatarsDirectory = Config.SiteSetup?.Avatars; + if (string.IsNullOrWhiteSpace(avatarsDirectory)) + { + avatarsDirectory = "avatars"; + } + Directory.CreateDirectory(avatarsDirectory); + var item = new FileReceivedInfo - (Config.AvatarsOptions.RequestPath.ToUriComponent(), + (avatarsRequestPath, user.UserName + ".png"); using (var org = formFile.OpenReadStream()) @@ -256,15 +272,15 @@ namespace Yavsc.Server.Helpers using var image = new MagickImage(org); image.Resize(size); - image.Write(Path.Combine(Config.SiteSetup.Avatars, item.FileName)); + image.Write(Path.Combine(avatarsDirectory, item.FileName)); size.X = 64; size.Y = 64; image.Resize(size); - image.Write(Path.Combine(Config.SiteSetup.Avatars, user.UserName + ".s.png")); + image.Write(Path.Combine(avatarsDirectory, user.UserName + ".s.png")); size.X = 32; size.Y = 32; image.Resize(size); - image.Write(Path.Combine(Config.SiteSetup.Avatars, user.UserName + ".xs.png")); + image.Write(Path.Combine(avatarsDirectory, user.UserName + ".xs.png")); } diff --git a/src/Yavsc.Server/Helpers/ServiceExtensions.cs b/src/Yavsc.Server/Helpers/ServiceExtensions.cs index 2c421025..06e877be 100644 --- a/src/Yavsc.Server/Helpers/ServiceExtensions.cs +++ b/src/Yavsc.Server/Helpers/ServiceExtensions.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Tokens; @@ -133,6 +134,23 @@ public static class ServiceExtensions (_, _, _, _) => true }; } + + options.Events = new JwtBearerEvents + { + OnMessageReceived = context => + { + // Fallback for clients that cannot reliably attach Authorization header + // on multipart uploads. Restrict query-token support to this endpoint only. + if (string.IsNullOrEmpty(context.Token) + && context.Request.Path.Value?.Contains("/api/v1/account/set-avatar", StringComparison.OrdinalIgnoreCase) == true + && context.Request.Query.TryGetValue("access_token", out var tokenValues)) + { + context.Token = tokenValues.ToString(); + } + + return Task.CompletedTask; + } + }; }); return result; diff --git a/src/Yavsc.Server/Settings/SiteSettings.cs b/src/Yavsc.Server/Settings/SiteSettings.cs index 9267370e..ef74781d 100644 --- a/src/Yavsc.Server/Settings/SiteSettings.cs +++ b/src/Yavsc.Server/Settings/SiteSettings.cs @@ -5,7 +5,7 @@ namespace Yavsc public class SiteSettings { public string Title { get; set; } = "Yavsc"; - + public string Slogan { get; set; } = ""; public string Banner { get; set; } = ""; @@ -27,6 +27,10 @@ namespace Yavsc /// public string ExternalUrl { get; set; } = "http://lua.pschneider.fr"; /// + /// Base URL of the API fronting this site. + /// + public string ApiUrl { get; set; } = ""; + /// /// Must be a fqdn. /// /// From 343538a7eac4bf6da158e1725bad02ab31fc9929 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Fri, 4 Sep 2026 20:25:06 +0100 Subject: [PATCH 274/276] setup the avatar --- src/PostIt/PostIt.Tests/BearerScopeTests.cs | 4 +- src/PostIt/PostIt.Tests/PostAclDialogTests.cs | 6 +- src/PostIt/PostIt.Tests/SettingsLoadTests.cs | 2 +- .../PostIt.Tests/YavscApiClientTests.cs | 8 +- .../Helpers/ServiceCollectionHelpers.cs | 4 +- src/PostIt/PostIt/Services/YavscApiClient.cs | 51 +++++++++ .../ViewModels/Commands/BrushViewModel.cs | 2 +- .../PostIt/ViewModels/Settings/Settings.cs | 12 +- .../PostIt/Views/Commands/BrushPage.axaml | 41 +------ .../PostIt/Views/Commands/RdvPage.axaml | 12 +- src/PostIt/PostIt/Views/SettingsPage.axaml | 33 +++--- src/PostIt/PostIt/Views/SettingsPage.axaml.cs | 60 +++++++++- .../accounting/AccountController.cs | 106 ++++++++++++++++-- src/Yavsc.Org/Views/Manage/SetAvatar.cshtml | 49 ++++++-- 14 files changed, 299 insertions(+), 91 deletions(-) diff --git a/src/PostIt/PostIt.Tests/BearerScopeTests.cs b/src/PostIt/PostIt.Tests/BearerScopeTests.cs index c6bf7d56..984483fc 100644 --- a/src/PostIt/PostIt.Tests/BearerScopeTests.cs +++ b/src/PostIt/PostIt.Tests/BearerScopeTests.cs @@ -67,7 +67,7 @@ public class BearerScopeTests Scopes = userScopes, RedirectUri = "postit://callback", }, - BusinessApiUrl = "https://example.invalid/api/v1/", + ApiUrl = "https://example.invalid/api/v1/", }; var tokensPath = Path.Combine( @@ -266,7 +266,7 @@ public class BearerScopeTests // private HttpClient is independent, so we resolve the // absolute URI ourselves from Settings.BusinessApiUrl — // the same URL BlogApiClient would have set as BaseAddress. - var absolute = new Uri(new Uri(Settings.BusinessApiUrl), path); + var absolute = new Uri(new Uri(Settings.ApiUrl), path); using var req = new HttpRequestMessage(method, absolute); req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken); diff --git a/src/PostIt/PostIt.Tests/PostAclDialogTests.cs b/src/PostIt/PostIt.Tests/PostAclDialogTests.cs index dd277629..e7175203 100644 --- a/src/PostIt/PostIt.Tests/PostAclDialogTests.cs +++ b/src/PostIt/PostIt.Tests/PostAclDialogTests.cs @@ -95,7 +95,7 @@ public class PostAclDialogTests HttpMethod method, string path, object? body = null, CancellationToken ct = default) { - var absolute = new Uri(new Uri(Settings.BusinessApiUrl), path); + var absolute = new Uri(new Uri(Settings.ApiUrl), path); using var req = new HttpRequestMessage(method, absolute); using var resp = _http.SendAsync(req, ct).GetAwaiter().GetResult(); resp.EnsureSuccessStatusCode(); @@ -123,8 +123,8 @@ public class PostAclDialogTests var handler = new CountingHttpHandler(); var settings = new Settings(); var api = new TestableYavscApiClient(settings, new TokenStore(System.IO.Path.GetTempFileName()), handler); - var aclClient = new BlogAclApiClient(api, settings.BusinessApiUrl); - var circleClient = new CircleApiClient(api, settings.BusinessApiUrl); + var aclClient = new BlogAclApiClient(api, settings.ApiUrl); + var circleClient = new CircleApiClient(api, settings.ApiUrl); var services = new ServiceCollection(); services.AddSingleton(settings); diff --git a/src/PostIt/PostIt.Tests/SettingsLoadTests.cs b/src/PostIt/PostIt.Tests/SettingsLoadTests.cs index 3f350240..3f8dc02b 100644 --- a/src/PostIt/PostIt.Tests/SettingsLoadTests.cs +++ b/src/PostIt/PostIt.Tests/SettingsLoadTests.cs @@ -89,7 +89,7 @@ public class SettingsLoadTests settings.Authentication.RedirectUri = global::AuthenticationSettings.DesktopRedirectUri; - settings.BusinessApiUrl = flip + settings.ApiUrl = flip ? "https://a.example.test/api/v1/" : "https://b.example.test/api/v1/"; diff --git a/src/PostIt/PostIt.Tests/YavscApiClientTests.cs b/src/PostIt/PostIt.Tests/YavscApiClientTests.cs index 21c0dd00..b074de66 100644 --- a/src/PostIt/PostIt.Tests/YavscApiClientTests.cs +++ b/src/PostIt/PostIt.Tests/YavscApiClientTests.cs @@ -58,7 +58,7 @@ public class YavscApiClientTests // calls CallAsync("posts", ...) directly (bypassing // BlogApiClient, which is the only thing that would set // it in production). Mirror prod here. - reloaded.Http.BaseAddress = new Uri(settings.BusinessApiUrl); + reloaded.Http.BaseAddress = new Uri(settings.ApiUrl); var posts = await reloaded.CallAsync>( HttpMethod.Get, "posts", TestContext.Current.CancellationToken); @@ -118,7 +118,7 @@ public class YavscApiClientTests RedirectUri = "postit://callback", Scopes = new[] { "openid" }, }, - BusinessApiUrl = "https://127.0.0.1:5003/api/v1", + ApiUrl = "https://127.0.0.1:5003/api/v1", }; var client = new YavscApiClient(settings, new TokenStore(Path.Combine( Path.GetTempPath(), $"postit-tests-noop-{Guid.NewGuid():N}.json"))); @@ -162,7 +162,7 @@ public class YavscApiClientTests RedirectUri = authority.LoopbackRedirectUri, Scopes = new[] { "openid", "profile", "blog" } }, - BusinessApiUrl = apiBaseUrl + ApiUrl = apiBaseUrl }; private static async Task LoginAndPersistAsync( @@ -175,7 +175,7 @@ public class YavscApiClientTests // directly (bypassing BlogApiClient) rely on the same // BaseAddress the production chain sets in BlogApiClient's // ctor. Mirror that here so "posts" resolves to the stub. - client.Http.BaseAddress = new Uri(settings.BusinessApiUrl); + client.Http.BaseAddress = new Uri(settings.ApiUrl); // Force the API client to use the test browser by routing the // LoginInteractiveAsync call through a small wrapper. diff --git a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs index ff532b2b..1b71e873 100644 --- a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs +++ b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs @@ -24,8 +24,8 @@ public static class ServiceCollectionHelpers var circleClient = new CircleApiClient(api, settings.BlogsApiUrl); var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl); var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl); - var activityClient = new ActivityApiClient(api, settings.BusinessApiUrl); - var billingClient = new BillingApiClient(api, settings.BusinessApiUrl); + var activityClient = new ActivityApiClient(api, settings.ApiUrl); + var billingClient = new BillingApiClient(api, settings.ApiUrl); var userDirectory = new UserDirectory(userSearchClient); // Vues diff --git a/src/PostIt/PostIt/Services/YavscApiClient.cs b/src/PostIt/PostIt/Services/YavscApiClient.cs index 726b3f4f..f08bcc08 100644 --- a/src/PostIt/PostIt/Services/YavscApiClient.cs +++ b/src/PostIt/PostIt/Services/YavscApiClient.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -351,6 +352,56 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable _store.Save(_tokens); } + /// + /// Upload a user avatar to the Yavsc API. The server expects a + /// single multipart file named file and validates the image + /// content type before persisting it. + /// + public async Task SetAvatarAsync( + Stream imageStream, + string fileName, + string? contentType = null, + CancellationToken ct = default) + { + if (imageStream is null) + throw new ArgumentNullException(nameof(imageStream)); + if (string.IsNullOrWhiteSpace(fileName)) + throw new ArgumentException("A file name is required.", nameof(fileName)); + + var endpoint = new Uri(new Uri(Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "account/set-avatar"); + + await EnsureFreshTokenAsync(ct).ConfigureAwait(false); + + var attemptUpload = async () => + { + if (imageStream.CanSeek) + imageStream.Position = 0; + + using var content = new MultipartFormDataContent(); + using var fileContent = new StreamContent(imageStream); + fileContent.Headers.ContentType = new MediaTypeHeaderValue( + string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType); + content.Add(fileContent, "file", fileName); + + using var request = new HttpRequestMessage(HttpMethod.Post, endpoint) + { + Content = content, + }; + + return await Http.SendAsync(request, ct).ConfigureAwait(false); + }; + + var response = await attemptUpload().ConfigureAwait(false); + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + response.Dispose(); + await ForceRefreshAsync(ct).ConfigureAwait(false); + response = await attemptUpload().ConfigureAwait(false); + } + + await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false); + } + public async Task LogoutAsync() { _store.Clear(); diff --git a/src/PostIt/PostIt/ViewModels/Commands/BrushViewModel.cs b/src/PostIt/PostIt/ViewModels/Commands/BrushViewModel.cs index 03dab60e..d49fde80 100644 --- a/src/PostIt/PostIt/ViewModels/Commands/BrushViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Commands/BrushViewModel.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using Yavsc.Abstract.Workflow; using Yavsc.Api.Client; +using Yavsc.Models.Billing; using Yavsc.Models.Haircut; namespace PostIt.ViewModels.Commands; @@ -21,7 +22,6 @@ public partial class BrushViewModel : RdvViewModel [ObservableProperty] public partial HairPrestationDto? SelectedPrestation { get; set; } - public BrushViewModel(ActivityInfo activity, ActivityUserDisplayItem performer, CommandFormSummary form, BillingApiClient billingClient) : base(activity, performer, form, billingClient) { diff --git a/src/PostIt/PostIt/ViewModels/Settings/Settings.cs b/src/PostIt/PostIt/ViewModels/Settings/Settings.cs index 0c7a040b..e63f0b32 100644 --- a/src/PostIt/PostIt/ViewModels/Settings/Settings.cs +++ b/src/PostIt/PostIt/ViewModels/Settings/Settings.cs @@ -26,7 +26,7 @@ public partial class Settings : ViewModelBase public partial string BlogsApiUrl { get; set; } = "https://blogs.pschneider.fr/api/v1/"; [ObservableProperty] - public partial string BusinessApiUrl { get; set; } = "https://api.pschneider.fr/api/v1/"; + public partial string ApiUrl { get; set; } = "https://api.pschneider.fr/api/v1/"; [ObservableProperty] public partial string SearchText { get; set; } = string.Empty; @@ -45,7 +45,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 OnApiUrlChanged(string value) => MarkDirty(); partial void OnSearchTextChanged(string value) => MarkDirty(); /// @@ -306,9 +306,9 @@ public partial class Settings : ViewModelBase this.BlogsApiUrl = !string.IsNullOrWhiteSpace(settings.BlogsApiUrl) ? settings.BlogsApiUrl : legacyApiUrl ?? this.BlogsApiUrl; - this.BusinessApiUrl = !string.IsNullOrWhiteSpace(settings.BusinessApiUrl) - ? settings.BusinessApiUrl - : this.BusinessApiUrl; + this.ApiUrl = !string.IsNullOrWhiteSpace(settings.ApiUrl) + ? settings.ApiUrl + : this.ApiUrl; this.SearchText = settings.SearchText ?? string.Empty; if (!(settings.Authentication is null)) { @@ -384,7 +384,7 @@ public partial class Settings : ViewModelBase }; this.DarkMode = false; this.BlogsApiUrl = "https://blogs.pschneider.fr/api/v1/"; - this.BusinessApiUrl = "https://api.pschneider.fr/api/v1/"; + this.ApiUrl = "https://api.pschneider.fr/api/v1/"; this.SearchText = string.Empty; } diff --git a/src/PostIt/PostIt/Views/Commands/BrushPage.axaml b/src/PostIt/PostIt/Views/Commands/BrushPage.axaml index 75397abd..9c33cecb 100644 --- a/src/PostIt/PostIt/Views/Commands/BrushPage.axaml +++ b/src/PostIt/PostIt/Views/Commands/BrushPage.axaml @@ -33,13 +33,11 @@ + Margin="0,0,12,8" /> + Margin="0,0,0,8" /> @@ -60,14 +58,12 @@ + Margin="0,0,12,8" /> + Margin="0,0,0,8"> @@ -78,39 +74,14 @@ - - - - - - - - - - - - - - + Margin="0,0,12,8" /> + Margin="0,0,0,8" /> + Margin="0,0,12,8" /> + Margin="0,0,0,8" /> @@ -57,13 +55,11 @@ + Margin="0,0,12,8" /> + Margin="0,0,0,8" /> - + + Text="{Binding ApiUrl, Mode=TwoWay}"/> - - - public async Task SetAvatarAsync( + public async Task SetAvatarAsync( Stream imageStream, string fileName, string? contentType = null, @@ -400,6 +400,27 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable } await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false); + + var payload = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(payload)) + return "Avatar mis à jour."; + + try + { + using var json = JsonDocument.Parse(payload); + if (json.RootElement.TryGetProperty("message", out var msgEl)) + { + var message = msgEl.GetString(); + if (!string.IsNullOrWhiteSpace(message)) + return message; + } + } + catch (JsonException) + { + // Keep a user-friendly fallback when the API payload is not JSON. + } + + return "Avatar mis à jour."; } public async Task LogoutAsync() diff --git a/src/PostIt/PostIt/Views/SettingsPage.axaml b/src/PostIt/PostIt/Views/SettingsPage.axaml index 3c6d7410..611b8995 100644 --- a/src/PostIt/PostIt/Views/SettingsPage.axaml +++ b/src/PostIt/PostIt/Views/SettingsPage.axaml @@ -46,8 +46,18 @@ - -