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