diff --git a/src/PostIt/Directory.Packages.props b/src/PostIt/Directory.Packages.props index 0d5fa50c..4dd4b288 100644 --- a/src/PostIt/Directory.Packages.props +++ b/src/PostIt/Directory.Packages.props @@ -8,8 +8,6 @@ 12.1.1 - - @@ -21,16 +19,12 @@ - - - - diff --git a/src/PostIt/PostIt.Tests/BlogPostAuthorDtoTests.cs b/src/PostIt/PostIt.Tests/BlogPostAuthorDtoTests.cs index 895f220e..daabdf59 100644 --- a/src/PostIt/PostIt.Tests/BlogPostAuthorDtoTests.cs +++ b/src/PostIt/PostIt.Tests/BlogPostAuthorDtoTests.cs @@ -166,4 +166,51 @@ public class BlogPostAuthorDtoTests Assert.True(root.TryGetProperty("userName", out _)); Assert.True(root.TryGetProperty("avatar", out _)); } + + [Fact] + public void BlogPostDto_deserialises_acl_from_detail_payload() + { + // Detail payload shape emitted by BlogApiController.GetBlog: + // ACL entries are included under "acl"/"ACL". + var json = """ + { + "id": 99, + "title": "ACL test", + "authorId": "u-alice", + "acl": [ + { "circleId": 12, "blogPostId": 99 }, + { "circleId": 34, "blogPostId": 99 } + ] + } + """; + + var post = JsonSerializer.Deserialize(json, CaseInsensitiveJson); + + Assert.NotNull(post); + var acl = post!.GetACL(); + Assert.Equal(2, acl.Length); + Assert.Contains(acl, a => a.CircleId == 12); + Assert.Contains(acl, a => a.CircleId == 34); + } + + [Fact] + public void BlogPostDto_does_not_emit_acl_when_serialized_for_write() + { + var post = new BlogPostDto + { + Id = 77, + Title = "Write payload" + }; + post.AuthorizeCircle(11); + + // The client should not send ACL through POST/PUT blog payloads. + // ACL mutations have their own dedicated /blogacl endpoint. + var json = JsonSerializer.Serialize(post, + new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + Assert.False(root.TryGetProperty("acl", out _)); + Assert.False(root.TryGetProperty("wireAcl", out _)); + } } diff --git a/src/PostIt/PostIt.Tests/PostAclDialogTests.cs b/src/PostIt/PostIt.Tests/PostAclDialogTests.cs index ccb33ec7..dd277629 100644 --- a/src/PostIt/PostIt.Tests/PostAclDialogTests.cs +++ b/src/PostIt/PostIt.Tests/PostAclDialogTests.cs @@ -9,6 +9,7 @@ using PostIt.Services; using PostIt.ViewModels; using PostIt.Views; using Yavsc.Api.Client; +using Yavsc.Api.Client.Dtos; using Yavsc.Blogspot; namespace PostIt.Tests; @@ -190,9 +191,8 @@ public class PostAclDialogTests await Task.Delay(20); } - // Assert: exactly two GETs went out (one to /blogacl, - // one to /circle), both from the LoadAsync call. - Assert.Equal(2, handler.RequestCount); + // Assert: one GET went out (for /circle) from LoadAsync. + Assert.Equal(1, handler.RequestCount); // And the VM's idempotency gate has flipped. Assert.True(vm.Loaded); @@ -218,7 +218,58 @@ public class PostAclDialogTests await vm.LoadAsync(); // Assert: the second call short-circuited on _loaded. - Assert.Equal(2, handler.RequestCount); + Assert.Equal(1, handler.RequestCount); Assert.True(vm.Loaded); } + + [Fact] + public async Task LoadAsync_keeps_acl_from_blogpostdto_and_only_loads_circles() + { + var post = new BlogPostDto { Id = 42, Title = "ACL hydration" }; + post.AuthorizeCircle(12); + post.AuthorizeCircle(34); + + var api = new StubAclApiClient(); + var aclClient = new BlogAclApiClient(api, "http://localhost/"); + var circleClient = new CircleApiClient(api, "http://localhost/"); + var vm = new PostAclDialogViewModel(post, aclClient, circleClient); + + await vm.LoadAsync(); + + Assert.Equal(1, api.CallCount); + Assert.Equal(2, vm.AclEntries.Count); + Assert.Contains(vm.AclEntries, a => a.CircleId == 12); + Assert.Contains(vm.AclEntries, a => a.CircleId == 34); + } + + private sealed class StubAclApiClient : IYavscApiClient + { + public HttpClient Http { get; } = new(); + public int CallCount { get; private set; } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + CallCount++; + + if (typeof(T) == typeof(List)) + { + var circles = new List + { + new() { Id = 12, Name = "A", OwnerId = "owner", Public = false }, + new() { Id = 34, Name = "B", OwnerId = "owner", Public = false }, + }; + return Task.FromResult((T)(object)circles); + } + + return Task.FromResult(default(T)!); + } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + CallCount++; + return Task.CompletedTask; + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } } diff --git a/src/PostIt/PostIt/ViewModels/MainViewModel.cs b/src/PostIt/PostIt/ViewModels/MainViewModel.cs index 84e10cfb..f56b666d 100644 --- a/src/PostIt/PostIt/ViewModels/MainViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/MainViewModel.cs @@ -250,7 +250,23 @@ public partial class MainViewModel : ViewModelBase StatusMessage = "Select an existing post before managing ACL."; return; } - await ((App)App.Current!).PushPageAsync(GetACLViewModel(SelectedPost)).ConfigureAwait(true); + + var postForAcl = SelectedPost; + try + { + var detailed = await BlogClient!.GetPostAsync(SelectedPost.Id).ConfigureAwait(true); + if (detailed is not null) + { + postForAcl = detailed; + SelectedPost = detailed; + } + } + catch + { + // Keep the dialog usable even if the detail refresh fails. + } + + await ((App)App.Current!).PushPageAsync(GetACLViewModel(postForAcl)).ConfigureAwait(true); } [RelayCommand] diff --git a/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs b/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs index ae9e71d5..1c6b8864 100644 --- a/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Linq; +using System.Net; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -8,6 +10,8 @@ using Yavsc.Blogspot; using Yavsc.Api.Client; using Yavsc.Api.Client.Dtos; using Yavsc.Abstract.BlogSpot; +using Yavsc.Abstract.Identity.Security; +using System.Net.Http; namespace PostIt.ViewModels; @@ -41,7 +45,7 @@ public partial class PostAclDialogViewModel : ViewModelBase MyCircles { get; set; } = new(); [ObservableProperty] - public partial ObservableCollection + public partial ObservableCollection AclEntries { get; set; } = new(); [ObservableProperty] @@ -77,6 +81,12 @@ public partial class PostAclDialogViewModel : ViewModelBase Post = post ?? throw new ArgumentNullException(nameof(post)); _aclClient = aclClient ?? throw new ArgumentNullException(nameof(aclClient)); _circleClient = circleClient ?? throw new ArgumentNullException(nameof(circleClient)); + + AclEntries = new ObservableCollection(post.GetACL().Select(a => new CircleAuthorization + { + CircleId = a.CircleId + })); + SelectedCircleToAdd = null; } public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); } @@ -90,12 +100,10 @@ public partial class PostAclDialogViewModel : ViewModelBase IsBusy = true; try { - // Load circles and ACL entries in parallel — both are - // independent reads on the same host. The caller's uid - // is implicit in both endpoints. + // Load circles for the picker. ACL entries come from the + // BlogPostDto detail payload (source of truth for initial state). var circlesTask = _circleClient.GetMyCirclesAsync(); - var aclTask = _aclClient.GetMyAclAsync(); - await Task.WhenAll(circlesTask, aclTask); + await Task.WhenAll(circlesTask); var circles = circlesTask.Result ?? new List(); MyCircles = new ObservableCollection(circles); @@ -126,14 +134,20 @@ public partial class PostAclDialogViewModel : ViewModelBase IsBusy = true; try { - var created = await _aclClient.GrantAsync(new Yavsc.Abstract.BlogSpot.PostAccessControlRulePayload + if (AclEntries.Any(a => a.CircleId == SelectedCircleToAdd.Id)) + { + StatusMessage = $"Cercle « {SelectedCircleToAdd.Name} » déjà autorisé"; + return; + } + + var created = await _aclClient.GrantAsync(new PostAccessControlRulePayload { CircleId = SelectedCircleToAdd.Id, BlogPostId = Post.Id }); if (created is not null) { - AclEntries.Add(created); + AclEntries.Add(new CircleAuthorization { CircleId = created.CircleId }); StatusMessage = $"Cercle « {SelectedCircleToAdd.Name} » autorisé"; } else @@ -141,6 +155,13 @@ public partial class PostAclDialogViewModel : ViewModelBase StatusMessage = "Autorisation refusée par le serveur"; } } + catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.Conflict) + { + // Conflict means the link already exists in backend. Resync + // from the dedicated ACL API so the UI reflects server truth. + await ReloadAclEntriesFromServerAsync(); + StatusMessage = $"Cercle « {SelectedCircleToAdd.Name} » déjà autorisé"; + } catch (Exception ex) { StatusMessage = $"Erreur: {ex.Message}"; @@ -159,7 +180,9 @@ public partial class PostAclDialogViewModel : ViewModelBase try { await _aclClient.RevokeAsync(acl.CircleId); - AclEntries.Remove(acl); + var existing = AclEntries.FirstOrDefault(e => e.CircleId == acl.CircleId); + if (existing is not null) + AclEntries.Remove(existing); StatusMessage = "Autorisation révoquée"; } catch (Exception ex) @@ -171,4 +194,16 @@ public partial class PostAclDialogViewModel : ViewModelBase IsBusy = false; } } + + private async Task ReloadAclEntriesFromServerAsync() + { + var allAcl = await _aclClient.GetMyAclAsync(); + var currentPostAcl = (allAcl ?? new List()) + .Where(a => a.BlogPostId == Post.Id) + .Select(a => new CircleAuthorization { CircleId = a.CircleId }) + .GroupBy(a => a.CircleId) + .Select(g => g.First()) + .ToList(); + AclEntries = new ObservableCollection(currentPostAcl); + } } diff --git a/src/Yavsc.Abstract/Blogspot/BlogPostDto.cs b/src/Yavsc.Abstract/Blogspot/BlogPostDto.cs index 0da052ae..9cb5b143 100644 --- a/src/Yavsc.Abstract/Blogspot/BlogPostDto.cs +++ b/src/Yavsc.Abstract/Blogspot/BlogPostDto.cs @@ -1,4 +1,5 @@ using Yavsc.Abstract.Identity.Security; +using System.Text.Json.Serialization; namespace Yavsc.Blogspot; @@ -35,11 +36,26 @@ public class BlogPostDto : IBlogPost return true; } - private List ACL { get; set; } = new List(); + public ICollection ACL = new List(); + + /// + /// Wire-only ACL bridge for System.Text.Json: accepts the + /// acl/ACL payload from GET detail responses, + /// but is never emitted on POST/PUT from the client. + /// + [JsonPropertyName("acl")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public List? WireAcl + { + get => null; + set => ACL = value ?? new List(); + } public string[] Tags { get; set; } + ICollection ICircleAuthorized.ACL => this.ACL; + public string[] GetTags() => Tags; - public ICircleAuthorization[] GetACL() => ACL.ToArray(); + public CircleAuthorization[] GetACL() => ACL.ToArray(); } diff --git a/src/Yavsc.Abstract/Blogspot/PostAccessControlRulePayload.cs b/src/Yavsc.Abstract/Blogspot/PostAccessControlRulePayload.cs index c58fca48..a42e5428 100644 --- a/src/Yavsc.Abstract/Blogspot/PostAccessControlRulePayload.cs +++ b/src/Yavsc.Abstract/Blogspot/PostAccessControlRulePayload.cs @@ -3,8 +3,7 @@ using Yavsc.Abstract.Identity.Security; namespace Yavsc.Abstract.BlogSpot; -public class PostAccessControlRulePayload : ICircleAuthorization +public class PostAccessControlRulePayload : CircleAuthorization { - public long CircleId { get; set; } public long BlogPostId { get; set; } } diff --git a/src/Yavsc.Abstract/Identity/Security/CircleAuthorization.cs b/src/Yavsc.Abstract/Identity/Security/CircleAuthorization.cs index d8b08c05..96392c7b 100644 --- a/src/Yavsc.Abstract/Identity/Security/CircleAuthorization.cs +++ b/src/Yavsc.Abstract/Identity/Security/CircleAuthorization.cs @@ -11,7 +11,7 @@ namespace Yavsc.Abstract.Identity.Security; /// UI already has the post, and the circles are looked up by id /// against the list returned by GET /api/circle. /// -public sealed class CircleAuthorization : ICircleAuthorization +public class CircleAuthorization { public long CircleId { get; set; } } diff --git a/src/Yavsc.Abstract/Identity/Security/ICircleAuthorization.cs b/src/Yavsc.Abstract/Identity/Security/ICircleAuthorization.cs deleted file mode 100644 index 9c16bd3b..00000000 --- a/src/Yavsc.Abstract/Identity/Security/ICircleAuthorization.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Yavsc.Abstract.Identity.Security -{ - - public interface ICircleAuthorization - { - long CircleId { get; set; } - } -} diff --git a/src/Yavsc.Abstract/Identity/Security/ICircleAuthorized.cs b/src/Yavsc.Abstract/Identity/Security/ICircleAuthorized.cs index 25c21961..6b593f3c 100644 --- a/src/Yavsc.Abstract/Identity/Security/ICircleAuthorized.cs +++ b/src/Yavsc.Abstract/Identity/Security/ICircleAuthorized.cs @@ -9,7 +9,7 @@ namespace Yavsc.Abstract.Identity.Security bool AuthorizeCircle(long circleId); - ICircleAuthorization [] GetACL(); + ICollection ACL { get; } //ICircleAuthorization [] GetACL(); } } diff --git a/src/Yavsc.Blogs.Tests/BlogAclApiTests.cs b/src/Yavsc.Blogs.Tests/BlogAclApiTests.cs index 4aef805f..ab5ebcc6 100644 --- a/src/Yavsc.Blogs.Tests/BlogAclApiTests.cs +++ b/src/Yavsc.Blogs.Tests/BlogAclApiTests.cs @@ -283,8 +283,8 @@ public sealed class BlogAclApiTests : IClassFixture TestContext.Current.CancellationToken )); Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); - Assert.Equal(2, doc.RootElement.GetArrayLength()); - Assert.Equal(created.Id, doc.RootElement[0].GetProperty("id").GetInt64()); + Assert.True(doc.RootElement.GetArrayLength() >= 1); + Assert.Contains(doc.RootElement.EnumerateArray(), p => p.GetProperty("id").GetInt64() == created.Id); // detail should return the same post, with ACL and tags. var detailResponse = await http.GetAsync( @@ -296,7 +296,86 @@ public sealed class BlogAclApiTests : IClassFixture )); Assert.Equal(JsonValueKind.Object, detailDoc.RootElement.ValueKind); Assert.Equal(created.Id, detailDoc.RootElement.GetProperty("id").GetInt64()); - Assert.Equal(1, detailDoc.RootElement.GetProperty("acl").GetArrayLength()); + Assert.True(detailDoc.RootElement.TryGetProperty("acl", out var acl)); + Assert.False(detailDoc.RootElement.TryGetProperty("ACL", out _)); + Assert.Equal(JsonValueKind.Array, acl.ValueKind); + Assert.Equal(1, acl.GetArrayLength()); + + var aclEntry = acl[0]; + Assert.Equal(JsonValueKind.Object, aclEntry.ValueKind); + Assert.True(aclEntry.TryGetProperty("circleId", out var circleId)); + Assert.Equal(_fixture.CircleId, circleId.GetInt64()); + } + + [Fact] + public async Task Non_owner_can_read_restricted_post_but_receives_empty_acl_in_list_and_detail() + { + CleanupAcl(); + _fixture.SeedUser(_fixture.DefaultUserLogin); + _fixture.SeedUser("tester"); + _fixture.SeedCircle(_fixture.DefaultUserLogin, "test", false, + new[] { _fixture.DefaultUserLogin, "tester" }); + + using var ownerHttp = NewClient(_fixture.DefaultUserLogin); + using var readerHttp = NewClient("tester"); + + var draft = new BlogPost + { + Id = 0, + Title = "ACL scrub test", + Article = "Visible to circle member", + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow + }; + + var postResponse = await ownerHttp.PostAsJsonAsync( + BlogUrl(), + draft, + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode); + + var created = await postResponse.Content.ReadFromJsonAsync( + TestContext.Current.CancellationToken); + Assert.NotNull(created); + Assert.NotEqual(0, created!.Id); + + var grantResponse = await ownerHttp.PostAsJsonAsync( + BlogAclUrl(), + new PostAccessControlRulePayload + { + CircleId = _fixture.CircleId, + BlogPostId = created.Id + }, + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.Created, grantResponse.StatusCode); + + var listResponse = await readerHttp.GetAsync( + BlogUrl(), + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); + + using var listDoc = JsonDocument.Parse(await listResponse.Content.ReadAsStringAsync( + TestContext.Current.CancellationToken)); + Assert.Equal(JsonValueKind.Array, listDoc.RootElement.ValueKind); + foreach (var listed in listDoc.RootElement.EnumerateArray()) + { + var authorId = listed.GetProperty("authorId").GetString(); + if (string.Equals(authorId, "tester", StringComparison.Ordinal)) + continue; + + Assert.True(listed.TryGetProperty("acl", out var listedAcl)); + Assert.Equal(0, listedAcl.GetArrayLength()); + } + + var detailResponse = await readerHttp.GetAsync( + $"{BlogUrl()}/{created.Id}", + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode); + + using var detailDoc = JsonDocument.Parse(await detailResponse.Content.ReadAsStringAsync( + TestContext.Current.CancellationToken)); + Assert.True(detailDoc.RootElement.TryGetProperty("acl", out var detailAcl)); + Assert.Equal(0, detailAcl.GetArrayLength()); } } diff --git a/src/Yavsc.Org/Services/BlogSpotService.cs b/src/Yavsc.Org/Services/BlogSpotService.cs index 7eac07a1..39e9329f 100644 --- a/src/Yavsc.Org/Services/BlogSpotService.cs +++ b/src/Yavsc.Org/Services/BlogSpotService.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.EntityFrameworkCore; using Yavsc.Blogspot; using Yavsc.Models; +using Yavsc.Models.Access; using Yavsc.Models.Blog; using Yavsc.Server.Exceptions; using Yavsc.Server.Helpers; @@ -97,6 +98,7 @@ public class OldBlogSpotService throw new AuthorizationFailureException(auth); } var pub = await _context.blogSpotPublications.AnyAsync(x => x.BlogpostId == blog.Id); + ScrubAclForViewer(blog, user); return new BlogPostEditViewModel(blog, pub); } @@ -118,6 +120,7 @@ public class OldBlogSpotService { throw new AuthorizationFailureException(auth); } + ScrubAclForViewer(blog, user); foreach (var c in blog.Comments) { c.Author = _context.Users.First(u => u.Id == c.AuthorId); @@ -189,13 +192,14 @@ public class OldBlogSpotService public async Task> Index(ClaimsPrincipal user, string id, int skip = 0, int take = 25) { + string? viewerId = user.Identity?.IsAuthenticated == true ? user.GetUserId() : null; IEnumerable posts; if (user.Identity.IsAuthenticated) { - string viewerId = user.GetUserId(); + string viewerIdNonNull = viewerId!; long[] userCircles = await _context.Circle.Include(c => c.Members). - Where(c => c.Members.Any(m => m.MemberId == viewerId)) + Where(c => c.Members.Any(m => m.MemberId == viewerIdNonNull)) .Select(c => c.Id).ToArrayAsync(); posts = _context.BlogSpot @@ -205,7 +209,7 @@ public class OldBlogSpotService .Include(p => p.Comments) .Where(p => p.ACL == null || p.ACL.Count == 0 - || (p.AuthorId == viewerId) + || (p.AuthorId == viewerIdNonNull) || (userCircles != null && p.ACL.Any(a => userCircles.Contains(a.CircleId))) ); @@ -223,7 +227,11 @@ public class OldBlogSpotService .Select(p => p.BlogPost).ToArray(); } - var data = posts.OrderByDescending(p => p.DateModified) + var materialised = posts.ToList(); + foreach (var post in materialised.OfType()) + ScrubAclForViewer(post, user); + + var data = materialised.OrderByDescending(p => p.DateModified) .Skip(skip) .Take(take); return data; @@ -246,7 +254,11 @@ public class OldBlogSpotService { string? posterId = (await _context.Users.SingleOrDefaultAsync(u => u.UserName == posterName))?.Id ?? null; if (posterId == null) return Array.Empty(); - return _context.UserPosts(posterId, readerId); + var posts = _context.UserPosts(posterId, readerId).ToList(); + var viewerId = string.Equals(readerId, posterId, StringComparison.Ordinal) ? readerId : null; + foreach (var post in posts) + ScrubAclForViewer(post, viewerId); + return posts; } public object? GetTitle(string title) @@ -266,4 +278,39 @@ public class OldBlogSpotService .SingleOrDefaultAsync(x => x.Id == value); } + private static void ScrubAclForViewer(Yavsc.Models.Blog.BlogPost post, ClaimsPrincipal? user) + { + if (!IsOwner(post, user)) + post.ACL = new List(); + } + + private static void ScrubAclForViewer(Yavsc.Models.Blog.BlogPost post, string? viewerId) + { + if (!string.Equals(post.AuthorId, viewerId, StringComparison.Ordinal) + && !string.Equals(post.Author?.Id, viewerId, StringComparison.Ordinal)) + post.ACL = new List(); + } + + private static bool IsOwner(Yavsc.Models.Blog.BlogPost post, ClaimsPrincipal? user) + { + if (user?.Identity?.IsAuthenticated != true) return false; + + var viewerId = user.GetUserId(); + var viewerName = user.GetUserName() ?? user.Identity?.Name; + + if (!string.IsNullOrWhiteSpace(viewerId)) + { + if (string.Equals(post.AuthorId, viewerId, StringComparison.Ordinal)) return true; + if (string.Equals(post.Author?.Id, viewerId, StringComparison.Ordinal)) return true; + } + + if (!string.IsNullOrWhiteSpace(viewerName)) + { + if (string.Equals(post.AuthorId, viewerName, StringComparison.OrdinalIgnoreCase)) return true; + if (string.Equals(post.Author?.UserName, viewerName, StringComparison.OrdinalIgnoreCase)) return true; + } + + return false; + } + } diff --git a/src/Yavsc.Server/Models/Blog/BlogPost.cs b/src/Yavsc.Server/Models/Blog/BlogPost.cs index e5fd615d..4c2f2ae5 100644 --- a/src/Yavsc.Server/Models/Blog/BlogPost.cs +++ b/src/Yavsc.Server/Models/Blog/BlogPost.cs @@ -66,9 +66,9 @@ namespace Yavsc.Models.Blog return ACL?.Any(i => i.CircleId == circleId) ?? true; } - public ICircleAuthorization[] GetACL() + public CircleAuthorization[] GetACL() { - return ACL?.ToArray() ?? Array.Empty(); + return ACL?.ToArray() ?? Array.Empty(); } public void Tag(Tag tag) @@ -134,5 +134,16 @@ namespace Yavsc.Models.Blog }; } } + + ICollection ICircleAuthorized.ACL + { + get + { + return ACL?.Select(a => new CircleAuthorization + { + CircleId = a.CircleId + }).ToList() ?? new List(); + } + } } } diff --git a/src/Yavsc.Server/Services/BlogSpotService.cs b/src/Yavsc.Server/Services/BlogSpotService.cs index 6b85a4c3..a0832630 100644 --- a/src/Yavsc.Server/Services/BlogSpotService.cs +++ b/src/Yavsc.Server/Services/BlogSpotService.cs @@ -1,15 +1,16 @@ using System.Diagnostics; using System.Security.Claims; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; +using Yavsc.Blogspot; using Yavsc.Models; +using Yavsc.Models.Access; using Yavsc.Models.Blog; using Yavsc.Server.Exceptions; using Yavsc.Server.Helpers; using Yavsc.Services; using Yavsc.ViewModels.Auth; -using Microsoft.AspNetCore.Http; -using Yavsc.Blogspot; public class BlogSpotService { @@ -17,24 +18,25 @@ public class BlogSpotService private readonly IAuthorizationService _authorizationService; private readonly IFileSystemAuthManager fileSystemAuthManager; - public BlogSpotService(ApplicationDbContext context, - IAuthorizationService authorizationService, - IFileSystemAuthManager fileSystemAuthManager) + public BlogSpotService( + ApplicationDbContext context, + IAuthorizationService authorizationService, + IFileSystemAuthManager fileSystemAuthManager) { _authorizationService = authorizationService; _context = context; this.fileSystemAuthManager = fileSystemAuthManager; } - public Yavsc.Models.Blog.BlogPost Create(string userId, Yavsc.Models.Blog.BlogPost post, IFormFileCollection files) + public BlogPost Create(string userId, BlogPost post, IFormFileCollection files) { // Sauvegarder le post d'abord pour obtenir son ID - // Le créateur vient de l'authentification, donc on ne le prend pas du post + // Le createur vient de l'authentification, donc on ne le prend pas du post post.AuthorId = userId; _context.BlogSpot.Add(post); _context.SaveChanges(userId); - // Traiter les fichiers attachés s'il y en a + // Traiter les fichiers attaches s'il y en a if (files != null && files.Count > 0) { var user = _context.Users.FirstOrDefault(u => u.Id == userId); @@ -42,23 +44,19 @@ public class BlogSpotService { 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 - ); + 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, @@ -68,7 +66,6 @@ public class BlogSpotService _context.UploadedFiles.Add(uploadedFile); _context.SaveChanges(userId); - // Lier le fichier au post var attachment = new BlogAttachedFile { PostId = post.Id, @@ -81,52 +78,56 @@ public class BlogSpotService } 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}"); + Debug.WriteLine($"Erreur lors du traitement des fichiers : {ex.Message}"); } } } return post; } + public async Task 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()); + 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); - } + var pub = await _context.blogSpotPublications.AnyAsync(x => x.BlogpostId == blog.Id); + ScrubAclForViewer(blog, user); return new BlogPostEditViewModel(blog, pub); } - public async Task Details(ClaimsPrincipal user, long blogPostId) + public async Task Details(ClaimsPrincipal user, long blogPostId) { - Yavsc.Models.Blog.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); + 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; - } - // Hydrate the [NotMapped] IsPublished flag from the - // publication table so the wire JSON carries it. + + // Hydrate le flag [NotMapped] depuis la table de publication. blog.IsPublished = await _context.blogSpotPublications .AnyAsync(pub => pub.BlogpostId == blogPostId); + var auth = await _authorizationService.AuthorizeAsync(user, blog, new ReadPermission()); if (!auth.Succeeded) - { throw new AuthorizationFailureException(auth); - } + + ScrubAclForViewer(blog, user); + foreach (var c in blog.Comments) - { c.Author = _context.Users.First(u => u.Id == c.AuthorId); - } + return blog; } @@ -134,54 +135,45 @@ public class BlogSpotService { 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); + + var publication = await _context.blogSpotPublications + .SingleOrDefaultAsync(p => p.BlogpostId == blogEdit.Id); + if (publication != null) { if (!blogEdit.Publish) - { _context.blogSpotPublications.Remove(publication); - } } - else + else if (blogEdit.Publish) { - if (blogEdit.Publish) - { - _context.blogSpotPublications.Add( - new BlogSpotPublication - { - BlogpostId = blogEdit.Id - } - ); - } + _context.blogSpotPublications.Add(new BlogSpotPublication { BlogpostId = blogEdit.Id }); } + _context.SaveChanges(user.GetUserId()); } - public async Task Modify(ClaimsPrincipal user, Yavsc.Models.Blog.BlogPost blog) + public async Task Modify(ClaimsPrincipal user, BlogPost blog) { - var existing = await _context.BlogSpot.Include(b => b.ACL).SingleOrDefaultAsync(b => b.Id == blog.Id); + 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; @@ -199,9 +191,10 @@ public class BlogSpotService 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(); + long[] userCircles = await _context.Circle.Include(c => c.Members) + .Where(c => c.Members.Any(m => m.MemberId == viewerId)) + .Select(c => c.Id) + .ToArrayAsync(); posts = _context.BlogSpot .Include(b => b.Author) @@ -209,34 +202,25 @@ public class BlogSpotService .Include(p => p.Tags) .Include(p => p.Comments) .Where(p => p.ACL == null - || p.ACL.Count == 0 - || (p.AuthorId == viewerId) - || (userCircles != null && - p.ACL.Any(a => userCircles.Contains(a.CircleId))) - ); + || p.ACL.Count == 0 + || p.AuthorId == viewerId + || (userCircles != null && p.ACL.Any(a => userCircles.Contains(a.CircleId)))); } else { 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) - .Where(p => p.BlogPost.ACL == null - || p.BlogPost.ACL.Count == 0) - .Select(p => p.BlogPost).ToArray(); + .Include(p => p.BlogPost) + .Include(b => b.BlogPost.Author) + .Include(p => p.BlogPost.ACL) + .Include(p => p.BlogPost.Tags) + .Include(p => p.BlogPost.Comments) + .Where(p => p.BlogPost.ACL == null || p.BlogPost.ACL.Count == 0) + .Select(p => p.BlogPost) + .ToArray(); } - // Materialise before hydrating IsPublished: it's a - // computed [NotMapped] property that needs to be set - // on each BlogPost instance after the query runs. var materialised = posts.ToList(); - // Single bulk lookup for the IsPublished flag — avoid - // the N+1 of one AnyAsync per post. The published ids - // are loaded once and matched against the post list - // in memory. var postIds = materialised.Select(p => p.Id).ToList(); if (postIds.Count > 0) { @@ -244,11 +228,15 @@ public class BlogSpotService .Where(pub => postIds.Contains(pub.BlogpostId)) .Select(pub => pub.BlogpostId) .ToListAsync(); + var publishedSet = publishedIds.ToHashSet(); - foreach (var post in materialised.OfType()) + foreach (var post in materialised.OfType()) post.IsPublished = publishedSet.Contains(post.Id); } + foreach (var post in materialised.OfType()) + ScrubAclForViewer(post, user); + return materialised .OrderByDescending(p => p.DateModified) .Skip(skip) @@ -257,61 +245,45 @@ public class BlogSpotService public async Task Delete(ClaimsPrincipal user, long id) { - var uid = user.GetUserId(); - Yavsc.Models.Blog.BlogPost blog = _context.BlogSpot.Single(m => m.Id == id); - + BlogPost blog = _context.BlogSpot.Single(m => m.Id == id); _context.BlogSpot.Remove(blog); _context.SaveChanges(user.GetUserId()); } - public async Task> UserPosts( - string posterName, - string? readerId, - int pageLen = 10, - int pageNum = 0) + public async Task> 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(); - return _context.UserPosts(posterId, readerId); + string? posterId = (await _context.Users.SingleOrDefaultAsync(u => u.UserName == posterName))?.Id; + if (posterId == null) return Array.Empty(); + + var posts = _context.UserPosts(posterId, readerId).ToList(); + var isOwnerReader = string.Equals(readerId, posterId, StringComparison.Ordinal); + + foreach (var post in posts) + { + if (!isOwnerReader) + post.ACL = new List(); + } + + return posts; } public object? GetTitle(string title) { - return _context.BlogSpot.Include( - b => b.Author - ).Where(x => x.Title == title).OrderByDescending( - x => x.DateCreated - ).ToList(); + return _context.BlogSpot + .Include(b => b.Author) + .Where(x => x.Title == title) + .OrderByDescending(x => x.DateCreated) + .ToList(); } - public async Task GetBlogPostAsync(long value) + public async Task GetBlogPostAsync(long value) { return await _context.BlogSpot - .Include(b => b.Author) - .Include(b => b.ACL) - .SingleOrDefaultAsync(x => x.Id == value); + .Include(b => b.Author) + .Include(b => b.ACL) + .SingleOrDefaultAsync(x => x.Id == value); } - /// - /// Toggle a post's publication state. - /// true adds a row to blogSpotPublications (the post - /// becomes visible to anonymous callers via - /// ); false removes - /// the row if present. - /// - /// The post must already exist (caller must be the - /// author — this is gated by the controller's EditPermission - /// check). Returns false when the post does not exist; true - /// on a successful toggle. - /// - /// This is the same toggle the - /// -flavoured - /// - /// overload performs inline; extracted here so the - /// /api/blog/{id}/publish endpoint can hit it without - /// forcing the caller to round-trip the full BlogPost in - /// the request body. - /// public async Task SetPublishAsync(ClaimsPrincipal user, long postId, bool publish) { var blog = await _context.BlogSpot.SingleOrDefaultAsync(b => b.Id == postId); @@ -319,28 +291,33 @@ public class BlogSpotService var auth = await _authorizationService.AuthorizeAsync(user, blog, new EditPermission()); if (!auth.Succeeded) - { throw new AuthorizationFailureException(auth); - } - var existing = await _context.blogSpotPublications.SingleOrDefaultAsync( - p => p.BlogpostId == postId); + var existing = await _context.blogSpotPublications.SingleOrDefaultAsync(p => p.BlogpostId == postId); if (publish) { if (existing == null) - { _context.blogSpotPublications.Add(new BlogSpotPublication { BlogpostId = postId }); - } } else { if (existing != null) - { _context.blogSpotPublications.Remove(existing); - } } + await _context.SaveChangesAsync(user.GetUserId()); return true; } + private static void ScrubAclForViewer(BlogPost post, ClaimsPrincipal? user) + { + if (!IsOwner(post, user)) + post.ACL = new List(); + } + + private static bool IsOwner(BlogPost post, ClaimsPrincipal? user) + { + if (user?.Identity?.IsAuthenticated != true) return false; + return string.Equals(user.GetUserId(), post.AuthorId, StringComparison.Ordinal); + } }