yavsc/src/Yavsc.Server/Helpers/UserHelpers.cs

68 lines
2.6 KiB
C#
Raw Normal View History

2026-06-19 21:13:17 +01:00
using Microsoft.EntityFrameworkCore;
2023-03-19 17:57:55 +00:00
using System.Security.Claims;
feat(blog): add Visibility { Private, Public } to gate post reads Replace the implicit 'ACL empty = private' convention with an explicit two-axis model: Visibility is the master switch, the ACL is the exception list. Semantics (matches what BlogSpotService.Index / Details enforce): Visibility.Public + empty ACL : every caller sees it Visibility.Public + non-empty : only author + ACL circles + admin Visibility.Private + any ACL : only author + admin (ACL ignored) ACL is preserved across Private/Public flips so reopening is lossless The Public+non-empty shape is the 'restrict by exception' case: open by default, narrowed by the ACL. This is intentionally different from the previous behaviour, where a Public post with a non-empty ACL was effectively ACL-restricted anyway — the new model makes that explicit and removes ambiguity. Server (Yavsc.Blogs / Yavsc.Server) - New enum Visibility { Private, Public } in Yavsc.Abstract.Blogspot (so the wire DTO and the EF entity share the same type). Stored as int via .HasConversion<int>() on BlogPost.Visibility. Default Private on construction; the column default in the migration is 0 so existing rows land Private without any data migration. - BlogSpotService.Index: filter rewritten to honour the two- axis model. Authenticated and anonymous callers now share the same shape (Public+emptyACL visible to all, otherwise scoped). Admin reads still go through PermissionHandler. - PermissionHandler.IsPublic: dropped the blogSpotPublications lookup, replaced with the Visibility + empty-ACL check that matches the new model. PermissionHandler.IsSponsor and IsOwner unchanged. - UserHelpers.UserPosts (the per-author feed for /CircleMembers/Details and similar): mirror of the Index filter, so the two code paths can't silently diverge. - BlogPostEditViewModel.Publish untouched on this commit. It still controls whether a row exists in BlogSpotPublication; the two systems coexist (Publish = 'is this draft published', Visibility = 'who can read it'). Follow-up to consolidate. EF migration (Yavsc.Org/Migrations/20260818143013_AddBlogPostVisibility) - Scaffolded by 'dotnet ef migrations add', not hand-edited, per the repo preference for generated migrations. - Adds the new Visibility column (int, NOT NULL, default 0). - Also drops three shadow-state ClientId1 foreign keys and their indexes/columns on ClientScopes, ClientRedirectUris, ClientGrantTypes. These shadow FKs were created by EF from HasOne<Client>().HasForeignKey(e => e.ClientId) mappings that have long since been removed from ApplicationDbContext.OnModelCreating, but the snapshot was never regenerated against the current model. The columns are nullable ints with no production data, so the drop is lossless. Without this, EF Core would keep emitting warnings on every migration add and the model would drift further from reality. DTO wire (Yavsc.Abstract.Blogspot.BlogPost) - Visibility property added to BlogPostDto. System.Text.Json serialises the enum as its underlying int, so the JSON shape is a plain number, no JsonConverter needed. Client UI (PostIt) - MainPageViewModel: DraftVisibility ObservableProperty mirroring the existing DraftTitle/DraftArticle pattern. Initialised to Private so a fresh draft is private by default. Save command writes the chosen value into the BlogPostDto payload for both CreatePostAsync and UpdatePostAsync. OnSelectedPostChanged hydrates the buffer from the server-supplied value. - AllVisibilities property on the VM exposes [Private, Public] in that order, bound by the ComboBox in MainPage.axaml. - VisibilityLabelConverter (PostIt.Views) maps the enum to French user-facing labels ('Privé' / 'Public'); registered in App.axaml as a static resource. - MainPage.axaml: a new ComboBox row in the editor pane between Title and Article. Uses the existing 'no hardcoded Background without Foreground' lesson so dark mode works. Tests (Yavsc.Blogs.Tests) - BlogVisibilityTests (5 [Fact]): drive GET /api/v1/blog with Visibility fixtures seeded directly in the in-memory DB: * Private + ACL: only the author sees it * Public + empty ACL: any authenticated caller sees it * Public + non-empty ACL: caller without ACL membership does NOT see it * Private + ACL: ACL is ignored, only the author sees it * Visibility round-trips through the JSON wire (int 1) - UserHelpersVisibilityTests (4 [Fact]): exercise the helper directly so the two code paths (Index filter vs per-author feed) can't diverge silently. Same fixture, no HTTP. Test totals: 29/29 Yavsc.Blogs.Tests (was 20, +5 BlogVisibility +4 UserHelpersVisibility), 51/51 PostIt.Tests (no change), 44/44 Yavsc.Org.Tests (no change). Out of scope (tracked in MEMORY.md, 2026-08-18): - i18n: only the new 'Visibilité :' label is localised; the rest of MainPage.axaml is still hard-coded French. - BlogPostEditViewModel.Publish ↔ Visibility consolidation (which system wins when both are set on the same post?). - Org-side UI for editing Visibility (the admin web Yavsc still edits posts without a visibility field).
2026-08-18 15:35:22 +01:00
using Yavsc.Blogspot;
2026-06-19 21:13:17 +01:00
using Yavsc.Models;
using Yavsc.Models.Blog;
2017-02-01 19:53:10 +01:00
namespace Yavsc.Server.Helpers
2017-02-01 19:53:10 +01:00
{
public static class UserHelpers
{
2026-06-19 21:13:17 +01:00
public static IEnumerable<BlogPost> UserPosts(this ApplicationDbContext dbContext, string posterId, string? readerId)
{
if (readerId == null)
{
var userPosts = dbContext.blogSpotPublications.Include(
b => b.BlogPost
).Where(x => x.BlogPost.AuthorId == posterId)
.Select(x => x.BlogPost).ToArray();
return userPosts;
}
else
{
long[] readerCirclesMemberships =
dbContext.Circle.Include(c => c.Members)
.Where(c => c.Members.Any(m => m.MemberId == readerId))
.Select(c => c.Id).ToArray();
feat(blog): add Visibility { Private, Public } to gate post reads Replace the implicit 'ACL empty = private' convention with an explicit two-axis model: Visibility is the master switch, the ACL is the exception list. Semantics (matches what BlogSpotService.Index / Details enforce): Visibility.Public + empty ACL : every caller sees it Visibility.Public + non-empty : only author + ACL circles + admin Visibility.Private + any ACL : only author + admin (ACL ignored) ACL is preserved across Private/Public flips so reopening is lossless The Public+non-empty shape is the 'restrict by exception' case: open by default, narrowed by the ACL. This is intentionally different from the previous behaviour, where a Public post with a non-empty ACL was effectively ACL-restricted anyway — the new model makes that explicit and removes ambiguity. Server (Yavsc.Blogs / Yavsc.Server) - New enum Visibility { Private, Public } in Yavsc.Abstract.Blogspot (so the wire DTO and the EF entity share the same type). Stored as int via .HasConversion<int>() on BlogPost.Visibility. Default Private on construction; the column default in the migration is 0 so existing rows land Private without any data migration. - BlogSpotService.Index: filter rewritten to honour the two- axis model. Authenticated and anonymous callers now share the same shape (Public+emptyACL visible to all, otherwise scoped). Admin reads still go through PermissionHandler. - PermissionHandler.IsPublic: dropped the blogSpotPublications lookup, replaced with the Visibility + empty-ACL check that matches the new model. PermissionHandler.IsSponsor and IsOwner unchanged. - UserHelpers.UserPosts (the per-author feed for /CircleMembers/Details and similar): mirror of the Index filter, so the two code paths can't silently diverge. - BlogPostEditViewModel.Publish untouched on this commit. It still controls whether a row exists in BlogSpotPublication; the two systems coexist (Publish = 'is this draft published', Visibility = 'who can read it'). Follow-up to consolidate. EF migration (Yavsc.Org/Migrations/20260818143013_AddBlogPostVisibility) - Scaffolded by 'dotnet ef migrations add', not hand-edited, per the repo preference for generated migrations. - Adds the new Visibility column (int, NOT NULL, default 0). - Also drops three shadow-state ClientId1 foreign keys and their indexes/columns on ClientScopes, ClientRedirectUris, ClientGrantTypes. These shadow FKs were created by EF from HasOne<Client>().HasForeignKey(e => e.ClientId) mappings that have long since been removed from ApplicationDbContext.OnModelCreating, but the snapshot was never regenerated against the current model. The columns are nullable ints with no production data, so the drop is lossless. Without this, EF Core would keep emitting warnings on every migration add and the model would drift further from reality. DTO wire (Yavsc.Abstract.Blogspot.BlogPost) - Visibility property added to BlogPostDto. System.Text.Json serialises the enum as its underlying int, so the JSON shape is a plain number, no JsonConverter needed. Client UI (PostIt) - MainPageViewModel: DraftVisibility ObservableProperty mirroring the existing DraftTitle/DraftArticle pattern. Initialised to Private so a fresh draft is private by default. Save command writes the chosen value into the BlogPostDto payload for both CreatePostAsync and UpdatePostAsync. OnSelectedPostChanged hydrates the buffer from the server-supplied value. - AllVisibilities property on the VM exposes [Private, Public] in that order, bound by the ComboBox in MainPage.axaml. - VisibilityLabelConverter (PostIt.Views) maps the enum to French user-facing labels ('Privé' / 'Public'); registered in App.axaml as a static resource. - MainPage.axaml: a new ComboBox row in the editor pane between Title and Article. Uses the existing 'no hardcoded Background without Foreground' lesson so dark mode works. Tests (Yavsc.Blogs.Tests) - BlogVisibilityTests (5 [Fact]): drive GET /api/v1/blog with Visibility fixtures seeded directly in the in-memory DB: * Private + ACL: only the author sees it * Public + empty ACL: any authenticated caller sees it * Public + non-empty ACL: caller without ACL membership does NOT see it * Private + ACL: ACL is ignored, only the author sees it * Visibility round-trips through the JSON wire (int 1) - UserHelpersVisibilityTests (4 [Fact]): exercise the helper directly so the two code paths (Index filter vs per-author feed) can't diverge silently. Same fixture, no HTTP. Test totals: 29/29 Yavsc.Blogs.Tests (was 20, +5 BlogVisibility +4 UserHelpersVisibility), 51/51 PostIt.Tests (no change), 44/44 Yavsc.Org.Tests (no change). Out of scope (tracked in MEMORY.md, 2026-08-18): - i18n: only the new 'Visibilité :' label is localised; the rest of MainPage.axaml is still hard-coded French. - BlogPostEditViewModel.Publish ↔ Visibility consolidation (which system wins when both are set on the same post?). - Org-side UI for editing Visibility (the admin web Yavsc still edits posts without a visibility field).
2026-08-18 15:35:22 +01:00
// Mirror of BlogSpotService.Index for an
// authenticated reader: Private restricts to the
// author; Public is read-through-ACL.
2026-06-19 21:13:17 +01:00
return dbContext.BlogSpot.Include(
b => b.Author
).Include(p => p.ACL).Where(x => x.Author.Id == posterId &&
feat(blog): add Visibility { Private, Public } to gate post reads Replace the implicit 'ACL empty = private' convention with an explicit two-axis model: Visibility is the master switch, the ACL is the exception list. Semantics (matches what BlogSpotService.Index / Details enforce): Visibility.Public + empty ACL : every caller sees it Visibility.Public + non-empty : only author + ACL circles + admin Visibility.Private + any ACL : only author + admin (ACL ignored) ACL is preserved across Private/Public flips so reopening is lossless The Public+non-empty shape is the 'restrict by exception' case: open by default, narrowed by the ACL. This is intentionally different from the previous behaviour, where a Public post with a non-empty ACL was effectively ACL-restricted anyway — the new model makes that explicit and removes ambiguity. Server (Yavsc.Blogs / Yavsc.Server) - New enum Visibility { Private, Public } in Yavsc.Abstract.Blogspot (so the wire DTO and the EF entity share the same type). Stored as int via .HasConversion<int>() on BlogPost.Visibility. Default Private on construction; the column default in the migration is 0 so existing rows land Private without any data migration. - BlogSpotService.Index: filter rewritten to honour the two- axis model. Authenticated and anonymous callers now share the same shape (Public+emptyACL visible to all, otherwise scoped). Admin reads still go through PermissionHandler. - PermissionHandler.IsPublic: dropped the blogSpotPublications lookup, replaced with the Visibility + empty-ACL check that matches the new model. PermissionHandler.IsSponsor and IsOwner unchanged. - UserHelpers.UserPosts (the per-author feed for /CircleMembers/Details and similar): mirror of the Index filter, so the two code paths can't silently diverge. - BlogPostEditViewModel.Publish untouched on this commit. It still controls whether a row exists in BlogSpotPublication; the two systems coexist (Publish = 'is this draft published', Visibility = 'who can read it'). Follow-up to consolidate. EF migration (Yavsc.Org/Migrations/20260818143013_AddBlogPostVisibility) - Scaffolded by 'dotnet ef migrations add', not hand-edited, per the repo preference for generated migrations. - Adds the new Visibility column (int, NOT NULL, default 0). - Also drops three shadow-state ClientId1 foreign keys and their indexes/columns on ClientScopes, ClientRedirectUris, ClientGrantTypes. These shadow FKs were created by EF from HasOne<Client>().HasForeignKey(e => e.ClientId) mappings that have long since been removed from ApplicationDbContext.OnModelCreating, but the snapshot was never regenerated against the current model. The columns are nullable ints with no production data, so the drop is lossless. Without this, EF Core would keep emitting warnings on every migration add and the model would drift further from reality. DTO wire (Yavsc.Abstract.Blogspot.BlogPost) - Visibility property added to BlogPostDto. System.Text.Json serialises the enum as its underlying int, so the JSON shape is a plain number, no JsonConverter needed. Client UI (PostIt) - MainPageViewModel: DraftVisibility ObservableProperty mirroring the existing DraftTitle/DraftArticle pattern. Initialised to Private so a fresh draft is private by default. Save command writes the chosen value into the BlogPostDto payload for both CreatePostAsync and UpdatePostAsync. OnSelectedPostChanged hydrates the buffer from the server-supplied value. - AllVisibilities property on the VM exposes [Private, Public] in that order, bound by the ComboBox in MainPage.axaml. - VisibilityLabelConverter (PostIt.Views) maps the enum to French user-facing labels ('Privé' / 'Public'); registered in App.axaml as a static resource. - MainPage.axaml: a new ComboBox row in the editor pane between Title and Article. Uses the existing 'no hardcoded Background without Foreground' lesson so dark mode works. Tests (Yavsc.Blogs.Tests) - BlogVisibilityTests (5 [Fact]): drive GET /api/v1/blog with Visibility fixtures seeded directly in the in-memory DB: * Private + ACL: only the author sees it * Public + empty ACL: any authenticated caller sees it * Public + non-empty ACL: caller without ACL membership does NOT see it * Private + ACL: ACL is ignored, only the author sees it * Visibility round-trips through the JSON wire (int 1) - UserHelpersVisibilityTests (4 [Fact]): exercise the helper directly so the two code paths (Index filter vs per-author feed) can't diverge silently. Same fixture, no HTTP. Test totals: 29/29 Yavsc.Blogs.Tests (was 20, +5 BlogVisibility +4 UserHelpersVisibility), 51/51 PostIt.Tests (no change), 44/44 Yavsc.Org.Tests (no change). Out of scope (tracked in MEMORY.md, 2026-08-18): - i18n: only the new 'Visibilité :' label is localised; the rest of MainPage.axaml is still hard-coded French. - BlogPostEditViewModel.Publish ↔ Visibility consolidation (which system wins when both are set on the same post?). - Org-side UI for editing Visibility (the admin web Yavsc still edits posts without a visibility field).
2026-08-18 15:35:22 +01:00
(
(x.Visibility == Visibility.Private && x.AuthorId == readerId)
|| (x.Visibility == Visibility.Public
&& (x.ACL == null
|| x.ACL.Count == 0
|| x.AuthorId == readerId
|| (readerCirclesMemberships != null
&& x.ACL.Any(a => readerCirclesMemberships.Contains(a.CircleId)))))
));
2026-06-19 21:13:17 +01:00
}
}
2026-07-12 01:17:52 +01:00
2023-03-19 17:57:55 +00:00
public static string GetUserId(this ClaimsPrincipal user)
{
return user.FindFirstValue("sub")
?? user.FindFirstValue(ClaimTypes.NameIdentifier)
?? user.FindFirstValue("nameid");
2023-03-19 17:57:55 +00:00
}
2024-12-03 01:44:58 +00:00
2023-03-19 17:57:55 +00:00
public static string GetUserName(this ClaimsPrincipal user)
{
2025-07-10 15:19:28 +01:00
return user.FindFirstValue("name");
2023-03-19 17:57:55 +00:00
}
public static bool IsSignedIn(this ClaimsPrincipal user)
{
return user.Identity.IsAuthenticated;
}
2025-07-31 11:44:02 +01:00
public static bool IsInMsRole(this ClaimsPrincipal user, string roleName)
{
return user.HasClaim("http://schemas.microsoft.com/ws/2008/06/identity/claims/role", roleName);
}
2017-02-01 19:53:10 +01:00
}
}