2026-06-19 21:13:17 +01:00
|
|
|
using System.Diagnostics;
|
|
|
|
|
using System.Security.Claims;
|
|
|
|
|
using Microsoft.AspNetCore.Authorization;
|
|
|
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
|
using Yavsc;
|
|
|
|
|
using Yavsc.Models;
|
|
|
|
|
using Yavsc.Models.Blog;
|
|
|
|
|
using Yavsc.Server.Exceptions;
|
|
|
|
|
using Yavsc.Server.Helpers;
|
|
|
|
|
using Yavsc.Services;
|
|
|
|
|
using Yavsc.ViewModels.Auth;
|
|
|
|
|
using Microsoft.AspNetCore.Http;
|
2026-08-03 01:32:59 +01:00
|
|
|
using Yavsc.Blogspot;
|
2026-06-19 21:13:17 +01:00
|
|
|
|
|
|
|
|
public class BlogSpotService
|
|
|
|
|
{
|
|
|
|
|
private readonly ApplicationDbContext _context;
|
|
|
|
|
private readonly IAuthorizationService _authorizationService;
|
|
|
|
|
private readonly IFileSystemAuthManager fileSystemAuthManager;
|
|
|
|
|
|
|
|
|
|
public BlogSpotService(ApplicationDbContext context,
|
|
|
|
|
IAuthorizationService authorizationService,
|
|
|
|
|
IFileSystemAuthManager fileSystemAuthManager)
|
|
|
|
|
{
|
|
|
|
|
_authorizationService = authorizationService;
|
|
|
|
|
_context = context;
|
|
|
|
|
this.fileSystemAuthManager = fileSystemAuthManager;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public BlogPost Create(string userId, BlogPost post, IFormFileCollection files)
|
|
|
|
|
{
|
|
|
|
|
// Sauvegarder le post d'abord pour obtenir son ID
|
2026-08-02 23:02:54 +01:00
|
|
|
// Le créateur vient de l'authentification, donc on ne le prend pas du post
|
|
|
|
|
post.AuthorId = userId;
|
2026-06-19 21:13:17 +01:00
|
|
|
_context.BlogSpot.Add(post);
|
|
|
|
|
_context.SaveChanges(userId);
|
|
|
|
|
|
|
|
|
|
// Traiter les fichiers attachés s'il y en a
|
|
|
|
|
if (files != null && files.Count > 0)
|
|
|
|
|
{
|
|
|
|
|
var user = _context.Users.FirstOrDefault(u => u.Id == userId);
|
|
|
|
|
if (user != null)
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
// Créer un répertoire pour les fichiers du blog
|
|
|
|
|
string blogFilesSubdir = $"blogs/{post.Id}";
|
|
|
|
|
string destDir = Path.Combine(
|
|
|
|
|
AbstractFileSystemHelpers.UserFilesDirName,
|
|
|
|
|
user.UserName,
|
|
|
|
|
blogFilesSubdir
|
|
|
|
|
);
|
|
|
|
|
var di = new DirectoryInfo(destDir);
|
|
|
|
|
if (!di.Exists) di.Create();
|
|
|
|
|
|
|
|
|
|
// Traiter chaque fichier
|
|
|
|
|
foreach (var formFile in files)
|
|
|
|
|
{
|
|
|
|
|
var fileInfo = user.ReceiveUserFile(destDir, formFile);
|
|
|
|
|
if (fileInfo != null && !fileInfo.QuotaOffense)
|
|
|
|
|
{
|
|
|
|
|
// Créer une entrée UploadedFile si nécessaire
|
|
|
|
|
var uploadedFile = new UploadedFile
|
|
|
|
|
{
|
|
|
|
|
Path = fileInfo.FileName,
|
|
|
|
|
ContentType = formFile.ContentType,
|
|
|
|
|
Length = formFile.Length
|
|
|
|
|
};
|
|
|
|
|
_context.UploadedFiles.Add(uploadedFile);
|
|
|
|
|
_context.SaveChanges(userId);
|
|
|
|
|
|
|
|
|
|
// Lier le fichier au post
|
|
|
|
|
var attachment = new BlogAttachedFile
|
|
|
|
|
{
|
|
|
|
|
PostId = post.Id,
|
|
|
|
|
FileId = uploadedFile.Id
|
|
|
|
|
};
|
|
|
|
|
_context.BlogAttachedFiles.Add(attachment);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_context.SaveChanges(userId);
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
// Logger l'erreur mais ne pas échouer la création du post
|
|
|
|
|
System.Diagnostics.Debug.WriteLine($"Erreur lors du traitement des fichiers : {ex.Message}");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return post;
|
|
|
|
|
}
|
|
|
|
|
public async Task<BlogPostEditViewModel> GetPostForEdition(ClaimsPrincipal user, long blogPostId)
|
|
|
|
|
{
|
|
|
|
|
var blog = await _context.BlogSpot.Include(x => x.Author).Include(x => x.ACL).SingleAsync(m => m.Id == blogPostId);
|
|
|
|
|
var auth = await _authorizationService.AuthorizeAsync(user, blog, new EditPermission());
|
|
|
|
|
if (!auth.Succeeded)
|
|
|
|
|
{
|
|
|
|
|
throw new AuthorizationFailureException(auth);
|
2026-08-02 23:02:54 +01:00
|
|
|
}
|
2026-06-19 21:13:17 +01:00
|
|
|
var pub = await _context.blogSpotPublications.AnyAsync(x => x.BlogpostId == blog.Id);
|
2026-08-02 23:02:54 +01:00
|
|
|
|
2026-06-19 21:13:17 +01:00
|
|
|
return new BlogPostEditViewModel(blog, pub);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<BlogPost> Details(ClaimsPrincipal user, long blogPostId)
|
|
|
|
|
{
|
|
|
|
|
BlogPost blog = await _context.BlogSpot
|
|
|
|
|
.Include(p => p.Author)
|
|
|
|
|
.Include(p => p.Tags)
|
|
|
|
|
.Include(p => p.Comments)
|
|
|
|
|
.Include(p => p.ACL)
|
|
|
|
|
.SingleAsync(m => m.Id == blogPostId);
|
|
|
|
|
if (blog == null)
|
|
|
|
|
{
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
var auth = await _authorizationService.AuthorizeAsync(user, blog, new ReadPermission());
|
|
|
|
|
if (!auth.Succeeded)
|
|
|
|
|
{
|
|
|
|
|
throw new AuthorizationFailureException(auth);
|
|
|
|
|
}
|
|
|
|
|
foreach (var c in blog.Comments)
|
|
|
|
|
{
|
|
|
|
|
c.Author = _context.Users.First(u => u.Id == c.AuthorId);
|
|
|
|
|
}
|
|
|
|
|
return blog;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task Modify(ClaimsPrincipal user, BlogPostEditViewModel blogEdit)
|
|
|
|
|
{
|
|
|
|
|
var blog = _context.BlogSpot.SingleOrDefault(b => b.Id == blogEdit.Id);
|
|
|
|
|
Debug.Assert(blog != null);
|
|
|
|
|
var auth = await _authorizationService.AuthorizeAsync(user, blog, new EditPermission());
|
|
|
|
|
if (!auth.Succeeded)
|
|
|
|
|
{
|
|
|
|
|
throw new AuthorizationFailureException(auth);
|
|
|
|
|
}
|
|
|
|
|
blog.Article = blogEdit.Article;
|
|
|
|
|
blog.Title = blogEdit.Title;
|
|
|
|
|
blog.Photo = blogEdit.Photo;
|
|
|
|
|
blog.ACL = blogEdit.ACL;
|
|
|
|
|
// saves the change
|
|
|
|
|
_context.Update(blog);
|
|
|
|
|
var publication = await _context.blogSpotPublications.SingleOrDefaultAsync
|
|
|
|
|
(p => p.BlogpostId == blogEdit.Id);
|
|
|
|
|
if (publication != null)
|
|
|
|
|
{
|
|
|
|
|
if (!blogEdit.Publish)
|
|
|
|
|
{
|
|
|
|
|
_context.blogSpotPublications.Remove(publication);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
if (blogEdit.Publish)
|
|
|
|
|
{
|
|
|
|
|
_context.blogSpotPublications.Add(
|
|
|
|
|
new BlogSpotPublication
|
|
|
|
|
{
|
|
|
|
|
BlogpostId = blogEdit.Id
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_context.SaveChanges(user.GetUserId());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task Modify(ClaimsPrincipal user, BlogPost blog)
|
|
|
|
|
{
|
|
|
|
|
var existing = await _context.BlogSpot.Include(b => b.ACL).SingleOrDefaultAsync(b => b.Id == blog.Id);
|
|
|
|
|
if (existing == null)
|
|
|
|
|
{
|
|
|
|
|
throw new InvalidOperationException($"Blog post {blog.Id} not found.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var auth = await _authorizationService.AuthorizeAsync(user, existing, new EditPermission());
|
|
|
|
|
if (!auth.Succeeded)
|
|
|
|
|
{
|
|
|
|
|
throw new AuthorizationFailureException(auth);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
existing.Title = blog.Title;
|
|
|
|
|
existing.Article = blog.Article;
|
|
|
|
|
existing.Photo = blog.Photo;
|
|
|
|
|
existing.ACL = blog.ACL;
|
|
|
|
|
|
|
|
|
|
_context.Update(existing);
|
|
|
|
|
_context.SaveChanges(user.GetUserId());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<IEnumerable<IBlogPost>> Index(ClaimsPrincipal user, string id, int skip = 0, int take = 25)
|
|
|
|
|
{
|
|
|
|
|
IEnumerable<IBlogPost> posts;
|
|
|
|
|
|
|
|
|
|
if (user.Identity.IsAuthenticated)
|
|
|
|
|
{
|
|
|
|
|
string viewerId = user.GetUserId();
|
|
|
|
|
long[] userCircles = await _context.Circle.Include(c => c.Members).
|
|
|
|
|
Where(c => c.Members.Any(m => m.MemberId == viewerId))
|
|
|
|
|
.Select(c => c.Id).ToArrayAsync();
|
|
|
|
|
|
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
|
|
|
// Visibility drives the read gate:
|
|
|
|
|
// * Public : the ACL decides. Open if the ACL is
|
|
|
|
|
// empty, narrowed otherwise to author +
|
|
|
|
|
// ACL circles + admin.
|
|
|
|
|
// * Private : ACL is ignored at read time. Only the
|
|
|
|
|
// author (and administrators, checked
|
|
|
|
|
// elsewhere) can read.
|
|
|
|
|
// Admin reads (the Administrator role) go through
|
|
|
|
|
// IsInMsRole("Administrator") upstream in
|
|
|
|
|
// PermissionHandler; we don't repeat that here so the
|
|
|
|
|
// listing query stays role-agnostic.
|
2026-06-19 21:13:17 +01:00
|
|
|
posts = _context.BlogSpot
|
|
|
|
|
.Include(b => b.Author)
|
|
|
|
|
.Include(p => p.ACL)
|
|
|
|
|
.Include(p => p.Tags)
|
|
|
|
|
.Include(p => p.Comments)
|
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
|
|
|
.Where(p =>
|
|
|
|
|
(p.Visibility == Visibility.Private && p.AuthorId == viewerId)
|
|
|
|
|
|| (p.Visibility == Visibility.Public
|
|
|
|
|
&& (p.ACL == null
|
|
|
|
|
|| p.ACL.Count == 0
|
|
|
|
|
|| p.AuthorId == viewerId
|
|
|
|
|
|| (userCircles != null
|
|
|
|
|
&& p.ACL.Any(a => userCircles.Contains(a.CircleId))))));
|
2026-06-19 21:13:17 +01:00
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
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
|
|
|
// Anonymous callers only see Public posts with no
|
|
|
|
|
// ACL — anything else either requires membership
|
|
|
|
|
// (which we have no way to check without an
|
|
|
|
|
// identity) or is Private.
|
2026-06-19 21:13:17 +01:00
|
|
|
posts = _context.blogSpotPublications
|
|
|
|
|
.Include(p => p.BlogPost)
|
|
|
|
|
.Include(b => b.BlogPost.Author)
|
|
|
|
|
.Include(p => p.BlogPost.ACL)
|
|
|
|
|
.Include(p => p.BlogPost.Tags)
|
|
|
|
|
.Include(p => p.BlogPost.Comments)
|
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
|
|
|
.Where(p => p.BlogPost.Visibility == Visibility.Public
|
|
|
|
|
&& (p.BlogPost.ACL == null
|
|
|
|
|
|| p.BlogPost.ACL.Count == 0))
|
2026-06-19 21:13:17 +01:00
|
|
|
.Select(p => p.BlogPost).ToArray();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var data = posts.OrderByDescending(p => p.DateModified)
|
|
|
|
|
.Skip(skip)
|
|
|
|
|
.Take(take);
|
|
|
|
|
return data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task Delete(ClaimsPrincipal user, long id)
|
|
|
|
|
{
|
|
|
|
|
var uid = user.GetUserId();
|
|
|
|
|
BlogPost blog = _context.BlogSpot.Single(m => m.Id == id);
|
|
|
|
|
|
|
|
|
|
_context.BlogSpot.Remove(blog);
|
|
|
|
|
_context.SaveChanges(user.GetUserId());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<IEnumerable<BlogPost>> UserPosts(
|
|
|
|
|
string posterName,
|
|
|
|
|
string? readerId,
|
|
|
|
|
int pageLen = 10,
|
|
|
|
|
int pageNum = 0)
|
|
|
|
|
{
|
|
|
|
|
string? posterId = (await _context.Users.SingleOrDefaultAsync(u => u.UserName == posterName))?.Id ?? null;
|
|
|
|
|
if (posterId == null) return Array.Empty<BlogPost>();
|
|
|
|
|
return _context.UserPosts(posterId, readerId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public object? GetTitle(string title)
|
|
|
|
|
{
|
|
|
|
|
return _context.BlogSpot.Include(
|
|
|
|
|
b => b.Author
|
|
|
|
|
).Where(x => x.Title == title).OrderByDescending(
|
|
|
|
|
x => x.DateCreated
|
|
|
|
|
).ToList();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<BlogPost?> GetBlogPostAsync(long value)
|
|
|
|
|
{
|
|
|
|
|
return await _context.BlogSpot
|
|
|
|
|
.Include(b => b.Author)
|
|
|
|
|
.Include(b => b.ACL)
|
|
|
|
|
.SingleOrDefaultAsync(x => x.Id == value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|