load the ACL
This commit is contained in:
parent
3a1ce55c0f
commit
0241dd98f0
14 changed files with 438 additions and 174 deletions
|
|
@ -8,8 +8,6 @@
|
||||||
<AvaloniaVersionBase>12.1.1</AvaloniaVersionBase>
|
<AvaloniaVersionBase>12.1.1</AvaloniaVersionBase>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- Avalonia packages -->
|
|
||||||
<!-- Important: keep version in sync! -->
|
|
||||||
<PackageVersion Include="Avalonia" Version="$(AvaloniaVersionBase)" />
|
<PackageVersion Include="Avalonia" Version="$(AvaloniaVersionBase)" />
|
||||||
<PackageVersion Include="Avalonia.Themes.Fluent" Version="$(AvaloniaVersionBase)" />
|
<PackageVersion Include="Avalonia.Themes.Fluent" Version="$(AvaloniaVersionBase)" />
|
||||||
<PackageVersion Include="Avalonia.Desktop" Version="$(AvaloniaVersionBase)" />
|
<PackageVersion Include="Avalonia.Desktop" Version="$(AvaloniaVersionBase)" />
|
||||||
|
|
@ -21,16 +19,12 @@
|
||||||
|
|
||||||
<PackageVersion Include="Material.Avalonia" Version="3.19.0" />
|
<PackageVersion Include="Material.Avalonia" Version="3.19.0" />
|
||||||
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
|
<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="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||||
|
|
||||||
<PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.10.0.1" />
|
<PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.10.0.1" />
|
||||||
|
|
||||||
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0" />
|
<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.Runtime" Version="2.10.0.1" />
|
||||||
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Common" 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="Xamarin.UITest" Version="4.4.2" />
|
||||||
|
|
||||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" />
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" />
|
||||||
|
|
|
||||||
|
|
@ -166,4 +166,51 @@ public class BlogPostAuthorDtoTests
|
||||||
Assert.True(root.TryGetProperty("userName", out _));
|
Assert.True(root.TryGetProperty("userName", out _));
|
||||||
Assert.True(root.TryGetProperty("avatar", 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 _));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ using PostIt.Services;
|
||||||
using PostIt.ViewModels;
|
using PostIt.ViewModels;
|
||||||
using PostIt.Views;
|
using PostIt.Views;
|
||||||
using Yavsc.Api.Client;
|
using Yavsc.Api.Client;
|
||||||
|
using Yavsc.Api.Client.Dtos;
|
||||||
using Yavsc.Blogspot;
|
using Yavsc.Blogspot;
|
||||||
|
|
||||||
namespace PostIt.Tests;
|
namespace PostIt.Tests;
|
||||||
|
|
@ -190,9 +191,8 @@ public class PostAclDialogTests
|
||||||
await Task.Delay(20);
|
await Task.Delay(20);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assert: exactly two GETs went out (one to /blogacl,
|
// Assert: one GET went out (for /circle) from LoadAsync.
|
||||||
// one to /circle), both from the LoadAsync call.
|
Assert.Equal(1, handler.RequestCount);
|
||||||
Assert.Equal(2, handler.RequestCount);
|
|
||||||
|
|
||||||
// And the VM's idempotency gate has flipped.
|
// And the VM's idempotency gate has flipped.
|
||||||
Assert.True(vm.Loaded);
|
Assert.True(vm.Loaded);
|
||||||
|
|
@ -218,7 +218,58 @@ public class PostAclDialogTests
|
||||||
await vm.LoadAsync();
|
await vm.LoadAsync();
|
||||||
|
|
||||||
// Assert: the second call short-circuited on _loaded.
|
// Assert: the second call short-circuited on _loaded.
|
||||||
Assert.Equal(2, handler.RequestCount);
|
Assert.Equal(1, handler.RequestCount);
|
||||||
Assert.True(vm.Loaded);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -250,7 +250,23 @@ public partial class MainViewModel : ViewModelBase
|
||||||
StatusMessage = "Select an existing post before managing ACL.";
|
StatusMessage = "Select an existing post before managing ACL.";
|
||||||
return;
|
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]
|
[RelayCommand]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
|
@ -8,6 +10,8 @@ using Yavsc.Blogspot;
|
||||||
using Yavsc.Api.Client;
|
using Yavsc.Api.Client;
|
||||||
using Yavsc.Api.Client.Dtos;
|
using Yavsc.Api.Client.Dtos;
|
||||||
using Yavsc.Abstract.BlogSpot;
|
using Yavsc.Abstract.BlogSpot;
|
||||||
|
using Yavsc.Abstract.Identity.Security;
|
||||||
|
using System.Net.Http;
|
||||||
|
|
||||||
namespace PostIt.ViewModels;
|
namespace PostIt.ViewModels;
|
||||||
|
|
||||||
|
|
@ -41,7 +45,7 @@ public partial class PostAclDialogViewModel : ViewModelBase
|
||||||
MyCircles { get; set; } = new();
|
MyCircles { get; set; } = new();
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial ObservableCollection<PostAccessControlRulePayload>
|
public partial ObservableCollection<CircleAuthorization>
|
||||||
AclEntries { get; set; } = new();
|
AclEntries { get; set; } = new();
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
|
|
@ -77,6 +81,12 @@ public partial class PostAclDialogViewModel : ViewModelBase
|
||||||
Post = post ?? throw new ArgumentNullException(nameof(post));
|
Post = post ?? throw new ArgumentNullException(nameof(post));
|
||||||
_aclClient = aclClient ?? throw new ArgumentNullException(nameof(aclClient));
|
_aclClient = aclClient ?? throw new ArgumentNullException(nameof(aclClient));
|
||||||
_circleClient = circleClient ?? throw new ArgumentNullException(nameof(circleClient));
|
_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(); }
|
public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); }
|
||||||
|
|
@ -90,12 +100,10 @@ public partial class PostAclDialogViewModel : ViewModelBase
|
||||||
IsBusy = true;
|
IsBusy = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Load circles and ACL entries in parallel — both are
|
// Load circles for the picker. ACL entries come from the
|
||||||
// independent reads on the same host. The caller's uid
|
// BlogPostDto detail payload (source of truth for initial state).
|
||||||
// is implicit in both endpoints.
|
|
||||||
var circlesTask = _circleClient.GetMyCirclesAsync();
|
var circlesTask = _circleClient.GetMyCirclesAsync();
|
||||||
var aclTask = _aclClient.GetMyAclAsync();
|
await Task.WhenAll(circlesTask);
|
||||||
await Task.WhenAll(circlesTask, aclTask);
|
|
||||||
|
|
||||||
var circles = circlesTask.Result ?? new List<CircleDto>();
|
var circles = circlesTask.Result ?? new List<CircleDto>();
|
||||||
MyCircles = new ObservableCollection<CircleDto>(circles);
|
MyCircles = new ObservableCollection<CircleDto>(circles);
|
||||||
|
|
@ -126,14 +134,20 @@ public partial class PostAclDialogViewModel : ViewModelBase
|
||||||
IsBusy = true;
|
IsBusy = true;
|
||||||
try
|
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,
|
CircleId = SelectedCircleToAdd.Id,
|
||||||
BlogPostId = Post.Id
|
BlogPostId = Post.Id
|
||||||
});
|
});
|
||||||
if (created is not null)
|
if (created is not null)
|
||||||
{
|
{
|
||||||
AclEntries.Add(created);
|
AclEntries.Add(new CircleAuthorization { CircleId = created.CircleId });
|
||||||
StatusMessage = $"Cercle « {SelectedCircleToAdd.Name} » autorisé";
|
StatusMessage = $"Cercle « {SelectedCircleToAdd.Name} » autorisé";
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|
@ -141,6 +155,13 @@ public partial class PostAclDialogViewModel : ViewModelBase
|
||||||
StatusMessage = "Autorisation refusée par le serveur";
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
StatusMessage = $"Erreur: {ex.Message}";
|
StatusMessage = $"Erreur: {ex.Message}";
|
||||||
|
|
@ -159,7 +180,9 @@ public partial class PostAclDialogViewModel : ViewModelBase
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _aclClient.RevokeAsync(acl.CircleId);
|
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";
|
StatusMessage = "Autorisation révoquée";
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|
@ -171,4 +194,16 @@ public partial class PostAclDialogViewModel : ViewModelBase
|
||||||
IsBusy = false;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using Yavsc.Abstract.Identity.Security;
|
using Yavsc.Abstract.Identity.Security;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace Yavsc.Blogspot;
|
namespace Yavsc.Blogspot;
|
||||||
|
|
||||||
|
|
@ -35,11 +36,26 @@ public class BlogPostDto : IBlogPost
|
||||||
return true;
|
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; }
|
public string[] Tags { get; set; }
|
||||||
|
|
||||||
|
ICollection<CircleAuthorization> ICircleAuthorized.ACL => this.ACL;
|
||||||
|
|
||||||
public string[] GetTags() => Tags;
|
public string[] GetTags() => Tags;
|
||||||
|
|
||||||
public ICircleAuthorization[] GetACL() => ACL.ToArray();
|
public CircleAuthorization[] GetACL() => ACL.ToArray();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,7 @@ using Yavsc.Abstract.Identity.Security;
|
||||||
|
|
||||||
namespace Yavsc.Abstract.BlogSpot;
|
namespace Yavsc.Abstract.BlogSpot;
|
||||||
|
|
||||||
public class PostAccessControlRulePayload : ICircleAuthorization
|
public class PostAccessControlRulePayload : CircleAuthorization
|
||||||
{
|
{
|
||||||
public long CircleId { get; set; }
|
|
||||||
public long BlogPostId { get; set; }
|
public long BlogPostId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ namespace Yavsc.Abstract.Identity.Security;
|
||||||
/// UI already has the post, and the circles are looked up by id
|
/// UI already has the post, and the circles are looked up by id
|
||||||
/// against the list returned by <c>GET /api/circle</c>.</para>
|
/// against the list returned by <c>GET /api/circle</c>.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class CircleAuthorization : ICircleAuthorization
|
public class CircleAuthorization
|
||||||
{
|
{
|
||||||
public long CircleId { get; set; }
|
public long CircleId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
namespace Yavsc.Abstract.Identity.Security
|
|
||||||
{
|
|
||||||
|
|
||||||
public interface ICircleAuthorization
|
|
||||||
{
|
|
||||||
long CircleId { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -9,7 +9,7 @@ namespace Yavsc.Abstract.Identity.Security
|
||||||
|
|
||||||
bool AuthorizeCircle(long circleId);
|
bool AuthorizeCircle(long circleId);
|
||||||
|
|
||||||
ICircleAuthorization [] GetACL();
|
ICollection<CircleAuthorization> ACL { get; } //ICircleAuthorization [] GetACL();
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -283,8 +283,8 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
|
||||||
TestContext.Current.CancellationToken
|
TestContext.Current.CancellationToken
|
||||||
));
|
));
|
||||||
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
||||||
Assert.Equal(2, doc.RootElement.GetArrayLength());
|
Assert.True(doc.RootElement.GetArrayLength() >= 1);
|
||||||
Assert.Equal(created.Id, doc.RootElement[0].GetProperty("id").GetInt64());
|
Assert.Contains(doc.RootElement.EnumerateArray(), p => p.GetProperty("id").GetInt64() == created.Id);
|
||||||
|
|
||||||
// detail should return the same post, with ACL and tags.
|
// detail should return the same post, with ACL and tags.
|
||||||
var detailResponse = await http.GetAsync(
|
var detailResponse = await http.GetAsync(
|
||||||
|
|
@ -296,7 +296,86 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
|
||||||
));
|
));
|
||||||
Assert.Equal(JsonValueKind.Object, detailDoc.RootElement.ValueKind);
|
Assert.Equal(JsonValueKind.Object, detailDoc.RootElement.ValueKind);
|
||||||
Assert.Equal(created.Id, detailDoc.RootElement.GetProperty("id").GetInt64());
|
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());
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Yavsc.Blogspot;
|
using Yavsc.Blogspot;
|
||||||
using Yavsc.Models;
|
using Yavsc.Models;
|
||||||
|
using Yavsc.Models.Access;
|
||||||
using Yavsc.Models.Blog;
|
using Yavsc.Models.Blog;
|
||||||
using Yavsc.Server.Exceptions;
|
using Yavsc.Server.Exceptions;
|
||||||
using Yavsc.Server.Helpers;
|
using Yavsc.Server.Helpers;
|
||||||
|
|
@ -97,6 +98,7 @@ public class OldBlogSpotService
|
||||||
throw new AuthorizationFailureException(auth);
|
throw new AuthorizationFailureException(auth);
|
||||||
}
|
}
|
||||||
var pub = await _context.blogSpotPublications.AnyAsync(x => x.BlogpostId == blog.Id);
|
var pub = await _context.blogSpotPublications.AnyAsync(x => x.BlogpostId == blog.Id);
|
||||||
|
ScrubAclForViewer(blog, user);
|
||||||
|
|
||||||
return new BlogPostEditViewModel(blog, pub);
|
return new BlogPostEditViewModel(blog, pub);
|
||||||
}
|
}
|
||||||
|
|
@ -118,6 +120,7 @@ public class OldBlogSpotService
|
||||||
{
|
{
|
||||||
throw new AuthorizationFailureException(auth);
|
throw new AuthorizationFailureException(auth);
|
||||||
}
|
}
|
||||||
|
ScrubAclForViewer(blog, user);
|
||||||
foreach (var c in blog.Comments)
|
foreach (var c in blog.Comments)
|
||||||
{
|
{
|
||||||
c.Author = _context.Users.First(u => u.Id == c.AuthorId);
|
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)
|
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;
|
IEnumerable<IBlogPost> posts;
|
||||||
|
|
||||||
if (user.Identity.IsAuthenticated)
|
if (user.Identity.IsAuthenticated)
|
||||||
{
|
{
|
||||||
string viewerId = user.GetUserId();
|
string viewerIdNonNull = viewerId!;
|
||||||
long[] userCircles = await _context.Circle.Include(c => c.Members).
|
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();
|
.Select(c => c.Id).ToArrayAsync();
|
||||||
|
|
||||||
posts = _context.BlogSpot
|
posts = _context.BlogSpot
|
||||||
|
|
@ -205,7 +209,7 @@ public class OldBlogSpotService
|
||||||
.Include(p => p.Comments)
|
.Include(p => p.Comments)
|
||||||
.Where(p => p.ACL == null
|
.Where(p => p.ACL == null
|
||||||
|| p.ACL.Count == 0
|
|| p.ACL.Count == 0
|
||||||
|| (p.AuthorId == viewerId)
|
|| (p.AuthorId == viewerIdNonNull)
|
||||||
|| (userCircles != null &&
|
|| (userCircles != null &&
|
||||||
p.ACL.Any(a => userCircles.Contains(a.CircleId)))
|
p.ACL.Any(a => userCircles.Contains(a.CircleId)))
|
||||||
);
|
);
|
||||||
|
|
@ -223,7 +227,11 @@ public class OldBlogSpotService
|
||||||
.Select(p => p.BlogPost).ToArray();
|
.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)
|
.Skip(skip)
|
||||||
.Take(take);
|
.Take(take);
|
||||||
return data;
|
return data;
|
||||||
|
|
@ -246,7 +254,11 @@ public class OldBlogSpotService
|
||||||
{
|
{
|
||||||
string? posterId = (await _context.Users.SingleOrDefaultAsync(u => u.UserName == posterName))?.Id ?? null;
|
string? posterId = (await _context.Users.SingleOrDefaultAsync(u => u.UserName == posterName))?.Id ?? null;
|
||||||
if (posterId == null) return Array.Empty<Yavsc.Models.Blog.BlogPost>();
|
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)
|
public object? GetTitle(string title)
|
||||||
|
|
@ -266,4 +278,39 @@ public class OldBlogSpotService
|
||||||
.SingleOrDefaultAsync(x => x.Id == value);
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,9 +66,9 @@ namespace Yavsc.Models.Blog
|
||||||
return ACL?.Any(i => i.CircleId == circleId) ?? true;
|
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)
|
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>();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Yavsc.Blogspot;
|
||||||
using Yavsc.Models;
|
using Yavsc.Models;
|
||||||
|
using Yavsc.Models.Access;
|
||||||
using Yavsc.Models.Blog;
|
using Yavsc.Models.Blog;
|
||||||
using Yavsc.Server.Exceptions;
|
using Yavsc.Server.Exceptions;
|
||||||
using Yavsc.Server.Helpers;
|
using Yavsc.Server.Helpers;
|
||||||
using Yavsc.Services;
|
using Yavsc.Services;
|
||||||
using Yavsc.ViewModels.Auth;
|
using Yavsc.ViewModels.Auth;
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
using Yavsc.Blogspot;
|
|
||||||
|
|
||||||
public class BlogSpotService
|
public class BlogSpotService
|
||||||
{
|
{
|
||||||
|
|
@ -17,24 +18,25 @@ public class BlogSpotService
|
||||||
private readonly IAuthorizationService _authorizationService;
|
private readonly IAuthorizationService _authorizationService;
|
||||||
private readonly IFileSystemAuthManager fileSystemAuthManager;
|
private readonly IFileSystemAuthManager fileSystemAuthManager;
|
||||||
|
|
||||||
public BlogSpotService(ApplicationDbContext context,
|
public BlogSpotService(
|
||||||
IAuthorizationService authorizationService,
|
ApplicationDbContext context,
|
||||||
IFileSystemAuthManager fileSystemAuthManager)
|
IAuthorizationService authorizationService,
|
||||||
|
IFileSystemAuthManager fileSystemAuthManager)
|
||||||
{
|
{
|
||||||
_authorizationService = authorizationService;
|
_authorizationService = authorizationService;
|
||||||
_context = context;
|
_context = context;
|
||||||
this.fileSystemAuthManager = fileSystemAuthManager;
|
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
|
// 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;
|
post.AuthorId = userId;
|
||||||
_context.BlogSpot.Add(post);
|
_context.BlogSpot.Add(post);
|
||||||
_context.SaveChanges(userId);
|
_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)
|
if (files != null && files.Count > 0)
|
||||||
{
|
{
|
||||||
var user = _context.Users.FirstOrDefault(u => u.Id == userId);
|
var user = _context.Users.FirstOrDefault(u => u.Id == userId);
|
||||||
|
|
@ -42,23 +44,19 @@ public class BlogSpotService
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Créer un répertoire pour les fichiers du blog
|
|
||||||
string blogFilesSubdir = $"blogs/{post.Id}";
|
string blogFilesSubdir = $"blogs/{post.Id}";
|
||||||
string destDir = Path.Combine(
|
string destDir = Path.Combine(
|
||||||
AbstractFileSystemHelpers.UserFilesDirName,
|
AbstractFileSystemHelpers.UserFilesDirName,
|
||||||
user.UserName,
|
user.UserName,
|
||||||
blogFilesSubdir
|
blogFilesSubdir);
|
||||||
);
|
|
||||||
var di = new DirectoryInfo(destDir);
|
var di = new DirectoryInfo(destDir);
|
||||||
if (!di.Exists) di.Create();
|
if (!di.Exists) di.Create();
|
||||||
|
|
||||||
// Traiter chaque fichier
|
|
||||||
foreach (var formFile in files)
|
foreach (var formFile in files)
|
||||||
{
|
{
|
||||||
var fileInfo = user.ReceiveUserFile(destDir, formFile);
|
var fileInfo = user.ReceiveUserFile(destDir, formFile);
|
||||||
if (fileInfo != null && !fileInfo.QuotaOffense)
|
if (fileInfo != null && !fileInfo.QuotaOffense)
|
||||||
{
|
{
|
||||||
// Créer une entrée UploadedFile si nécessaire
|
|
||||||
var uploadedFile = new UploadedFile
|
var uploadedFile = new UploadedFile
|
||||||
{
|
{
|
||||||
Path = fileInfo.FileName,
|
Path = fileInfo.FileName,
|
||||||
|
|
@ -68,7 +66,6 @@ public class BlogSpotService
|
||||||
_context.UploadedFiles.Add(uploadedFile);
|
_context.UploadedFiles.Add(uploadedFile);
|
||||||
_context.SaveChanges(userId);
|
_context.SaveChanges(userId);
|
||||||
|
|
||||||
// Lier le fichier au post
|
|
||||||
var attachment = new BlogAttachedFile
|
var attachment = new BlogAttachedFile
|
||||||
{
|
{
|
||||||
PostId = post.Id,
|
PostId = post.Id,
|
||||||
|
|
@ -81,52 +78,56 @@ public class BlogSpotService
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Logger l'erreur mais ne pas échouer la création du post
|
Debug.WriteLine($"Erreur lors du traitement des fichiers : {ex.Message}");
|
||||||
System.Diagnostics.Debug.WriteLine($"Erreur lors du traitement des fichiers : {ex.Message}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return post;
|
return post;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<BlogPostEditViewModel> GetPostForEdition(ClaimsPrincipal user, long blogPostId)
|
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 blog = await _context.BlogSpot
|
||||||
var auth = await _authorizationService.AuthorizeAsync(user, blog, new EditPermission());
|
.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)
|
if (!auth.Succeeded)
|
||||||
{
|
|
||||||
throw new AuthorizationFailureException(auth);
|
throw new AuthorizationFailureException(auth);
|
||||||
}
|
|
||||||
var pub = await _context.blogSpotPublications.AnyAsync(x => x.BlogpostId == blog.Id);
|
var pub = await _context.blogSpotPublications.AnyAsync(x => x.BlogpostId == blog.Id);
|
||||||
|
ScrubAclForViewer(blog, user);
|
||||||
|
|
||||||
return new BlogPostEditViewModel(blog, pub);
|
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
|
BlogPost blog = await _context.BlogSpot
|
||||||
.Include(p => p.Author)
|
.Include(p => p.Author)
|
||||||
.Include(p => p.Tags)
|
.Include(p => p.Tags)
|
||||||
.Include(p => p.Comments)
|
.Include(p => p.Comments)
|
||||||
.Include(p => p.ACL)
|
.Include(p => p.ACL)
|
||||||
.SingleAsync(m => m.Id == blogPostId);
|
.SingleAsync(m => m.Id == blogPostId);
|
||||||
|
|
||||||
if (blog == null)
|
if (blog == null)
|
||||||
{
|
|
||||||
return null;
|
return null;
|
||||||
}
|
|
||||||
// Hydrate the [NotMapped] IsPublished flag from the
|
// Hydrate le flag [NotMapped] depuis la table de publication.
|
||||||
// publication table so the wire JSON carries it.
|
|
||||||
blog.IsPublished = await _context.blogSpotPublications
|
blog.IsPublished = await _context.blogSpotPublications
|
||||||
.AnyAsync(pub => pub.BlogpostId == blogPostId);
|
.AnyAsync(pub => pub.BlogpostId == blogPostId);
|
||||||
|
|
||||||
var auth = await _authorizationService.AuthorizeAsync(user, blog, new ReadPermission());
|
var auth = await _authorizationService.AuthorizeAsync(user, blog, new ReadPermission());
|
||||||
if (!auth.Succeeded)
|
if (!auth.Succeeded)
|
||||||
{
|
|
||||||
throw new AuthorizationFailureException(auth);
|
throw new AuthorizationFailureException(auth);
|
||||||
}
|
|
||||||
|
ScrubAclForViewer(blog, user);
|
||||||
|
|
||||||
foreach (var c in blog.Comments)
|
foreach (var c in blog.Comments)
|
||||||
{
|
|
||||||
c.Author = _context.Users.First(u => u.Id == c.AuthorId);
|
c.Author = _context.Users.First(u => u.Id == c.AuthorId);
|
||||||
}
|
|
||||||
return blog;
|
return blog;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -134,54 +135,45 @@ public class BlogSpotService
|
||||||
{
|
{
|
||||||
var blog = _context.BlogSpot.SingleOrDefault(b => b.Id == blogEdit.Id);
|
var blog = _context.BlogSpot.SingleOrDefault(b => b.Id == blogEdit.Id);
|
||||||
Debug.Assert(blog != null);
|
Debug.Assert(blog != null);
|
||||||
|
|
||||||
var auth = await _authorizationService.AuthorizeAsync(user, blog, new EditPermission());
|
var auth = await _authorizationService.AuthorizeAsync(user, blog, new EditPermission());
|
||||||
if (!auth.Succeeded)
|
if (!auth.Succeeded)
|
||||||
{
|
|
||||||
throw new AuthorizationFailureException(auth);
|
throw new AuthorizationFailureException(auth);
|
||||||
}
|
|
||||||
blog.Article = blogEdit.Article;
|
blog.Article = blogEdit.Article;
|
||||||
blog.Title = blogEdit.Title;
|
blog.Title = blogEdit.Title;
|
||||||
blog.Photo = blogEdit.Photo;
|
blog.Photo = blogEdit.Photo;
|
||||||
blog.ACL = blogEdit.ACL;
|
blog.ACL = blogEdit.ACL;
|
||||||
// saves the change
|
|
||||||
_context.Update(blog);
|
_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 (publication != null)
|
||||||
{
|
{
|
||||||
if (!blogEdit.Publish)
|
if (!blogEdit.Publish)
|
||||||
{
|
|
||||||
_context.blogSpotPublications.Remove(publication);
|
_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());
|
_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)
|
if (existing == null)
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"Blog post {blog.Id} not found.");
|
throw new InvalidOperationException($"Blog post {blog.Id} not found.");
|
||||||
}
|
|
||||||
|
|
||||||
var auth = await _authorizationService.AuthorizeAsync(user, existing, new EditPermission());
|
var auth = await _authorizationService.AuthorizeAsync(user, existing, new EditPermission());
|
||||||
if (!auth.Succeeded)
|
if (!auth.Succeeded)
|
||||||
{
|
|
||||||
throw new AuthorizationFailureException(auth);
|
throw new AuthorizationFailureException(auth);
|
||||||
}
|
|
||||||
|
|
||||||
existing.Title = blog.Title;
|
existing.Title = blog.Title;
|
||||||
existing.Article = blog.Article;
|
existing.Article = blog.Article;
|
||||||
|
|
@ -199,9 +191,10 @@ public class BlogSpotService
|
||||||
if (user.Identity.IsAuthenticated)
|
if (user.Identity.IsAuthenticated)
|
||||||
{
|
{
|
||||||
string viewerId = user.GetUserId();
|
string viewerId = user.GetUserId();
|
||||||
long[] userCircles = await _context.Circle.Include(c => c.Members).
|
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 == viewerId))
|
||||||
.Select(c => c.Id).ToArrayAsync();
|
.Select(c => c.Id)
|
||||||
|
.ToArrayAsync();
|
||||||
|
|
||||||
posts = _context.BlogSpot
|
posts = _context.BlogSpot
|
||||||
.Include(b => b.Author)
|
.Include(b => b.Author)
|
||||||
|
|
@ -209,34 +202,25 @@ public class BlogSpotService
|
||||||
.Include(p => p.Tags)
|
.Include(p => p.Tags)
|
||||||
.Include(p => p.Comments)
|
.Include(p => p.Comments)
|
||||||
.Where(p => p.ACL == null
|
.Where(p => p.ACL == null
|
||||||
|| p.ACL.Count == 0
|
|| p.ACL.Count == 0
|
||||||
|| (p.AuthorId == viewerId)
|
|| p.AuthorId == viewerId
|
||||||
|| (userCircles != null &&
|
|| (userCircles != null && p.ACL.Any(a => userCircles.Contains(a.CircleId))));
|
||||||
p.ACL.Any(a => userCircles.Contains(a.CircleId)))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
posts = _context.blogSpotPublications
|
posts = _context.blogSpotPublications
|
||||||
.Include(p => p.BlogPost)
|
.Include(p => p.BlogPost)
|
||||||
.Include(b => b.BlogPost.Author)
|
.Include(b => b.BlogPost.Author)
|
||||||
.Include(p => p.BlogPost.ACL)
|
.Include(p => p.BlogPost.ACL)
|
||||||
.Include(p => p.BlogPost.Tags)
|
.Include(p => p.BlogPost.Tags)
|
||||||
.Include(p => p.BlogPost.Comments)
|
.Include(p => p.BlogPost.Comments)
|
||||||
.Where(p => p.BlogPost.ACL == null
|
.Where(p => p.BlogPost.ACL == null || p.BlogPost.ACL.Count == 0)
|
||||||
|| p.BlogPost.ACL.Count == 0)
|
.Select(p => p.BlogPost)
|
||||||
.Select(p => p.BlogPost).ToArray();
|
.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();
|
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();
|
var postIds = materialised.Select(p => p.Id).ToList();
|
||||||
if (postIds.Count > 0)
|
if (postIds.Count > 0)
|
||||||
{
|
{
|
||||||
|
|
@ -244,11 +228,15 @@ public class BlogSpotService
|
||||||
.Where(pub => postIds.Contains(pub.BlogpostId))
|
.Where(pub => postIds.Contains(pub.BlogpostId))
|
||||||
.Select(pub => pub.BlogpostId)
|
.Select(pub => pub.BlogpostId)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var publishedSet = publishedIds.ToHashSet();
|
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);
|
post.IsPublished = publishedSet.Contains(post.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (var post in materialised.OfType<BlogPost>())
|
||||||
|
ScrubAclForViewer(post, user);
|
||||||
|
|
||||||
return materialised
|
return materialised
|
||||||
.OrderByDescending(p => p.DateModified)
|
.OrderByDescending(p => p.DateModified)
|
||||||
.Skip(skip)
|
.Skip(skip)
|
||||||
|
|
@ -257,61 +245,45 @@ public class BlogSpotService
|
||||||
|
|
||||||
public async Task Delete(ClaimsPrincipal user, long id)
|
public async Task Delete(ClaimsPrincipal user, long id)
|
||||||
{
|
{
|
||||||
var uid = user.GetUserId();
|
BlogPost blog = _context.BlogSpot.Single(m => m.Id == id);
|
||||||
Yavsc.Models.Blog.BlogPost blog = _context.BlogSpot.Single(m => m.Id == id);
|
|
||||||
|
|
||||||
_context.BlogSpot.Remove(blog);
|
_context.BlogSpot.Remove(blog);
|
||||||
_context.SaveChanges(user.GetUserId());
|
_context.SaveChanges(user.GetUserId());
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<Yavsc.Models.Blog.BlogPost>> UserPosts(
|
public async Task<IEnumerable<BlogPost>> UserPosts(string posterName, string? readerId, int pageLen = 10, int pageNum = 0)
|
||||||
string posterName,
|
|
||||||
string? readerId,
|
|
||||||
int pageLen = 10,
|
|
||||||
int pageNum = 0)
|
|
||||||
{
|
{
|
||||||
string? posterId = (await _context.Users.SingleOrDefaultAsync(u => u.UserName == posterName))?.Id ?? null;
|
string? posterId = (await _context.Users.SingleOrDefaultAsync(u => u.UserName == posterName))?.Id;
|
||||||
if (posterId == null) return Array.Empty<Yavsc.Models.Blog.BlogPost>();
|
if (posterId == null) return Array.Empty<BlogPost>();
|
||||||
return _context.UserPosts(posterId, readerId);
|
|
||||||
|
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)
|
public object? GetTitle(string title)
|
||||||
{
|
{
|
||||||
return _context.BlogSpot.Include(
|
return _context.BlogSpot
|
||||||
b => b.Author
|
.Include(b => b.Author)
|
||||||
).Where(x => x.Title == title).OrderByDescending(
|
.Where(x => x.Title == title)
|
||||||
x => x.DateCreated
|
.OrderByDescending(x => x.DateCreated)
|
||||||
).ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Yavsc.Models.Blog.BlogPost?> GetBlogPostAsync(long value)
|
public async Task<BlogPost?> GetBlogPostAsync(long value)
|
||||||
{
|
{
|
||||||
return await _context.BlogSpot
|
return await _context.BlogSpot
|
||||||
.Include(b => b.Author)
|
.Include(b => b.Author)
|
||||||
.Include(b => b.ACL)
|
.Include(b => b.ACL)
|
||||||
.SingleOrDefaultAsync(x => x.Id == value);
|
.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)
|
public async Task<bool> SetPublishAsync(ClaimsPrincipal user, long postId, bool publish)
|
||||||
{
|
{
|
||||||
var blog = await _context.BlogSpot.SingleOrDefaultAsync(b => b.Id == postId);
|
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());
|
var auth = await _authorizationService.AuthorizeAsync(user, blog, new EditPermission());
|
||||||
if (!auth.Succeeded)
|
if (!auth.Succeeded)
|
||||||
{
|
|
||||||
throw new AuthorizationFailureException(auth);
|
throw new AuthorizationFailureException(auth);
|
||||||
}
|
|
||||||
|
|
||||||
var existing = await _context.blogSpotPublications.SingleOrDefaultAsync(
|
var existing = await _context.blogSpotPublications.SingleOrDefaultAsync(p => p.BlogpostId == postId);
|
||||||
p => p.BlogpostId == postId);
|
|
||||||
if (publish)
|
if (publish)
|
||||||
{
|
{
|
||||||
if (existing == null)
|
if (existing == null)
|
||||||
{
|
|
||||||
_context.blogSpotPublications.Add(new BlogSpotPublication { BlogpostId = postId });
|
_context.blogSpotPublications.Add(new BlogSpotPublication { BlogpostId = postId });
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
if (existing != null)
|
if (existing != null)
|
||||||
{
|
|
||||||
_context.blogSpotPublications.Remove(existing);
|
_context.blogSpotPublications.Remove(existing);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await _context.SaveChangesAsync(user.GetUserId());
|
await _context.SaveChangesAsync(user.GetUserId());
|
||||||
return true;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue