load the ACL

This commit is contained in:
Paul Schneider 2026-08-30 19:37:55 +01:00
commit 0241dd98f0
Signed by: notazof
GPG key ID: 1DD5D838E5343B06
14 changed files with 438 additions and 174 deletions

View file

@ -8,8 +8,6 @@
<AvaloniaVersionBase>12.1.1</AvaloniaVersionBase>
</PropertyGroup>
<ItemGroup>
<!-- Avalonia packages -->
<!-- Important: keep version in sync! -->
<PackageVersion Include="Avalonia" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Desktop" Version="$(AvaloniaVersionBase)" />
@ -21,16 +19,12 @@
<PackageVersion Include="Material.Avalonia" Version="3.19.0" />
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.10.0.1" />
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0" />
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Runtime" Version="2.10.0.1" />
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Common" Version="2.10.0.1" />
<PackageVersion Include="Xamarin.UITest" Version="4.4.2" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" />

View file

@ -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<BlogPostDto>(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 _));
}
}

View file

@ -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<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
CallCount++;
if (typeof(T) == typeof(List<CircleDto>))
{
var circles = new List<CircleDto>
{
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;
}
}

View file

@ -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]

View file

@ -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<PostAccessControlRulePayload>
public partial ObservableCollection<CircleAuthorization>
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<CircleAuthorization>(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<CircleDto>();
MyCircles = new ObservableCollection<CircleDto>(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<PostAccessControlRulePayload>())
.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<CircleAuthorization>(currentPostAcl);
}
}

View file

@ -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<CircleAuthorization> ACL { get; set; } = new List<CircleAuthorization>();
public ICollection<CircleAuthorization> ACL = new List<CircleAuthorization>();
/// <summary>
/// Wire-only ACL bridge for System.Text.Json: accepts the
/// <c>acl</c>/<c>ACL</c> payload from GET detail responses,
/// but is never emitted on POST/PUT from the client.
/// </summary>
[JsonPropertyName("acl")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public List<CircleAuthorization>? WireAcl
{
get => null;
set => ACL = value ?? new List<CircleAuthorization>();
}
public string[] Tags { get; set; }
ICollection<CircleAuthorization> ICircleAuthorized.ACL => this.ACL;
public string[] GetTags() => Tags;
public ICircleAuthorization[] GetACL() => ACL.ToArray();
public CircleAuthorization[] GetACL() => ACL.ToArray();
}

View file

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

View file

@ -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 <c>GET /api/circle</c>.</para>
/// </summary>
public sealed class CircleAuthorization : ICircleAuthorization
public class CircleAuthorization
{
public long CircleId { get; set; }
}

View file

@ -1,8 +0,0 @@
namespace Yavsc.Abstract.Identity.Security
{
public interface ICircleAuthorization
{
long CircleId { get; set; }
}
}

View file

@ -9,7 +9,7 @@ namespace Yavsc.Abstract.Identity.Security
bool AuthorizeCircle(long circleId);
ICircleAuthorization [] GetACL();
ICollection<CircleAuthorization> ACL { get; } //ICircleAuthorization [] GetACL();
}
}

View file

@ -283,8 +283,8 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
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<BlogsWebServerFixture>
));
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<BlogPost>(
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());
}
}

View file

@ -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<IEnumerable<IBlogPost>> Index(ClaimsPrincipal user, string id, int skip = 0, int take = 25)
{
string? viewerId = user.Identity?.IsAuthenticated == true ? user.GetUserId() : null;
IEnumerable<IBlogPost> 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<Yavsc.Models.Blog.BlogPost>())
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<Yavsc.Models.Blog.BlogPost>();
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<CircleAuthorizationToBlogPost>();
}
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<CircleAuthorizationToBlogPost>();
}
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;
}
}

View file

@ -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<ICircleAuthorization>();
return ACL?.ToArray() ?? Array.Empty<CircleAuthorization>();
}
public void Tag(Tag tag)
@ -134,5 +134,16 @@ namespace Yavsc.Models.Blog
};
}
}
ICollection<CircleAuthorization> ICircleAuthorized.ACL
{
get
{
return ACL?.Select(a => new CircleAuthorization
{
CircleId = a.CircleId
}).ToList() ?? new List<CircleAuthorization>();
}
}
}
}

View file

@ -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<BlogPostEditViewModel> GetPostForEdition(ClaimsPrincipal user, long blogPostId)
{
var blog = await _context.BlogSpot.Include(x => x.Author).Include(x => x.ACL).SingleAsync(m => m.Id == blogPostId);
var auth = await _authorizationService.AuthorizeAsync(user, blog, new EditPermission());
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<Yavsc.Models.Blog.BlogPost> Details(ClaimsPrincipal user, long blogPostId)
public async Task<BlogPost> 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<Yavsc.Models.Blog.BlogPost>())
foreach (var post in materialised.OfType<BlogPost>())
post.IsPublished = publishedSet.Contains(post.Id);
}
foreach (var post in materialised.OfType<BlogPost>())
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<IEnumerable<Yavsc.Models.Blog.BlogPost>> UserPosts(
string posterName,
string? readerId,
int pageLen = 10,
int pageNum = 0)
public async Task<IEnumerable<BlogPost>> UserPosts(string posterName, string? readerId, int pageLen = 10, int pageNum = 0)
{
string? posterId = (await _context.Users.SingleOrDefaultAsync(u => u.UserName == posterName))?.Id ?? null;
if (posterId == null) return Array.Empty<Yavsc.Models.Blog.BlogPost>();
return _context.UserPosts(posterId, readerId);
string? posterId = (await _context.Users.SingleOrDefaultAsync(u => u.UserName == posterName))?.Id;
if (posterId == null) return Array.Empty<BlogPost>();
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<CircleAuthorizationToBlogPost>();
}
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<Yavsc.Models.Blog.BlogPost?> GetBlogPostAsync(long value)
public async Task<BlogPost?> 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);
}
/// <summary>
/// Toggle a post's publication state. <paramref name="publish"/>
/// true adds a row to <c>blogSpotPublications</c> (the post
/// becomes visible to anonymous callers via
/// <see cref="PermissionHandler.IsPublic"/>); false removes
/// the row if present.
///
/// <para>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.</para>
///
/// <para>This is the same toggle the
/// <see cref="BlogPostEditViewModel"/>-flavoured
/// <see cref="Modify(ClaimsPrincipal, BlogPostEditViewModel)"/>
/// 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.</para>
/// </summary>
public async Task<bool> 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<CircleAuthorizationToBlogPost>();
}
private static bool IsOwner(BlogPost post, ClaimsPrincipal? user)
{
if (user?.Identity?.IsAuthenticated != true) return false;
return string.Equals(user.GetUserId(), post.AuthorId, StringComparison.Ordinal);
}
}