diff --git a/src/PostIt/Directory.Packages.props b/src/PostIt/Directory.Packages.props index 4dd4b288..0d5fa50c 100644 --- a/src/PostIt/Directory.Packages.props +++ b/src/PostIt/Directory.Packages.props @@ -8,6 +8,8 @@ 12.1.1 + + @@ -19,12 +21,16 @@ + + + + diff --git a/src/PostIt/PostIt.Tests/BlogPostAuthorDtoTests.cs b/src/PostIt/PostIt.Tests/BlogPostAuthorDtoTests.cs index daabdf59..895f220e 100644 --- a/src/PostIt/PostIt.Tests/BlogPostAuthorDtoTests.cs +++ b/src/PostIt/PostIt.Tests/BlogPostAuthorDtoTests.cs @@ -166,51 +166,4 @@ 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 dd277629..ccb33ec7 100644 --- a/src/PostIt/PostIt.Tests/PostAclDialogTests.cs +++ b/src/PostIt/PostIt.Tests/PostAclDialogTests.cs @@ -9,7 +9,6 @@ using PostIt.Services; using PostIt.ViewModels; using PostIt.Views; using Yavsc.Api.Client; -using Yavsc.Api.Client.Dtos; using Yavsc.Blogspot; namespace PostIt.Tests; @@ -191,8 +190,9 @@ public class PostAclDialogTests await Task.Delay(20); } - // Assert: one GET went out (for /circle) from LoadAsync. - Assert.Equal(1, handler.RequestCount); + // Assert: exactly two GETs went out (one to /blogacl, + // one to /circle), both from the LoadAsync call. + Assert.Equal(2, handler.RequestCount); // And the VM's idempotency gate has flipped. Assert.True(vm.Loaded); @@ -218,58 +218,7 @@ public class PostAclDialogTests await vm.LoadAsync(); // Assert: the second call short-circuited on _loaded. - Assert.Equal(1, handler.RequestCount); + Assert.Equal(2, 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/Settings/AuthenticationSettings.cs b/src/PostIt/PostIt/Settings/AuthenticationSettings.cs similarity index 95% rename from src/PostIt/PostIt/ViewModels/Settings/AuthenticationSettings.cs rename to src/PostIt/PostIt/Settings/AuthenticationSettings.cs index 8034820d..9ebeaebd 100644 --- a/src/PostIt/PostIt/ViewModels/Settings/AuthenticationSettings.cs +++ b/src/PostIt/PostIt/Settings/AuthenticationSettings.cs @@ -18,11 +18,11 @@ public partial class AuthenticationSettings : ObservableObject /// public const string AndroidRedirectUri = "android://postit-signin"; - public const string DefaultAuthority = "https://yavsc.pschneider.fr"; + public static string DefaultAuthority { get; internal set; } = "https://yavsc.pschneider.fr"; - public const string DefaultClientId = "postit"; + public static string DefaultClientId { get; internal set; } = "postit"; - public static readonly string[] DefaultScopes = { "blogs" }; + public static string[] DefaultScopes { get; set; } = { "blogs"} ; [ObservableProperty] public partial string Authority { get; set; } diff --git a/src/PostIt/PostIt/ViewModels/MainViewModel.cs b/src/PostIt/PostIt/ViewModels/MainViewModel.cs index f56b666d..84e10cfb 100644 --- a/src/PostIt/PostIt/ViewModels/MainViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/MainViewModel.cs @@ -250,23 +250,7 @@ public partial class MainViewModel : ViewModelBase StatusMessage = "Select an existing post before managing ACL."; return; } - - 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); + await ((App)App.Current!).PushPageAsync(GetACLViewModel(SelectedPost)).ConfigureAwait(true); } [RelayCommand] diff --git a/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs b/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs index 60544606..ae9e71d5 100644 --- a/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs @@ -1,8 +1,6 @@ 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; @@ -10,17 +8,9 @@ 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; -public sealed class PostAclEntry -{ - public long CircleId { get; init; } - public string CircleName { get; init; } = string.Empty; -} - /// /// View model for the "Gérer l'ACL" modal of a single blog post. /// @@ -51,7 +41,7 @@ public partial class PostAclDialogViewModel : ViewModelBase MyCircles { get; set; } = new(); [ObservableProperty] - public partial ObservableCollection + public partial ObservableCollection AclEntries { get; set; } = new(); [ObservableProperty] @@ -87,9 +77,6 @@ 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 => ToAclEntry(a.CircleId))); - SelectedCircleToAdd = null; } public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); } @@ -103,17 +90,16 @@ public partial class PostAclDialogViewModel : ViewModelBase IsBusy = true; try { - // Load circles for the picker. ACL entries come from the - // BlogPostDto detail payload (source of truth for initial state). + // Load circles and ACL entries in parallel — both are + // independent reads on the same host. The caller's uid + // is implicit in both endpoints. var circlesTask = _circleClient.GetMyCirclesAsync(); - await Task.WhenAll(circlesTask); + var aclTask = _aclClient.GetMyAclAsync(); + await Task.WhenAll(circlesTask, aclTask); var circles = circlesTask.Result ?? new List(); MyCircles = new ObservableCollection(circles); - // Resolve labels now that circles are available. - AclEntries = new ObservableCollection(AclEntries.Select(a => ToAclEntry(a.CircleId))); - StatusMessage = $"{AclEntries.Count} autorisation(s)"; _loaded = true; @@ -140,20 +126,14 @@ public partial class PostAclDialogViewModel : ViewModelBase IsBusy = true; try { - if (AclEntries.Any(a => a.CircleId == SelectedCircleToAdd.Id)) - { - StatusMessage = $"Cercle « {SelectedCircleToAdd.Name} » déjà autorisé"; - return; - } - - var created = await _aclClient.GrantAsync(new PostAccessControlRulePayload + var created = await _aclClient.GrantAsync(new Yavsc.Abstract.BlogSpot.PostAccessControlRulePayload { CircleId = SelectedCircleToAdd.Id, BlogPostId = Post.Id }); if (created is not null) { - AclEntries.Add(ToAclEntry(created.CircleId)); + AclEntries.Add(created); StatusMessage = $"Cercle « {SelectedCircleToAdd.Name} » autorisé"; } else @@ -161,13 +141,6 @@ 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}"; @@ -179,16 +152,14 @@ public partial class PostAclDialogViewModel : ViewModelBase } [RelayCommand] - public async Task RevokeAsync(PostAclEntry? acl) + public async Task RevokeAsync(PostAccessControlRulePayload? acl) { if (acl is null) return; IsBusy = true; try { await _aclClient.RevokeAsync(acl.CircleId); - var existing = AclEntries.FirstOrDefault(e => e.CircleId == acl.CircleId); - if (existing is not null) - AclEntries.Remove(existing); + AclEntries.Remove(acl); StatusMessage = "Autorisation révoquée"; } catch (Exception ex) @@ -200,26 +171,4 @@ 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 => ToAclEntry(a.CircleId)) - .GroupBy(a => a.CircleId) - .Select(g => g.First()) - .ToList(); - AclEntries = new ObservableCollection(currentPostAcl); - } - - private PostAclEntry ToAclEntry(long circleId) - { - var circleName = MyCircles.FirstOrDefault(c => c.Id == circleId)?.Name; - return new PostAclEntry - { - CircleId = circleId, - CircleName = string.IsNullOrWhiteSpace(circleName) ? $"Cercle #{circleId}" : circleName - }; - } } diff --git a/src/PostIt/PostIt/ViewModels/Settings/Settings.cs b/src/PostIt/PostIt/ViewModels/Settings.cs similarity index 99% rename from src/PostIt/PostIt/ViewModels/Settings/Settings.cs rename to src/PostIt/PostIt/ViewModels/Settings.cs index f942249b..8586fa34 100644 --- a/src/PostIt/PostIt/ViewModels/Settings/Settings.cs +++ b/src/PostIt/PostIt/ViewModels/Settings.cs @@ -314,7 +314,7 @@ public partial class Settings : ViewModelBase settings.Authentication.Scopes = AuthenticationSettings.DefaultScopes; } else - this.Authentication.Scopes = settings.Authentication.Scopes; + this.Authentication.Scopes = settings.Authentication.Scopes; } } // A disk load (or an embedded-resource fallback) is the diff --git a/src/PostIt/PostIt/Views/PostAclDialog.axaml b/src/PostIt/PostIt/Views/PostAclDialog.axaml index 8552988e..da5320d3 100644 --- a/src/PostIt/PostIt/Views/PostAclDialog.axaml +++ b/src/PostIt/PostIt/Views/PostAclDialog.axaml @@ -3,7 +3,8 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" x:Class="PostIt.Views.PostAclDialog" xmlns:vm="using:PostIt.ViewModels" - xmlns:dtos="using:Yavsc.Api.Client.Dtos" + xmlns:dtos="using:Yavsc.Api.Client.Dtos" + xmlns:yabst="using:Yavsc.Abstract.Identity.Security" x:DataType="vm:PostAclDialogViewModel" > @@ -31,10 +32,10 @@ - + - -public class CircleAuthorization +public sealed class CircleAuthorization : ICircleAuthorization { public long CircleId { get; set; } } diff --git a/src/Yavsc.Abstract/Identity/Security/ICircleAuthorization.cs b/src/Yavsc.Abstract/Identity/Security/ICircleAuthorization.cs new file mode 100644 index 00000000..9c16bd3b --- /dev/null +++ b/src/Yavsc.Abstract/Identity/Security/ICircleAuthorization.cs @@ -0,0 +1,8 @@ +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 6b593f3c..25c21961 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); - ICollection ACL { get; } //ICircleAuthorization [] GetACL(); + ICircleAuthorization [] GetACL(); } } diff --git a/src/Yavsc.Blogs.Tests/BlogAclApiTests.cs b/src/Yavsc.Blogs.Tests/BlogAclApiTests.cs index ab5ebcc6..4aef805f 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.True(doc.RootElement.GetArrayLength() >= 1); - Assert.Contains(doc.RootElement.EnumerateArray(), p => p.GetProperty("id").GetInt64() == created.Id); + Assert.Equal(2, doc.RootElement.GetArrayLength()); + Assert.Equal(created.Id, doc.RootElement[0].GetProperty("id").GetInt64()); // detail should return the same post, with ACL and tags. var detailResponse = await http.GetAsync( @@ -296,86 +296,7 @@ public sealed class BlogAclApiTests : IClassFixture )); Assert.Equal(JsonValueKind.Object, detailDoc.RootElement.ValueKind); Assert.Equal(created.Id, detailDoc.RootElement.GetProperty("id").GetInt64()); - 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()); + Assert.Equal(1, detailDoc.RootElement.GetProperty("acl").GetArrayLength()); } } diff --git a/src/Yavsc.Org/Services/BlogSpotService.cs b/src/Yavsc.Org/Services/BlogSpotService.cs index 39e9329f..7eac07a1 100644 --- a/src/Yavsc.Org/Services/BlogSpotService.cs +++ b/src/Yavsc.Org/Services/BlogSpotService.cs @@ -4,7 +4,6 @@ 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; @@ -98,7 +97,6 @@ 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); } @@ -120,7 +118,6 @@ 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); @@ -192,14 +189,13 @@ 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 viewerIdNonNull = viewerId!; + string viewerId = user.GetUserId(); long[] userCircles = await _context.Circle.Include(c => c.Members). - Where(c => c.Members.Any(m => m.MemberId == viewerIdNonNull)) + Where(c => c.Members.Any(m => m.MemberId == viewerId)) .Select(c => c.Id).ToArrayAsync(); posts = _context.BlogSpot @@ -209,7 +205,7 @@ public class OldBlogSpotService .Include(p => p.Comments) .Where(p => p.ACL == null || p.ACL.Count == 0 - || (p.AuthorId == viewerIdNonNull) + || (p.AuthorId == viewerId) || (userCircles != null && p.ACL.Any(a => userCircles.Contains(a.CircleId))) ); @@ -227,11 +223,7 @@ public class OldBlogSpotService .Select(p => p.BlogPost).ToArray(); } - var materialised = posts.ToList(); - foreach (var post in materialised.OfType()) - ScrubAclForViewer(post, user); - - var data = materialised.OrderByDescending(p => p.DateModified) + var data = posts.OrderByDescending(p => p.DateModified) .Skip(skip) .Take(take); return data; @@ -254,11 +246,7 @@ public class OldBlogSpotService { string? posterId = (await _context.Users.SingleOrDefaultAsync(u => u.UserName == posterName))?.Id ?? null; if (posterId == null) return Array.Empty(); - 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; + return _context.UserPosts(posterId, readerId); } public object? GetTitle(string title) @@ -278,39 +266,4 @@ 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 4c2f2ae5..e5fd615d 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 CircleAuthorization[] GetACL() + public ICircleAuthorization[] GetACL() { - return ACL?.ToArray() ?? Array.Empty(); + return ACL?.ToArray() ?? Array.Empty(); } public void Tag(Tag tag) @@ -134,16 +134,5 @@ 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 a0832630..6b85a4c3 100644 --- a/src/Yavsc.Server/Services/BlogSpotService.cs +++ b/src/Yavsc.Server/Services/BlogSpotService.cs @@ -1,16 +1,15 @@ 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 { @@ -18,25 +17,24 @@ 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 BlogPost Create(string userId, BlogPost post, IFormFileCollection files) + public Yavsc.Models.Blog.BlogPost Create(string userId, Yavsc.Models.Blog.BlogPost post, IFormFileCollection files) { // Sauvegarder le post d'abord pour obtenir son ID - // Le createur vient de l'authentification, donc on ne le prend pas du post + // Le créateur vient de l'authentification, donc on ne le prend pas du post post.AuthorId = userId; _context.BlogSpot.Add(post); _context.SaveChanges(userId); - // Traiter les fichiers attaches s'il y en a + // 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); @@ -44,19 +42,23 @@ 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, @@ -66,6 +68,7 @@ public class BlogSpotService _context.UploadedFiles.Add(uploadedFile); _context.SaveChanges(userId); + // Lier le fichier au post var attachment = new BlogAttachedFile { PostId = post.Id, @@ -78,56 +81,52 @@ public class BlogSpotService } catch (Exception ex) { - Debug.WriteLine($"Erreur lors du traitement des fichiers : {ex.Message}"); + // 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 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) { - 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); - + 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); if (blog == null) + { return null; - - // Hydrate le flag [NotMapped] depuis la table de publication. + } + // Hydrate the [NotMapped] IsPublished flag from the + // publication table so the wire JSON carries it. 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; } @@ -135,45 +134,54 @@ 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 if (blogEdit.Publish) + else { - _context.blogSpotPublications.Add(new BlogSpotPublication { BlogpostId = blogEdit.Id }); + if (blogEdit.Publish) + { + _context.blogSpotPublications.Add( + new BlogSpotPublication + { + BlogpostId = blogEdit.Id + } + ); + } } - _context.SaveChanges(user.GetUserId()); } - public async Task Modify(ClaimsPrincipal user, BlogPost blog) + public async Task Modify(ClaimsPrincipal user, Yavsc.Models.Blog.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; @@ -191,10 +199,9 @@ 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) @@ -202,25 +209,34 @@ 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) { @@ -228,15 +244,11 @@ 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) @@ -245,45 +257,61 @@ public class BlogSpotService public async Task Delete(ClaimsPrincipal user, long id) { - BlogPost blog = _context.BlogSpot.Single(m => m.Id == id); + var uid = user.GetUserId(); + Yavsc.Models.Blog.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; - 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; + string? posterId = (await _context.Users.SingleOrDefaultAsync(u => u.UserName == posterName))?.Id ?? null; + if (posterId == null) return Array.Empty(); + 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(); + 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); @@ -291,33 +319,28 @@ 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); - } }