Fixes ACL backend support

This commit is contained in:
Paul Schneider 2026-08-28 20:10:32 +01:00
commit e62adedc17
Signed by: notazof
GPG key ID: 1DD5D838E5343B06
14 changed files with 205 additions and 56 deletions

32
.vscode/tasks.json vendored
View file

@ -1,5 +1,21 @@
{ {
"version": "2.0.0", "version": "2.0.0",
"isRoot": true,
"problemMatcher": [
{
"owner": "dotnet",
"fileLocation": ["relative", "${workspaceFolder}"],
"source": "dotnet",
"pattern": {
"regexp": "^\\s+(.*)\\((\\d+):(\\d+)\\):\\s+(error|warning)\\s+(.*)$",
"file": 1,
"line": 2,
"column": 3,
"severity": 4,
"message": 5
}
}
],
"tasks": [ "tasks": [
{ {
"label": "run-debug-android", "label": "run-debug-android",
@ -20,21 +36,6 @@
"-p:AndroidAttachDebugger=true", "-p:AndroidAttachDebugger=true",
"-p:AndroidSdbHostPort=55555", "-p:AndroidSdbHostPort=55555",
"-p:AndroidSdbTargetPort=55555" "-p:AndroidSdbTargetPort=55555"
],
"problemMatcher": [
{
"owner": "dotnet",
"fileLocation": ["relative", "${workspaceFolder}"],
"source": "dotnet",
"pattern": {
"regexp": "^(.*):(\\d+):(\\d+):\\s+(error):\\s+(.*)$",
"file": 1,
"line": 2,
"column": 3,
"severity": 4,
"message": 5
}
}
] ]
}, },
{ {
@ -45,7 +46,6 @@
"group": "build", "group": "build",
"isBuildCommand": true, "isBuildCommand": true,
"isTestCommand": false, "isTestCommand": false,
"problemMatcher": ["$msCompile"],
"isBackground": true "isBackground": true
}, },
{ {

View file

@ -16,6 +16,16 @@ Cette convention est partagée avec le dépôt
[`postit-debian`](https://forgejo.pschneider.fr/notazof/postit-debian) [`postit-debian`](https://forgejo.pschneider.fr/notazof/postit-debian)
pour la production des paquets `.deb`. pour la production des paquets `.deb`.
## [1.0.8-rc4] - unstable
### Added
### Changed
### Fixed
* [TODO] bug loading a blog post from PostIt, ACL come along with and don't need any "Refresh" button.
## [1.0.8-rc3] - unstable ## [1.0.8-rc3] - unstable
### Added ### Added

View file

@ -7,6 +7,13 @@ namespace Yavsc
{ {
public const string APIPrefix = "api/v1"; public const string APIPrefix = "api/v1";
public const string BlogSpotPath = "blogspot";
public const string BlogAclPath = "blogacl";
public const string BlogTagPath = "blogtag";
public const string CirclePath = "circle";
public const string CommentsPath = "blogcomments";
public static readonly Scope[] SiteScopes = { public static readonly Scope[] SiteScopes = {
new Scope { Id = "profile", Description = "Your profile informations" }, new Scope { Id = "profile", Description = "Your profile informations" },
new Scope { Id = "book" , Description ="Your booking interface"}, new Scope { Id = "book" , Description ="Your booking interface"},

View file

@ -1,10 +1,12 @@
using System.Net; using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Yavsc.Abstract.BlogSpot; using Yavsc.Abstract.BlogSpot;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.Models.Access; using Yavsc.Models.Access;
using Yavsc.Models.Blog;
using Yavsc.Tests.Shared; using Yavsc.Tests.Shared;
using static Yavsc.Constants; using static Yavsc.Constants;
@ -38,15 +40,16 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
{ {
private readonly BlogsWebServerFixture _fixture; private readonly BlogsWebServerFixture _fixture;
public BlogAclApiTests(BlogsWebServerFixture fixture) public BlogAclApiTests(BlogsWebServerFixture fixture)
{ {
_fixture = fixture; _fixture = fixture;
} }
private string BlogUrl()
=> $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/{APIPrefix}/{BlogSpotPath}";
private string BlogAclUrl() private string BlogAclUrl()
=> $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/{APIPrefix}/blogacl"; => $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/{APIPrefix}/{BlogAclPath}";
/// <summary>Delete any ACL rows tied to the fixture's seeded /// <summary>Delete any ACL rows tied to the fixture's seeded
/// <c>(CircleId, BlogPostId)</c> pair. The shared SQLite store /// <c>(CircleId, BlogPostId)</c> pair. The shared SQLite store
@ -120,7 +123,7 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
// owned by the caller. We seed the same shape pre-POST so the // owned by the caller. We seed the same shape pre-POST so the
// test reproduces the prod scenario end-to-end. // test reproduces the prod scenario end-to-end.
CleanupAcl(); CleanupAcl();
using var http = NewClient("alice"); using var http = NewClient(_fixture.DefaultUserLogin);
var payload = new PostAccessControlRulePayload var payload = new PostAccessControlRulePayload
{ {
@ -178,7 +181,7 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
[MemberData(nameof(BlogAclPayloadsForNever500))] [MemberData(nameof(BlogAclPayloadsForNever500))]
public async Task PostCircleAuthorization_never_returns_500(PostAccessControlRulePayload payload) public async Task PostCircleAuthorization_never_returns_500(PostAccessControlRulePayload payload)
{ {
using var http = NewClient("alice"); using var http = NewClient(_fixture.DefaultUserLogin);
var response = await http.PostAsJsonAsync( var response = await http.PostAsJsonAsync(
BlogAclUrl(), payload, BlogAclUrl(), payload,
@ -216,4 +219,83 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
); );
} }
[Fact]
public async Task PostBlog_with_ACL_creates_a_post_and_Get_returns_it_in_the_list()
{
CleanupAcl();
_fixture.SeedUser(_fixture.DefaultUserLogin);
_fixture.SeedUser("tester");
_fixture.SeedCircle(_fixture.DefaultUserLogin, "test",
false,
new String[]
{
_fixture.DefaultUserLogin,
"tester"
});
using var http = NewClient(_fixture.DefaultUserLogin );
// Create a minimal BlogPost. The server assigns Id, so we
// send 0 + an explicit AuthorId; the production
// BlogSpotService.Create() tolerates that.
var draft = new BlogPost
{
Id = 0,
Title = "Premier billet",
AuthorId = "tester",
Article = "Contenu de test.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
ACL = new List<CircleAuthorizationToBlogPost>(
new CircleAuthorizationToBlogPost[]
{
new CircleAuthorizationToBlogPost
{
CircleId = _fixture.CircleId,
BlogPostId = _fixture.PostId
}
}
)
};
var postResponse = await http.PostAsJsonAsync(
BlogUrl(),
draft,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
// The POST returns the server-issued post (with a real Id).
var created = await postResponse.Content.ReadFromJsonAsync<BlogPost>(
TestContext.Current.CancellationToken
);
Assert.NotNull(created);
Assert.NotEqual(0, created!.Id);
Assert.Equal(draft.Title, created.Title);
// The list should now contain exactly one entry.
var listResponse = await http.GetAsync(
BlogUrl(),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
using var doc = JsonDocument.Parse(await listResponse.Content.ReadAsStringAsync(
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());
// detail should return the same post, with ACL and tags.
var detailResponse = await http.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.Equal(JsonValueKind.Object, detailDoc.RootElement.ValueKind);
Assert.Equal(created.Id, detailDoc.RootElement.GetProperty("id").GetInt64());
Assert.Equal(1, detailDoc.RootElement.GetProperty("acl").GetArrayLength());
}
} }

View file

@ -2,7 +2,6 @@ using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Security.Claims; using System.Security.Claims;
using System.Text.Json; using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.Models.Blog; using Yavsc.Models.Blog;
using Yavsc.Server.Helpers; using Yavsc.Server.Helpers;
@ -31,18 +30,6 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
_fixture = fixture; _fixture = fixture;
} }
/// <summary>Reset the in-memory database to a known empty state.
/// <c>UseInMemoryDatabase</c> shares its store across the
/// lifetime of the <see cref="BlogsWebServerFixture"/> instance,
/// so without a per-test reset the test order would leak
/// state between tests.</summary>
private void ResetDatabase()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.Database.EnsureDeleted();
db.Database.EnsureCreated();
}
/// <summary>Reset the database and seed the /// <summary>Reset the database and seed the
/// <c>tester</c> <see cref="ApplicationUser"/> row. Required /// <c>tester</c> <see cref="ApplicationUser"/> row. Required
@ -55,7 +42,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
/// at <c>SaveChanges</c> and the controller returns 500.</summary> /// at <c>SaveChanges</c> and the controller returns 500.</summary>
private void ResetAndSeedDefaultUser() private void ResetAndSeedDefaultUser()
{ {
ResetDatabase(); _fixture.ResetDatabase();
_fixture.SeedUser("tester"); _fixture.SeedUser("tester");
} }
@ -111,7 +98,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
[Fact] [Fact]
public async Task GetBlogs_returns_200_with_empty_list_when_no_posts() public async Task GetBlogs_returns_200_with_empty_list_when_no_posts()
{ {
ResetDatabase(); _fixture.ResetDatabase();
using var http = NewClient(); using var http = NewClient();
var response = await http.GetAsync("/api/v1/blog", var response = await http.GetAsync("/api/v1/blog",
@ -266,7 +253,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
[Fact] [Fact]
public async Task GetBlog_returns_401_when_no_token_is_provided() public async Task GetBlog_returns_401_when_no_token_is_provided()
{ {
ResetDatabase(); _fixture.ResetDatabase();
using var http = NewAnonymousClient(); using var http = NewAnonymousClient();
// No Authorization header → the JwtBearer middleware // No Authorization header → the JwtBearer middleware
@ -443,7 +430,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
// behaviour so a future change that, say, makes Title // behaviour so a future change that, say, makes Title
// nullable in the model or drops [Required], triggers a // nullable in the model or drops [Required], triggers a
// conscious update of the test (and probably of the VM). // conscious update of the test (and probably of the VM).
ResetDatabase(); _fixture.ResetDatabase();
using var http = NewClient(subject: "tester"); using var http = NewClient(subject: "tester");
var draft = new BlogPost var draft = new BlogPost

View file

@ -61,6 +61,7 @@ public sealed class BlogsWebServerFixture : WebHostFixture
public long CircleId { get; private set; } public long CircleId { get; private set; }
public long PostId { get; private set; } public long PostId { get; private set; }
public string DefaultUserLogin { get => "alice"; }
// A single SqliteConnection held open at the static level, // A single SqliteConnection held open at the static level,
// mirroring how Yavsc.Org.Tests.WebServerFixture hoists its // mirroring how Yavsc.Org.Tests.WebServerFixture hoists its
@ -169,7 +170,8 @@ public sealed class BlogsWebServerFixture : WebHostFixture
// PermissionHandler ownership check sees a null // PermissionHandler ownership check sees a null
// user id and rejects every PUT. // user id and rejects every PUT.
options.MapInboundClaims = false; options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters options.TokenValidationParameters
= new TokenValidationParameters
{ {
ValidateIssuer = true, ValidateIssuer = true,
ValidIssuer = TestTokenIssuer.Issuer, ValidIssuer = TestTokenIssuer.Issuer,
@ -263,6 +265,27 @@ public sealed class BlogsWebServerFixture : WebHostFixture
await Task.CompletedTask; await Task.CompletedTask;
return app; return app;
} }
/// <summary>Reset the in-memory database to a known empty state.
/// <c>UseInMemoryDatabase</c> shares its store across the
/// lifetime of the <see cref="BlogsWebServerFixture"/> instance,
/// so without a per-test reset the test order would leak
/// state between tests.</summary>
public void ResetDatabase()
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.Database.EnsureDeleted();
db.Database.EnsureCreated();
}
public void CleanupAcl()
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.CircleAuthorizationToBlogPost
.Where(a => a.CircleId == CircleId
&& a.BlogPostId == PostId)
.ExecuteDelete();
}
public override void Dispose() public override void Dispose()
{ {
@ -310,7 +333,8 @@ public sealed class BlogsWebServerFixture : WebHostFixture
/// <param name="configure">Optional hook to fill in fields /// <param name="configure">Optional hook to fill in fields
/// like <c>FullName</c> / <c>Avatar</c> / <c>EmailConfirmed</c> /// like <c>FullName</c> / <c>Avatar</c> / <c>EmailConfirmed</c>
/// that downstream tests assert on.</param> /// that downstream tests assert on.</param>
public ApplicationUser SeedUser(string userName, Action<ApplicationUser>? configure = null) public ApplicationUser SeedUser(string userName,
Action<ApplicationUser>? configure = null)
{ {
using var scope = Services.CreateScope(); using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
@ -351,20 +375,31 @@ public sealed class BlogsWebServerFixture : WebHostFixture
/// <summary>Create a circle owned by <paramref name="ownerId"/> /// <summary>Create a circle owned by <paramref name="ownerId"/>
/// directly in the SQLite store and return its server-assigned /// directly in the SQLite store and return its server-assigned
/// id.</summary> /// id.</summary>
private long SeedCircle(string ownerId, string name, bool isPublic = false) public long SeedCircle(string ownerId, string name, bool isPublic = false,
ICollection<String> members = null
)
{ {
using var scope = Services.CreateScope(); using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var circle = new Circle { OwnerId = ownerId, Name = name, Public = isPublic }; var circle = new Circle { OwnerId = ownerId, Name = name, Public = isPublic };
db.Circle.Add(circle); db.Circle.Add(circle);
db.SaveChanges(); db.SaveChanges();
if (members != null && members.Count > 0)
{
foreach (String memberId in members)
{
var member = new CircleMember { CircleId = circle.Id, MemberId = memberId };
db.CircleMembers.Add(member);
}
db.SaveChanges();
}
return circle.Id; return circle.Id;
} }
/// <summary>Create a blog post owned by <paramref name="authorId"/> /// <summary>Create a blog post owned by <paramref name="authorId"/>
/// directly in the SQLite store and return its server-assigned /// directly in the SQLite store and return its server-assigned
/// id.</summary> /// id.</summary>
private long SeedBlogPost(string authorId, string title) public long SeedBlogPost(string authorId, string title)
{ {
using var scope = Services.CreateScope(); using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();

View file

@ -9,7 +9,7 @@ namespace Yavsc.Blogs.Controllers
{ {
[Authorize("BlogScope")] [Authorize("BlogScope")]
[Produces("application/json")] [Produces("application/json")]
[Route(APIPrefix + "/blog")] [Route(APIPrefix + "/" + BlogSpotPath)]
public class BlogApiController : Controller public class BlogApiController : Controller
{ {
private readonly BlogSpotService blogSpotService; private readonly BlogSpotService blogSpotService;
@ -19,14 +19,14 @@ namespace Yavsc.Blogs.Controllers
this.blogSpotService = blogSpotService; this.blogSpotService = blogSpotService;
} }
// GET: api/BlogApi // GET: api/v1/blogspot
[HttpGet] [HttpGet]
public async Task<IEnumerable<IBlogPost>> GetBlogspot(int start = 0, int take = 25) public async Task<IEnumerable<IBlogPost>> GetBlogspot(int start = 0, int take = 25)
{ {
return await blogSpotService.Index(User, null, start, take); return await blogSpotService.Index(User, null, start, take);
} }
// GET: api/BlogApi/5 // GET: api/v1/blogspot/5
[HttpGet("{id}", Name = "GetBlog")] [HttpGet("{id}", Name = "GetBlog")]
public async Task<IActionResult> GetBlog([FromRoute] long id) public async Task<IActionResult> GetBlog([FromRoute] long id)
{ {
@ -43,7 +43,7 @@ namespace Yavsc.Blogs.Controllers
return NotFound(); return NotFound();
} }
return Ok(blog); return Ok(blog.GetPayload());
} }
catch (AuthorizationFailureException) catch (AuthorizationFailureException)
{ {
@ -51,7 +51,7 @@ namespace Yavsc.Blogs.Controllers
} }
} }
// PUT: api/BlogApi/5 // PUT: api/v1/blogspot/5
[HttpPut("{id}")] [HttpPut("{id}")]
public async Task<IActionResult> PutBlog(long id, [FromBody] Models.Blog.BlogPost blog) public async Task<IActionResult> PutBlog(long id, [FromBody] Models.Blog.BlogPost blog)
{ {
@ -83,7 +83,7 @@ namespace Yavsc.Blogs.Controllers
return new StatusCodeResult(StatusCodes.Status204NoContent); return new StatusCodeResult(StatusCodes.Status204NoContent);
} }
// POST: api/v1/blog // POST: api/v1/blogspot
[HttpPost] [HttpPost]
public IActionResult PostBlog([FromBody] Models.Blog.BlogPost blog) public IActionResult PostBlog([FromBody] Models.Blog.BlogPost blog)
{ {
@ -116,7 +116,8 @@ namespace Yavsc.Blogs.Controllers
: (IFormFileCollection)new FormFileCollection(); : (IFormFileCollection)new FormFileCollection();
var uid = User.GetUserId(); var uid = User.GetUserId();
var post = blogSpotService.Create(uid, blog, files); var post = blogSpotService.Create(uid, blog, files);
return CreatedAtRoute("GetBlog", new { id = post.Id }, post); return CreatedAtRoute("GetBlog", new { id = post.Id },
post.GetPayload());
} }
// DELETE: api/BlogApi/5 // DELETE: api/BlogApi/5
@ -135,7 +136,7 @@ namespace Yavsc.Blogs.Controllers
} }
await blogSpotService.Delete(User, id); await blogSpotService.Delete(User, id);
return Ok(blog); return Ok(blog.GetPayload());
} }
/// <summary> /// <summary>

View file

@ -6,7 +6,7 @@ using static Yavsc.Constants;
namespace Yavsc.Blogs.Controllers namespace Yavsc.Blogs.Controllers
{ {
[Produces("application/json")] [Produces("application/json")]
[Route(APIPrefix + "/blogtags")] [Route(APIPrefix + "/" + BlogTagPath )]
public class BlogTagsApiController : Controller public class BlogTagsApiController : Controller
{ {
private readonly ApplicationDbContext _context; private readonly ApplicationDbContext _context;

View file

@ -8,7 +8,7 @@ using static Yavsc.Constants;
namespace Yavsc.Blogs.Controllers namespace Yavsc.Blogs.Controllers
{ {
[Produces("application/json")] [Produces("application/json")]
[Route(APIPrefix +"/circle")] [Route(APIPrefix +"/" + CirclePath)]
public class CircleApiController : Controller public class CircleApiController : Controller
{ {
private readonly ApplicationDbContext _context; private readonly ApplicationDbContext _context;

View file

@ -11,7 +11,7 @@ namespace Yavsc.Blogs.Controllers
{ {
[Authorize] [Authorize]
[Produces("application/json")] [Produces("application/json")]
[Route(APIPrefix + "/blogcomments")] [Route(APIPrefix + "/" + CommentsPath)]
public class CommentsApiController : Controller public class CommentsApiController : Controller
{ {
private readonly ApplicationDbContext _context; private readonly ApplicationDbContext _context;

View file

@ -0,0 +1,21 @@
using Yavsc.Models.Blog;
public static class PayloadHelpers
{
public static object GetPayload(this BlogPost post)
{
return new
{
post.Id,
post.Title,
post.Article,
post.DateCreated,
post.UserCreated,
post.DateModified,
post.UserModified,
post.AuthorId,
ACL = post.GetACL(),
Tags = post.GetTags()
};
}
}

View file

@ -85,7 +85,7 @@ namespace Yavsc.Models.Blog
public string[] GetTags() public string[] GetTags()
{ {
return Tags.Select(t => t.Tag.Name).ToArray(); return Tags?.Select(t => t.Tag.Name).ToArray() ?? Array.Empty<string>();
} }
[InverseProperty("Post")] [InverseProperty("Post")]
@ -106,6 +106,7 @@ namespace Yavsc.Models.Blog
[NotMapped] [NotMapped]
public bool IsPublished { get; set; } public bool IsPublished { get; set; }
[JsonIgnore]
/// <summary> /// <summary>
/// Explicit interface implementation of /// Explicit interface implementation of
/// <see cref="IBlogPost.Author"/>. The underlying /// <see cref="IBlogPost.Author"/>. The underlying

View file

@ -1,14 +1,17 @@
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using Yavsc.Models.Relationship; using Yavsc.Models.Relationship;
namespace Yavsc.Models.Blog namespace Yavsc.Models.Blog
{ {
public partial class BlogTag public partial class BlogTag
{ {
[JsonIgnore]
[ForeignKey("PostId")] [ForeignKey("PostId")]
public virtual BlogPost Post { get; set; } public virtual BlogPost Post { get; set; }
public long PostId { get; set; } public long PostId { get; set; }
[JsonIgnore]
[ForeignKey("TagId")] [ForeignKey("TagId")]
public virtual Tag Tag{ get; set; } public virtual Tag Tag{ get; set; }
public long TagId { get; set; } public long TagId { get; set; }

View file

@ -13,15 +13,17 @@ namespace Yavsc.Models.Blog
[YaStringLength(1024)] [YaStringLength(1024)]
public string Article { get; set; } public string Article { get; set; }
[ForeignKeyAttribute(nameof(ReceiverId))][JsonIgnore] [JsonIgnore]
[ForeignKeyAttribute(nameof(ReceiverId))]
public virtual BlogPost Post { get; set; } public virtual BlogPost Post { get; set; }
[Required] [Required]
public long ReceiverId { get; set; } public long ReceiverId { get; set; }
public bool Visible { get; set; } public bool Visible { get; set; }
[ForeignKeyAttribute("AuthorId")][JsonIgnore] [ForeignKeyAttribute("AuthorId")]
[JsonIgnore]
public virtual ApplicationUser Author { public virtual ApplicationUser Author {
get; set; get; set;
} }