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);
+ }
}