Merge pull request 'fix/postit-acl-load' (#49) from fix/postit-acl-load into main
Some checks failed
Dotnet build and test / build (push) Has been cancelled
Some checks failed
Dotnet build and test / build (push) Has been cancelled
Reviewed-on: #49
This commit is contained in:
commit
3ec47ba4e7
17 changed files with 462 additions and 183 deletions
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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 _));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,9 +10,17 @@ 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// View model for the "Gérer l'ACL" modal of a single blog post.
|
||||
///
|
||||
|
|
@ -41,7 +51,7 @@ public partial class PostAclDialogViewModel : ViewModelBase
|
|||
MyCircles { get; set; } = new();
|
||||
|
||||
[ObservableProperty]
|
||||
public partial ObservableCollection<PostAccessControlRulePayload>
|
||||
public partial ObservableCollection<PostAclEntry>
|
||||
AclEntries { get; set; } = new();
|
||||
|
||||
[ObservableProperty]
|
||||
|
|
@ -77,6 +87,9 @@ 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<PostAclEntry>(post.GetACL().Select(a => ToAclEntry(a.CircleId)));
|
||||
SelectedCircleToAdd = null;
|
||||
}
|
||||
|
||||
public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); }
|
||||
|
|
@ -90,16 +103,17 @@ 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);
|
||||
|
||||
// Resolve labels now that circles are available.
|
||||
AclEntries = new ObservableCollection<PostAclEntry>(AclEntries.Select(a => ToAclEntry(a.CircleId)));
|
||||
|
||||
|
||||
StatusMessage = $"{AclEntries.Count} autorisation(s)";
|
||||
_loaded = true;
|
||||
|
|
@ -126,14 +140,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(ToAclEntry(created.CircleId));
|
||||
StatusMessage = $"Cercle « {SelectedCircleToAdd.Name} » autorisé";
|
||||
}
|
||||
else
|
||||
|
|
@ -141,6 +161,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}";
|
||||
|
|
@ -152,14 +179,16 @@ public partial class PostAclDialogViewModel : ViewModelBase
|
|||
}
|
||||
|
||||
[RelayCommand]
|
||||
public async Task RevokeAsync(PostAccessControlRulePayload? acl)
|
||||
public async Task RevokeAsync(PostAclEntry? acl)
|
||||
{
|
||||
if (acl is null) return;
|
||||
IsBusy = true;
|
||||
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 +200,26 @@ 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 => ToAclEntry(a.CircleId))
|
||||
.GroupBy(a => a.CircleId)
|
||||
.Select(g => g.First())
|
||||
.ToList();
|
||||
AclEntries = new ObservableCollection<PostAclEntry>(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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,11 +18,11 @@ public partial class AuthenticationSettings : ObservableObject
|
|||
/// </summary>
|
||||
public const string AndroidRedirectUri = "android://postit-signin";
|
||||
|
||||
public static string DefaultAuthority { get; internal set; } = "https://yavsc.pschneider.fr";
|
||||
public const string DefaultAuthority = "https://yavsc.pschneider.fr";
|
||||
|
||||
public static string DefaultClientId { get; internal set; } = "postit";
|
||||
public const string DefaultClientId = "postit";
|
||||
|
||||
public static string[] DefaultScopes { get; set; } = { "blogs"} ;
|
||||
public static readonly string[] DefaultScopes = { "blogs" };
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string Authority { get; set; }
|
||||
|
|
@ -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
|
||||
|
|
@ -3,8 +3,7 @@
|
|||
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:yabst="using:Yavsc.Abstract.Identity.Security"
|
||||
xmlns:dtos="using:Yavsc.Api.Client.Dtos"
|
||||
x:DataType="vm:PostAclDialogViewModel"
|
||||
>
|
||||
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="12">
|
||||
|
|
@ -32,10 +31,10 @@
|
|||
<ListBox Grid.Row="1"
|
||||
ItemsSource="{Binding AclEntries}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="yabst:CircleAuthorization">
|
||||
<DataTemplate x:DataType="vm:PostAclEntry">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="2">
|
||||
<TextBlock Text="{Binding CircleId, StringFormat='Cercle #{0}'}"
|
||||
<TextBlock Text="{Binding CircleName}"
|
||||
FontWeight="Bold"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Content="Révoquer"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
ICircleAuthorization [] GetACL();
|
||||
ICollection<CircleAuthorization> ACL { get; } //ICircleAuthorization [] GetACL();
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue