Revert "feat(blog): add Visibility { Private, Public } to gate post reads"

This reverts commit 33ecfa7ebd.
This commit is contained in:
Paul Schneider 2026-08-18 15:40:52 +01:00
commit 42625f5ddd
Signed by: notazof
GPG key ID: 1DD5D838E5343B06
16 changed files with 46 additions and 5427 deletions

View file

@ -1,22 +1,12 @@
<Application xmlns="https://github.com/avaloniaui" <Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:PostIt" xmlns:local="using:PostIt"
xmlns:views="using:PostIt.Views"
x:Class="PostIt.App"> x:Class="PostIt.App">
<Application.DataTemplates> <Application.DataTemplates>
<local:ViewLocator/> <local:ViewLocator/>
</Application.DataTemplates> </Application.DataTemplates>
<Application.Resources>
<!-- French labels for the Visibility enum. Used by the
ComboBox ItemTemplate in MainPage.axaml. The list
of available values lives on MainPageViewModel
(AllVisibilities) rather than here — x:Array is
awkward to author in Avalonia XAML. -->
<views:VisibilityLabelConverter x:Key="VisibilityLabelConverter"/>
</Application.Resources>
<Application.Styles> <Application.Styles>
<FluentTheme /> <FluentTheme />
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml" /> <StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml" />

View file

@ -1,5 +1,4 @@
using System; using System;
using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -36,25 +35,6 @@ public partial class MainPageViewModel : ViewModelBase
[ObservableProperty] [ObservableProperty]
public partial string DraftArticle { get; set; } public partial string DraftArticle { get; set; }
/// <summary>Editor buffer for the post visibility. Same
/// pattern as <see cref="DraftTitle"/> and
/// <see cref="DraftArticle"/>: the Save command reads from
/// here so the user can flip a draft to Public without
/// selecting an existing post first. Defaults to
/// <see cref="Visibility.Private"/> on a fresh draft so
/// new posts are private-by-default, matching the server
/// contract.</summary>
[ObservableProperty]
public partial Visibility DraftVisibility { get; set; } = Visibility.Private;
/// <summary>List of values offered in the visibility
/// ComboBox. Exposed as a VM property (rather than an
/// <c>x:Array</c> resource) because Avalonia XAML doesn't
/// author <c>x:Array</c> cleanly. Order: Private first,
/// matching the server default.</summary>
public IReadOnlyList<Visibility> AllVisibilities { get; } =
new[] { Visibility.Private, Visibility.Public };
[ObservableProperty] [ObservableProperty]
public partial ViewModelBase? CurrentViewModel { get; set; } public partial ViewModelBase? CurrentViewModel { get; set; }
@ -122,7 +102,6 @@ public partial class MainPageViewModel : ViewModelBase
WindowTitle = "PostIt"; WindowTitle = "PostIt";
DraftTitle = string.Empty; DraftTitle = string.Empty;
DraftArticle = string.Empty; DraftArticle = string.Empty;
DraftVisibility = Visibility.Private;
CurrentViewModel = this; CurrentViewModel = this;
} }
@ -152,9 +131,6 @@ public partial class MainPageViewModel : ViewModelBase
// doesn't show stale content. // doesn't show stale content.
DraftTitle = value?.Title ?? string.Empty; DraftTitle = value?.Title ?? string.Empty;
DraftArticle = value?.Article ?? string.Empty; DraftArticle = value?.Article ?? string.Empty;
// Mirror visibility too. Defaults to Private on null
// selection so a fresh draft starts private.
DraftVisibility = value?.Visibility ?? Visibility.Private;
UpdateCommandStates(); UpdateCommandStates();
} }
@ -219,7 +195,6 @@ public partial class MainPageViewModel : ViewModelBase
Article = DraftArticle ?? string.Empty, Article = DraftArticle ?? string.Empty,
DateCreated = DateTime.UtcNow, DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow, DateModified = DateTime.UtcNow,
Visibility = DraftVisibility,
}; };
var created = await BlogClient.CreatePostAsync(draft); var created = await BlogClient.CreatePostAsync(draft);
if (created is not null) if (created is not null)
@ -239,7 +214,6 @@ public partial class MainPageViewModel : ViewModelBase
Article = DraftArticle ?? string.Empty, Article = DraftArticle ?? string.Empty,
DateCreated = SelectedPost.DateCreated, DateCreated = SelectedPost.DateCreated,
DateModified = DateTime.UtcNow, DateModified = DateTime.UtcNow,
Visibility = DraftVisibility,
}; };
await BlogClient.UpdatePostAsync(SelectedPost.Id, update); await BlogClient.UpdatePostAsync(SelectedPost.Id, update);
StatusMessage = $"Saved post {SelectedPost.Id}."; StatusMessage = $"Saved post {SelectedPost.Id}.";

View file

@ -81,26 +81,7 @@
<TextBlock Grid.Row="0" Text="Post detail" FontWeight="SemiBold" /> <TextBlock Grid.Row="0" Text="Post detail" FontWeight="SemiBold" />
<TextBox Grid.Row="1" Text="{Binding DraftTitle, Mode=TwoWay}" PlaceholderText="Title" /> <TextBox Grid.Row="1" Text="{Binding DraftTitle, Mode=TwoWay}" PlaceholderText="Title" />
<!-- Visibility toggle: a ComboBox bound to the <AvaloniaEdit:TextEditor Grid.Row="2"
Yavsc.Blogspot.Visibility enum (Private /
Public). The enum serialises as int on the
wire; user-facing labels come from the
visibility label converter registered in
App.axaml. Default is Visibility.Private
(set on DraftVisibility), so a fresh draft
is private by default. -->
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="8">
<TextBlock Text="Visibilité :" VerticalAlignment="Center"/>
<ComboBox SelectedItem="{Binding DraftVisibility, Mode=TwoWay}"
ItemsSource="{Binding AllVisibilities}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="models:Visibility">
<TextBlock Text="{Binding Converter={StaticResource VisibilityLabelConverter}}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<AvaloniaEdit:TextEditor Grid.Row="3"
views:TextEditorBinding.Text="{Binding DraftArticle, Mode=TwoWay}" views:TextEditorBinding.Text="{Binding DraftArticle, Mode=TwoWay}"
ShowLineNumbers="True" ShowLineNumbers="True"
FontFamily="Cascadia Code, Consolas, Menlo, Monospace" FontFamily="Cascadia Code, Consolas, Menlo, Monospace"
@ -109,7 +90,7 @@
VerticalAlignment="Stretch" VerticalAlignment="Stretch"
VerticalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Auto" /> HorizontalScrollBarVisibility="Auto" />
<TextBlock Grid.Row="4" Text="{Binding StatusMessage}" Foreground="Gray" /> <TextBlock Grid.Row="3" Text="{Binding StatusMessage}" Foreground="Gray" />
</Grid> </Grid>
</Border> </Border>
</Grid> </Grid>

View file

@ -1,56 +0,0 @@
using System;
using System.Globalization;
using Avalonia.Data.Converters;
using Yavsc.Blogspot;
namespace PostIt.Views;
/// <summary>
/// Converts a <see cref="Visibility"/> enum value to a user-
/// facing French label. Used by <c>MainPage.axaml</c> to render
/// the visibility ComboBox without exposing the raw enum name
/// ("Private" / "Public") to the end user.
///
/// <para>Bidirectional: <c>ConvertBack</c> returns the value
/// unchanged, so the ComboBox can drive the bound
/// <c>DraftVisibility</c> property directly through the same
/// converter — the ComboBox just happens to use
/// <c>SelectedItem</c> binding so ConvertBack is never
/// invoked. The symmetry is kept for completeness in case a
/// future XAML needs to bind via <c>Text</c>.</para>
/// </summary>
public sealed class VisibilityLabelConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is Visibility v)
{
return v switch
{
Visibility.Private => "Privé",
Visibility.Public => "Public",
_ => v.ToString(),
};
}
return value;
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
// Reverse mapping: user input is unlikely to be the
// raw English enum name (the ComboBox shows French
// labels), so ConvertBack falls back to Private on any
// unrecognised input. The ComboBox uses SelectedItem
// binding so this path is never actually taken today.
if (value is string s)
{
return s switch
{
"Privé" => Visibility.Private,
"Public" => Visibility.Public,
_ => Visibility.Private,
};
}
return value;
}
}

View file

@ -19,18 +19,6 @@ public class BlogPostDto : IBlogPost
public string UserModified { get; set; } public string UserModified { get; set; }
public string Title { get; set; } public string Title { get; set; }
/// <summary>
/// Visibility of this post. Mirrors the EF entity
/// <c>Yavsc.Models.Blog.BlogPost.Visibility</c>: serialised
/// as an <c>int</c> by <c>System.Text.Json</c> (the enum's
/// underlying type), so clients see <c>0</c> or <c>1</c>
/// rather than <c>"Private"</c>/<c>"Public"</c>. Defaults
/// to <see cref="Visibility.Private"/> on construction, so
/// existing client code that doesn't set it explicitly
/// stays safe (private-by-default).
/// </summary>
public Visibility Visibility { get; set; } = Visibility.Private;
public bool AuthorizeCircle(long circleId) public bool AuthorizeCircle(long circleId)
{ {
throw new NotImplementedException(); throw new NotImplementedException();

View file

@ -1,41 +0,0 @@
namespace Yavsc.Blogspot;
/// <summary>
/// Post visibility.
///
/// <list type="bullet">
/// <item><description>
/// <see cref="Public"/>: the post is read via its ACL. If the
/// ACL is empty, every caller sees the post (including
/// unauthenticated ones, on endpoints that allow it). If the
/// ACL is non-empty, only the author, the members of the
/// circles in the ACL, and administrators can read. Public +
/// non-empty ACL is therefore the "restrict by exception"
/// shape: open by default, narrowed by the ACL.
/// </description></item>
/// <item><description>
/// <see cref="Private"/>: the ACL is ignored at read time.
/// Only the author and administrators can read. The ACL list
/// is preserved in the database so that flipping the post
/// back to <see cref="Public"/> restores the previous
/// restriction without re-entry.
/// </description></item>
/// </list>
///
/// <para>The two values together form a two-axis model: the ACL
/// is the exception list (it can narrow Public), and Visibility
/// is the master switch (it can disable the ACL entirely when
/// set to Private).</para>
///
/// <para>Stored as <c>int</c> (not the enum name) — see the
/// <c>.HasConversion&lt;int&gt;()</c> on <c>BlogPost.Visibility</c>
/// in <c>Yavsc.Server.Models.ApplicationDbContext</c>. Keeping the
/// int mapping means queries stay cheap and the wire JSON is a
/// plain number; the trade-off is that reading the column by hand
/// requires knowing the enum ordering.</para>
/// </summary>
public enum Visibility
{
Private = 0,
Public = 1,
}

View file

@ -1,236 +0,0 @@
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Blogspot;
using Yavsc.Models;
using Yavsc.Models.Access;
using Yavsc.Models.Blog;
using Yavsc.Models.Relationship;
using Yavsc.Tests.Shared;
namespace Yavsc.Blogs.Tests;
/// <summary>
/// Behavioural tests for <c>Visibility</c> on blog posts.
///
/// <para>Same fixture as <see cref="BlogApiTests"/>:
/// in-memory <c>ApplicationDbContext</c>, JWT bearer auth with
/// HS256 via <see cref="TestTokenIssuer"/>. The tests below
/// drive the controller surface (<c>GET /api/v1/blog</c> and
/// <c>GET /api/v1/blog/{id}</c>) and assert that visibility
/// scopes the read path the way
/// <see cref="BlogSpotService"/>'s filter expects.</para>
///
/// <para>Each test seeds its own posts directly through the
/// in-memory DbContext — going through POST would force
/// <c>Visibility</c> through the wire DTO which is fine, but
/// keeping it in the fixture avoids serialisation noise around
/// the visibility default (we want to test each visibility
/// value explicitly, not the JSON round-trip).</para>
/// </summary>
[Collection("JwtClaimMapping")]
public sealed class BlogVisibilityTests : IClassFixture<BlogsWebServerFixture>
{
private readonly BlogsWebServerFixture _fixture;
public BlogVisibilityTests(BlogsWebServerFixture fixture)
{
_fixture = fixture;
}
/// <summary>Reset the in-memory database and seed the
/// shared test users.</summary>
private void ResetDatabase()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.Database.EnsureDeleted();
db.Database.EnsureCreated();
db.Users.Add(new ApplicationUser
{
Id = "alice",
UserName = "alice",
Email = "alice@example.com",
EmailConfirmed = true,
});
db.Users.Add(new ApplicationUser
{
Id = "bob",
UserName = "bob",
Email = "bob@example.com",
EmailConfirmed = true,
});
db.SaveChanges();
}
/// <summary>Insert a blog post authored by <paramref name="authorId"/>
/// directly via the DbContext and return its id. The ACL,
/// when supplied, is added to the same context.</summary>
private long SeedPost(string authorId, Visibility visibility, params long[] aclCircleIds)
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var post = new BlogPost
{
AuthorId = authorId,
Title = $"post-by-{authorId}",
Article = "test article",
Visibility = visibility,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
};
db.BlogSpot.Add(post);
db.SaveChanges();
foreach (var circleId in aclCircleIds)
{
db.CircleAuthorizationToBlogPost.Add(new CircleAuthorizationToBlogPost
{
BlogPostId = post.Id,
CircleId = circleId,
Comment = false,
});
}
db.SaveChanges();
return post.Id;
}
/// <summary>Seed a circle owned by <paramref name="ownerId"/>
/// and return its id. The ACL grant for a post then points
/// at this circle; the post stays readable only to circle
/// members.</summary>
private long SeedCircle(string ownerId, string name)
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var circle = new Circle { OwnerId = ownerId, Name = name };
db.Circle.Add(circle);
db.SaveChanges();
return circle.Id;
}
private string BlogsUrl =>
_fixture.Addresses.First(a => a.StartsWith("https://")) + "/api/v1/blog";
private HttpClient NewClient(string subject)
{
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
var http = new HttpClient(handler)
{
BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://")))
};
http.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue(
"Bearer", TestTokenIssuer.Issue(subject));
return http;
}
private static int CountPosts(JsonDocument doc)
=> doc.RootElement.GetArrayLength();
[Fact]
public async Task Private_post_is_only_visible_to_its_author()
{
ResetDatabase();
SeedPost("alice", Visibility.Private);
// Alice (the author) sees it.
using (var alice = NewClient("alice"))
{
var response = await alice.GetAsync(BlogsUrl);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Assert.Equal(1, CountPosts(doc));
}
// Bob (a different authenticated user) does not.
using (var bob = NewClient("bob"))
{
var response = await bob.GetAsync(BlogsUrl);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Assert.Equal(0, CountPosts(doc));
}
}
[Fact]
public async Task Public_post_with_empty_ACL_is_visible_to_everyone_authenticated()
{
ResetDatabase();
SeedPost("alice", Visibility.Public);
using var bob = NewClient("bob");
var response = await bob.GetAsync(BlogsUrl);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var doc = JsonDocument.Parse(await bob.GetAsync(BlogsUrl).Result.Content.ReadAsStringAsync());
Assert.Equal(1, CountPosts(doc));
}
[Fact]
public async Task Public_post_with_nonempty_ACL_is_restricted_by_the_ACL()
{
ResetDatabase();
var familyCircleId = SeedCircle("alice", "Famille");
// Alice grants the post to her own "Famille" circle.
// Bob is not a member, so he must NOT see the post even
// though Visibility is Public.
SeedPost("alice", Visibility.Public, familyCircleId);
using var bob = NewClient("bob");
var response = await bob.GetAsync(BlogsUrl);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Assert.Equal(0, CountPosts(doc));
}
[Fact]
public async Task Private_post_is_not_visible_even_when_ACL_would_have_allowed()
{
ResetDatabase();
var familyCircleId = SeedCircle("alice", "Famille");
// The ACL would let Bob in, but Visibility.Private
// overrides it — only the author can read.
SeedPost("alice", Visibility.Private, familyCircleId);
using var bob = NewClient("bob");
var response = await bob.GetAsync(BlogsUrl);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Assert.Equal(0, CountPosts(doc));
}
[Fact]
public async Task Post_persists_Visibility_through_the_DTO_wire()
{
ResetDatabase();
using var http = NewClient("alice");
var draft = new BlogPost
{
Id = 0,
AuthorId = "alice",
Title = "Un post visible",
Article = "Contenu.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
Visibility = Visibility.Public,
};
var postResponse = await http.PostAsJsonAsync(BlogsUrl, draft);
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
// The wire DTO should round-trip Visibility (System.Text.Json
// serialises the enum as its underlying int — see
// Yavsc.Abstract.Blogspot.Visibility).
using var doc = JsonDocument.Parse(await postResponse.Content.ReadAsStringAsync());
Assert.Equal(1, doc.RootElement.GetProperty("visibility").GetInt32());
}
}

View file

@ -1,167 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Blogspot;
using Yavsc.Models;
using Yavsc.Models.Access;
using Yavsc.Models.Blog;
using Yavsc.Models.Relationship;
using Yavsc.Server.Helpers;
namespace Yavsc.Blogs.Tests;
/// <summary>
/// Tests that <see cref="UserHelpers.UserPosts"/> (the
/// "posts-by-author-for-this-reader" query) honours the same
/// Visibility rules as <see cref="BlogSpotService.Index"/>.
///
/// <para>The two code paths duplicate the ACL/Visibility filter
/// (one in the listing query, one in the per-author query);
/// these tests catch the case where the two diverge — the kind
/// of regression that's easy to miss in a code review because
/// both filters look correct in isolation.</para>
///
/// <para>Uses the same in-memory <c>ApplicationDbContext</c>
/// scaffold as <see cref="BlogsWebServerFixture"/> but
/// exercises the helper directly, without going through HTTP,
/// because <see cref="UserHelpers.UserPosts"/> is the unit
/// under test.</para>
/// </summary>
[Collection("JwtClaimMapping")]
public sealed class UserHelpersVisibilityTests : IClassFixture<BlogsWebServerFixture>
{
private readonly BlogsWebServerFixture _fixture;
public UserHelpersVisibilityTests(BlogsWebServerFixture fixture)
{
_fixture = fixture;
}
private void ResetDatabase()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.Database.EnsureDeleted();
db.Database.EnsureCreated();
db.Users.Add(new ApplicationUser
{
Id = "alice",
UserName = "alice",
Email = "alice@example.com",
EmailConfirmed = true,
});
db.Users.Add(new ApplicationUser
{
Id = "bob",
UserName = "bob",
Email = "bob@example.com",
EmailConfirmed = true,
});
db.SaveChanges();
}
private long SeedPost(string authorId, Visibility visibility, params long[] aclCircleIds)
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var post = new BlogPost
{
AuthorId = authorId,
Title = $"post-by-{authorId}",
Article = "test article",
Visibility = visibility,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
};
db.BlogSpot.Add(post);
db.SaveChanges();
foreach (var cid in aclCircleIds)
{
db.CircleAuthorizationToBlogPost.Add(new CircleAuthorizationToBlogPost
{
BlogPostId = post.Id,
CircleId = cid,
Comment = false,
});
}
db.SaveChanges();
return post.Id;
}
private long SeedCircle(string ownerId, string name)
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var circle = new Circle { OwnerId = ownerId, Name = name };
db.Circle.Add(circle);
db.SaveChanges();
return circle.Id;
}
private void AddMember(long circleId, string memberId)
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.CircleMembers.Add(new CircleMember { CircleId = circleId, MemberId = memberId });
db.SaveChanges();
}
private List<long> UserPostsIds(string posterId, string readerId)
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
return db.UserPosts(posterId, readerId).Select(p => p.Id).ToList();
}
[Fact]
public void UserPosts_returns_only_private_posts_to_their_author()
{
ResetDatabase();
SeedPost("alice", Visibility.Private);
var aliceSees = UserPostsIds("alice", "alice");
var bobSees = UserPostsIds("alice", "bob");
Assert.Single(aliceSees);
Assert.Empty(bobSees);
}
[Fact]
public void UserPosts_returns_public_posts_with_empty_ACL_to_anyone()
{
ResetDatabase();
SeedPost("alice", Visibility.Public);
var bobSees = UserPostsIds("alice", "bob");
Assert.Single(bobSees);
}
[Fact]
public void UserPosts_narrows_public_posts_with_nonempty_ACL()
{
ResetDatabase();
var circleId = SeedCircle("alice", "Famille");
AddMember(circleId, "alice");
// AddMember above adds alice, but we want bob NOT in
// the circle, so we add bob to a different circle only:
var otherCircleId = SeedCircle("alice", "Travail");
AddMember(otherCircleId, "bob");
// Make the post readable only to Famille:
SeedPost("alice", Visibility.Public, circleId);
var bobSees = UserPostsIds("alice", "bob");
Assert.Empty(bobSees);
}
[Fact]
public void UserPosts_lets_acl_members_read_public_posts_even_if_not_author()
{
ResetDatabase();
var circleId = SeedCircle("alice", "Famille");
AddMember(circleId, "bob");
SeedPost("alice", Visibility.Public, circleId);
var bobSees = UserPostsIds("alice", "bob");
Assert.Single(bobSees);
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,119 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Yavsc.Migrations
{
/// <inheritdoc />
public partial class AddBlogPostVisibility : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_ClientGrantTypes_Clients_ClientId1",
table: "ClientGrantTypes");
migrationBuilder.DropForeignKey(
name: "FK_ClientRedirectUris_Clients_ClientId1",
table: "ClientRedirectUris");
migrationBuilder.DropForeignKey(
name: "FK_ClientScopes_Clients_ClientId1",
table: "ClientScopes");
migrationBuilder.DropIndex(
name: "IX_ClientScopes_ClientId1",
table: "ClientScopes");
migrationBuilder.DropIndex(
name: "IX_ClientRedirectUris_ClientId1",
table: "ClientRedirectUris");
migrationBuilder.DropIndex(
name: "IX_ClientGrantTypes_ClientId1",
table: "ClientGrantTypes");
migrationBuilder.DropColumn(
name: "ClientId1",
table: "ClientScopes");
migrationBuilder.DropColumn(
name: "ClientId1",
table: "ClientRedirectUris");
migrationBuilder.DropColumn(
name: "ClientId1",
table: "ClientGrantTypes");
migrationBuilder.AddColumn<int>(
name: "Visibility",
table: "BlogSpot",
type: "integer",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Visibility",
table: "BlogSpot");
migrationBuilder.AddColumn<int>(
name: "ClientId1",
table: "ClientScopes",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ClientId1",
table: "ClientRedirectUris",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ClientId1",
table: "ClientGrantTypes",
type: "integer",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_ClientScopes_ClientId1",
table: "ClientScopes",
column: "ClientId1");
migrationBuilder.CreateIndex(
name: "IX_ClientRedirectUris_ClientId1",
table: "ClientRedirectUris",
column: "ClientId1");
migrationBuilder.CreateIndex(
name: "IX_ClientGrantTypes_ClientId1",
table: "ClientGrantTypes",
column: "ClientId1");
migrationBuilder.AddForeignKey(
name: "FK_ClientGrantTypes_Clients_ClientId1",
table: "ClientGrantTypes",
column: "ClientId1",
principalTable: "Clients",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_ClientRedirectUris_Clients_ClientId1",
table: "ClientRedirectUris",
column: "ClientId1",
principalTable: "Clients",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_ClientScopes_Clients_ClientId1",
table: "ClientScopes",
column: "ClientId1",
principalTable: "Clients",
principalColumn: "Id");
}
}
}

View file

@ -476,6 +476,9 @@ namespace Yavsc.Migrations
b.Property<int>("ClientId") b.Property<int>("ClientId")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<int?>("ClientId1")
.HasColumnType("integer");
b.Property<string>("GrantType") b.Property<string>("GrantType")
.HasColumnType("text"); .HasColumnType("text");
@ -483,6 +486,8 @@ namespace Yavsc.Migrations
b.HasIndex("ClientId"); b.HasIndex("ClientId");
b.HasIndex("ClientId1");
b.ToTable("ClientGrantTypes"); b.ToTable("ClientGrantTypes");
}); });
@ -578,6 +583,9 @@ namespace Yavsc.Migrations
b.Property<int>("ClientId") b.Property<int>("ClientId")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<int?>("ClientId1")
.HasColumnType("integer");
b.Property<string>("RedirectUri") b.Property<string>("RedirectUri")
.HasColumnType("text"); .HasColumnType("text");
@ -585,6 +593,8 @@ namespace Yavsc.Migrations
b.HasIndex("ClientId"); b.HasIndex("ClientId");
b.HasIndex("ClientId1");
b.ToTable("ClientRedirectUris"); b.ToTable("ClientRedirectUris");
}); });
@ -599,6 +609,9 @@ namespace Yavsc.Migrations
b.Property<int>("ClientId") b.Property<int>("ClientId")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<int?>("ClientId1")
.HasColumnType("integer");
b.Property<string>("Scope") b.Property<string>("Scope")
.HasColumnType("text"); .HasColumnType("text");
@ -606,6 +619,8 @@ namespace Yavsc.Migrations
b.HasIndex("ClientId"); b.HasIndex("ClientId");
b.HasIndex("ClientId1");
b.ToTable("ClientScopes"); b.ToTable("ClientScopes");
}); });
@ -1493,11 +1508,6 @@ namespace Yavsc.Migrations
b.Property<string>("UserModified") b.Property<string>("UserModified")
.HasColumnType("text"); .HasColumnType("text");
b.Property<int>("Visibility")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("AuthorId"); b.HasIndex("AuthorId");
@ -3450,12 +3460,16 @@ namespace Yavsc.Migrations
modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientGrantType", b => modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientGrantType", b =>
{ {
b.HasOne("IdentityServer8.EntityFramework.Entities.Client", "Client") b.HasOne("IdentityServer8.EntityFramework.Entities.Client", null)
.WithMany("AllowedGrantTypes") .WithMany("AllowedGrantTypes")
.HasForeignKey("ClientId") .HasForeignKey("ClientId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("IdentityServer8.EntityFramework.Entities.Client", "Client")
.WithMany()
.HasForeignKey("ClientId1");
b.Navigation("Client"); b.Navigation("Client");
}); });
@ -3506,23 +3520,31 @@ namespace Yavsc.Migrations
modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientRedirectUri", b => modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientRedirectUri", b =>
{ {
b.HasOne("IdentityServer8.EntityFramework.Entities.Client", "Client") b.HasOne("IdentityServer8.EntityFramework.Entities.Client", null)
.WithMany("RedirectUris") .WithMany("RedirectUris")
.HasForeignKey("ClientId") .HasForeignKey("ClientId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("IdentityServer8.EntityFramework.Entities.Client", "Client")
.WithMany()
.HasForeignKey("ClientId1");
b.Navigation("Client"); b.Navigation("Client");
}); });
modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientScope", b => modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientScope", b =>
{ {
b.HasOne("IdentityServer8.EntityFramework.Entities.Client", "Client") b.HasOne("IdentityServer8.EntityFramework.Entities.Client", null)
.WithMany("AllowedScopes") .WithMany("AllowedScopes")
.HasForeignKey("ClientId") .HasForeignKey("ClientId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("IdentityServer8.EntityFramework.Entities.Client", "Client")
.WithMany()
.HasForeignKey("ClientId1");
b.Navigation("Client"); b.Navigation("Client");
}); });

View file

@ -1,6 +1,5 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Security.Claims; using System.Security.Claims;
using Yavsc.Blogspot;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.Models.Blog; using Yavsc.Models.Blog;
@ -24,21 +23,10 @@ namespace Yavsc.Server.Helpers
dbContext.Circle.Include(c => c.Members) dbContext.Circle.Include(c => c.Members)
.Where(c => c.Members.Any(m => m.MemberId == readerId)) .Where(c => c.Members.Any(m => m.MemberId == readerId))
.Select(c => c.Id).ToArray(); .Select(c => c.Id).ToArray();
// Mirror of BlogSpotService.Index for an
// authenticated reader: Private restricts to the
// author; Public is read-through-ACL.
return dbContext.BlogSpot.Include( return dbContext.BlogSpot.Include(
b => b.Author b => b.Author
).Include(p => p.ACL).Where(x => x.Author.Id == posterId && ).Include(p => p.ACL).Where(x => x.Author.Id == posterId &&
( (x.ACL.Count == 0 || x.ACL.Any(a => readerCirclesMemberships.Contains(a.CircleId))));
(x.Visibility == Visibility.Private && x.AuthorId == readerId)
|| (x.Visibility == Visibility.Public
&& (x.ACL == null
|| x.ACL.Count == 0
|| x.AuthorId == readerId
|| (readerCirclesMemberships != null
&& x.ACL.Any(a => readerCirclesMemberships.Contains(a.CircleId)))))
));
} }
} }

View file

@ -13,7 +13,6 @@ namespace Yavsc.Models
using Bank; using Bank;
using Billing; using Billing;
using Blog; using Blog;
using Blogspot;
using Chat; using Chat;
using Drawing; using Drawing;
using Forms; using Forms;
@ -223,17 +222,6 @@ namespace Yavsc.Models
.WithMany(u => u.Posts) .WithMany(u => u.Posts)
.HasForeignKey(b => b.AuthorId) .HasForeignKey(b => b.AuthorId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
// Store Visibility as a plain int (NOT NULL, default
// 0 = Private) so existing rows land on the pre-ACL
// behaviour by default. System.Text.Json serialises
// the enum as its underlying int, so the wire shape
// is a plain number — no JsonConverter needed.
builder.Entity<BlogPost>()
.Property(b => b.Visibility)
.HasConversion<int>()
.HasDefaultValue(Visibility.Private)
.IsRequired();
builder.Entity<Comment>() builder.Entity<Comment>()
.HasOne(c => c.Author) .HasOne(c => c.Author)
.WithMany(u => u.BlogComments) .WithMany(u => u.BlogComments)

View file

@ -30,21 +30,6 @@ namespace Yavsc.Models.Blog
[Display(Name = "Liste de contrôle d'accès")] [Display(Name = "Liste de contrôle d'accès")]
public virtual List<CircleAuthorizationToBlogPost>? ACL { get; set; } public virtual List<CircleAuthorizationToBlogPost>? ACL { get; set; }
/// <summary>
/// Visibility of this post.
/// <para><see cref="Visibility.Public"/> reads through the
/// ACL (open when the ACL is empty, narrowed by the ACL
/// when it is non-empty). <see cref="Visibility.Private"/>
/// ignores the ACL at read time and restricts to author +
/// administrators. The ACL list is preserved across
/// Private/Public flips so re-opening is lossless.</para>
/// <para>Configured as <c>int</c> with default
/// <see cref="Visibility.Private"/> in
/// <c>ApplicationDbContext.OnModelCreating</c>.</para>
/// </summary>
[Display(Name = "Visibilité")]
public Visibility Visibility { get; set; } = Visibility.Private;
[Display(Name = "Identifiant de l'auteur")] [Display(Name = "Identifiant de l'auteur")]
[ForeignKey("Author")] [ForeignKey("Author")]
public string? AuthorId { get; set; } public string? AuthorId { get; set; }

View file

@ -200,46 +200,28 @@ public class BlogSpotService
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();
// Visibility drives the read gate:
// * Public : the ACL decides. Open if the ACL is
// empty, narrowed otherwise to author +
// ACL circles + admin.
// * Private : ACL is ignored at read time. Only the
// author (and administrators, checked
// elsewhere) can read.
// Admin reads (the Administrator role) go through
// IsInMsRole("Administrator") upstream in
// PermissionHandler; we don't repeat that here so the
// listing query stays role-agnostic.
posts = _context.BlogSpot posts = _context.BlogSpot
.Include(b => b.Author) .Include(b => b.Author)
.Include(p => p.ACL) .Include(p => p.ACL)
.Include(p => p.Tags) .Include(p => p.Tags)
.Include(p => p.Comments) .Include(p => p.Comments)
.Where(p => .Where(p => p.ACL == null
(p.Visibility == Visibility.Private && p.AuthorId == viewerId)
|| (p.Visibility == Visibility.Public
&& (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
{ {
// Anonymous callers only see Public posts with no
// ACL — anything else either requires membership
// (which we have no way to check without an
// identity) or is Private.
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.Visibility == Visibility.Public .Where(p => p.BlogPost.ACL == null
&& (p.BlogPost.ACL == null || p.BlogPost.ACL.Count == 0)
|| p.BlogPost.ACL.Count == 0))
.Select(p => p.BlogPost).ToArray(); .Select(p => p.BlogPost).ToArray();
} }

View file

@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Yavsc.Blogspot;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.Models.Blog; using Yavsc.Models.Blog;
using Yavsc.Server.Helpers; using Yavsc.Server.Helpers;
@ -56,15 +55,9 @@ public class PermissionHandler : IAuthorizationHandler
{ {
if (resource is BlogPost blogPost) if (resource is BlogPost blogPost)
{ {
// IsPublic is the authz twin of the Index/listing return
// filter in BlogSpotService: a post is "publicly applicationDbContext.blogSpotPublications
// readable" (no membership required) iff its .Any(p=>p.BlogpostId == blogPost.Id);
// Visibility is Public and its ACL is empty.
// Visibility.Public + non-empty ACL is narrowed by
// the ACL, so it does NOT pass IsPublic here; the
// caller has to match IsSponsor for that.
return blogPost.Visibility == Visibility.Public
&& (blogPost.ACL == null || blogPost.ACL.Count == 0);
} }
return false; return false;
} }