Compare commits

...

4 Commits

Author SHA1 Message Date
641313f97a
refactor: remove Author schema
Introducing new User which serves both as author model and credential model
2023-08-12 14:24:27 +01:00
47b648f327
fix: fix markdown formatting inside templates 2023-08-11 21:51:16 +01:00
6f7fa67135
refactor: move DateFormatter to child ns 2023-08-11 21:34:04 +01:00
034bd66b29
feat: format template arguments 2023-08-11 21:33:14 +01:00
15 changed files with 178 additions and 164 deletions

View File

@ -12,7 +12,7 @@
@{ @{
ViewData["Title"] = post.Title; ViewData["Title"] = post.Title;
Author author = Model.Author; User author = Model.Author;
DateTimeOffset published = post.Published; DateTimeOffset published = post.Published;
} }
@ -27,8 +27,8 @@
<h1>@post.Title</h1> <h1>@post.Title</h1>
<p class="text-muted"> <p class="text-muted">
<img class="blog-author-icon" src="https://gravatar.com/avatar/@author.AvatarHash?s=28" alt="@author.Name"> <img class="blog-author-icon" src="https://gravatar.com/avatar/@author.AvatarHash?s=28" alt="@author.DisplayName">
@author.Name &bull; @author.DisplayName &bull;
<abbr data-bs-toggle="tooltip" data-bs-title="@published.ToString("dddd, d MMMM yyyy HH:mm")"> <abbr data-bs-toggle="tooltip" data-bs-title="@published.ToString("dddd, d MMMM yyyy HH:mm")">
Published @published.Humanize() Published @published.Humanize()

View File

@ -12,21 +12,24 @@ namespace OliverBooth.Areas.Blog.Pages;
public class Article : PageModel public class Article : PageModel
{ {
private readonly BlogService _blogService; private readonly BlogService _blogService;
private readonly BlogUserService _blogUserService;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="Article" /> class. /// Initializes a new instance of the <see cref="Article" /> class.
/// </summary> /// </summary>
/// <param name="blogService">The <see cref="BlogService" />.</param> /// <param name="blogService">The <see cref="BlogService" />.</param>
public Article(BlogService blogService) /// <param name="blogUserService">The <see cref="BlogUserService" />.</param>
public Article(BlogService blogService, BlogUserService blogUserService)
{ {
_blogService = blogService; _blogService = blogService;
_blogUserService = blogUserService;
} }
/// <summary> /// <summary>
/// Gets the author of the post. /// Gets the author of the post.
/// </summary> /// </summary>
/// <value>The author of the post.</value> /// <value>The author of the post.</value>
public Author Author { get; private set; } = null!; public User Author { get; private set; } = null!;
/// <summary> /// <summary>
/// Gets a value indicating whether the post is a legacy WordPress post. /// Gets a value indicating whether the post is a legacy WordPress post.
@ -51,7 +54,7 @@ public class Article : PageModel
} }
Post = post; Post = post;
Author = _blogService.TryGetAuthor(post, out Author? author) ? author : null!; Author = _blogUserService.TryGetUser(post.AuthorId, out User? author) ? author : null!;
return Page(); return Page();
} }
} }

View File

@ -13,14 +13,17 @@ namespace OliverBooth.Areas.Blog.Pages;
public class RawArticle : PageModel public class RawArticle : PageModel
{ {
private readonly BlogService _blogService; private readonly BlogService _blogService;
private readonly BlogUserService _blogUserService;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="RawArticle" /> class. /// Initializes a new instance of the <see cref="RawArticle" /> class.
/// </summary> /// </summary>
/// <param name="blogService">The <see cref="BlogService" />.</param> /// <param name="blogService">The <see cref="BlogService" />.</param>
public RawArticle(BlogService blogService) /// <param name="blogUserService">The <see cref="BlogUserService" />.</param>
public RawArticle(BlogService blogService, BlogUserService blogUserService)
{ {
_blogService = blogService; _blogService = blogService;
_blogUserService = blogUserService;
} }
public IActionResult OnGet(int year, int month, int day, string slug) public IActionResult OnGet(int year, int month, int day, string slug)
@ -34,8 +37,8 @@ public class RawArticle : PageModel
using Utf8ValueStringBuilder builder = ZString.CreateUtf8StringBuilder(); using Utf8ValueStringBuilder builder = ZString.CreateUtf8StringBuilder();
builder.AppendLine("# " + post.Title); builder.AppendLine("# " + post.Title);
if (_blogService.TryGetAuthor(post, out Author? author)) if (_blogUserService.TryGetUser(post.AuthorId, out User? author))
builder.AppendLine($"Author: {author.Name}"); builder.AppendLine($"Author: {author.DisplayName}");
builder.AppendLine($"Published: {post.Published:R}"); builder.AppendLine($"Published: {post.Published:R}");
if (post.Updated.HasValue) if (post.Updated.HasValue)

View File

@ -16,14 +16,17 @@ namespace OliverBooth.Controllers;
public sealed class BlogApiController : ControllerBase public sealed class BlogApiController : ControllerBase
{ {
private readonly BlogService _blogService; private readonly BlogService _blogService;
private readonly BlogUserService _blogUserService;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="BlogApiController" /> class. /// Initializes a new instance of the <see cref="BlogApiController" /> class.
/// </summary> /// </summary>
/// <param name="blogService">The <see cref="BlogService" />.</param> /// <param name="blogService">The <see cref="BlogService" />.</param>
public BlogApiController(BlogService blogService) /// <param name="blogUserService">The <see cref="BlogUserService" />.</param>
public BlogApiController(BlogService blogService, BlogUserService blogUserService)
{ {
_blogService = blogService; _blogService = blogService;
_blogUserService = blogUserService;
} }
[Route("count")] [Route("count")]
@ -67,12 +70,12 @@ public sealed class BlogApiController : ControllerBase
public IActionResult GetAuthor(Guid id) public IActionResult GetAuthor(Guid id)
{ {
if (!ValidateReferer()) return NotFound(); if (!ValidateReferer()) return NotFound();
if (!_blogService.TryGetAuthor(id, out Author? author)) return NotFound(); if (!_blogUserService.TryGetUser(id, out User? author)) return NotFound();
return Ok(new return Ok(new
{ {
id = author.Id, id = author.Id,
name = author.Name, name = author.DisplayName,
avatarHash = author.AvatarHash, avatarHash = author.AvatarHash,
}); });
} }
@ -80,6 +83,6 @@ public sealed class BlogApiController : ControllerBase
private bool ValidateReferer() private bool ValidateReferer()
{ {
var referer = Request.Headers["Referer"].ToString(); var referer = Request.Headers["Referer"].ToString();
return referer.StartsWith(Url.PageLink("/index",values: new{area="blog"})!); return referer.StartsWith(Url.PageLink("/index", values: new { area = "blog" })!);
} }
} }

View File

@ -1,75 +0,0 @@
using System.ComponentModel.DataAnnotations.Schema;
using System.Security.Cryptography;
using System.Text;
namespace OliverBooth.Data.Blog;
/// <summary>
/// Represents an author of a blog post.
/// </summary>
public sealed class Author : IEquatable<Author>
{
[NotMapped]
public string AvatarHash
{
get
{
if (EmailAddress is null)
{
return string.Empty;
}
using var md5 = MD5.Create();
ReadOnlySpan<char> span = EmailAddress.AsSpan();
int byteCount = Encoding.UTF8.GetByteCount(span);
Span<byte> bytes = stackalloc byte[byteCount];
Encoding.UTF8.GetBytes(span, bytes);
Span<byte> hash = stackalloc byte[16];
md5.TryComputeHash(bytes, hash, out _);
var builder = new StringBuilder();
foreach (byte b in hash)
{
builder.Append(b.ToString("x2"));
}
return builder.ToString();
}
}
/// <summary>
/// Gets or sets the email address of the author.
/// </summary>
/// <value>The email address.</value>
public string? EmailAddress { get; set; }
/// <summary>
/// Gets the ID of the author.
/// </summary>
/// <value>The ID.</value>
public Guid Id { get; private set; }
/// <summary>
/// Gets or sets the name of the author.
/// </summary>
/// <value>The name.</value>
public string Name { get; set; } = string.Empty;
public bool Equals(Author? other)
{
if (ReferenceEquals(null, other)) return false;
if (ReferenceEquals(this, other)) return true;
return Id == other.Id;
}
public override bool Equals(object? obj)
{
return ReferenceEquals(this, obj) || obj is Author other && Equals(other);
}
public override int GetHashCode()
{
return Id.GetHashCode();
}
}

View File

@ -1,21 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace OliverBooth.Data.Blog.Configuration;
/// <summary>
/// Represents the configuration for the <see cref="Author" /> entity.
/// </summary>
internal sealed class AuthorConfiguration : IEntityTypeConfiguration<Author>
{
/// <inheritdoc />
public void Configure(EntityTypeBuilder<Author> builder)
{
builder.ToTable("Author");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id);
builder.Property(e => e.Name).HasMaxLength(100).IsRequired();
builder.Property(e => e.EmailAddress).HasMaxLength(255).IsRequired(false);
}
}

View File

@ -20,18 +20,18 @@ public sealed class BlogContext : DbContext
_configuration = configuration; _configuration = configuration;
} }
/// <summary>
/// Gets the set of authors.
/// </summary>
/// <value>The set of authors.</value>
public DbSet<Author> Authors { get; internal set; } = null!;
/// <summary> /// <summary>
/// Gets the set of blog posts. /// Gets the set of blog posts.
/// </summary> /// </summary>
/// <value>The set of blog posts.</value> /// <value>The set of blog posts.</value>
public DbSet<BlogPost> BlogPosts { get; internal set; } = null!; public DbSet<BlogPost> BlogPosts { get; internal set; } = null!;
/// <summary>
/// Gets the set of users.
/// </summary>
/// <value>The set of users.</value>
public DbSet<User> Users { get; internal set; } = null!;
/// <inheritdoc /> /// <inheritdoc />
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {
@ -43,7 +43,7 @@ public sealed class BlogContext : DbContext
/// <inheritdoc /> /// <inheritdoc />
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
modelBuilder.ApplyConfiguration(new AuthorConfiguration());
modelBuilder.ApplyConfiguration(new BlogPostConfiguration()); modelBuilder.ApplyConfiguration(new BlogPostConfiguration());
modelBuilder.ApplyConfiguration(new UserConfiguration());
} }
} }

View File

@ -1,7 +1,7 @@
using System.Globalization; using System.Globalization;
using SmartFormat.Core.Extensions; using SmartFormat.Core.Extensions;
namespace OliverBooth; namespace OliverBooth.Formatting;
/// <summary> /// <summary>
/// Represents a SmartFormat formatter that formats a date. /// Represents a SmartFormat formatter that formats a date.

View File

@ -0,0 +1,38 @@
using Markdig;
using SmartFormat.Core.Extensions;
namespace OliverBooth.Formatting;
/// <summary>
/// Represents a SmartFormat formatter that formats markdown.
/// </summary>
internal sealed class MarkdownFormatter : IFormatter
{
private readonly IServiceProvider _serviceProvider;
/// <summary>
/// Initializes a new instance of the <see cref="MarkdownFormatter" /> class.
/// </summary>
/// <param name="serviceProvider">The <see cref="IServiceProvider" />.</param>
public MarkdownFormatter(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
/// <inheritdoc />
public bool CanAutoDetect { get; set; } = true;
/// <inheritdoc />
public string Name { get; set; } = "markdown";
/// <inheritdoc />
public bool TryEvaluateFormat(IFormattingInfo formattingInfo)
{
if (formattingInfo.CurrentValue is not string value)
return false;
var pipeline = _serviceProvider.GetService<MarkdownPipeline>();
formattingInfo.Write(Markdig.Markdown.ToHtml(value, pipeline));
return true;
}
}

View File

@ -12,6 +12,7 @@ namespace OliverBooth.Middleware;
internal sealed class RssMiddleware internal sealed class RssMiddleware
{ {
private readonly BlogService _blogService; private readonly BlogService _blogService;
private readonly BlogUserService _userService;
private readonly ConfigurationService _configurationService; private readonly ConfigurationService _configurationService;
/// <summary> /// <summary>
@ -19,12 +20,15 @@ internal sealed class RssMiddleware
/// </summary> /// </summary>
/// <param name="_">The request delegate.</param> /// <param name="_">The request delegate.</param>
/// <param name="blogService">The blog service.</param> /// <param name="blogService">The blog service.</param>
/// <param name="userService">The user service.</param>
/// <param name="configurationService">The configuration service.</param> /// <param name="configurationService">The configuration service.</param>
public RssMiddleware(RequestDelegate _, public RssMiddleware(RequestDelegate _,
BlogService blogService, BlogService blogService,
BlogUserService userService,
ConfigurationService configurationService) ConfigurationService configurationService)
{ {
_blogService = blogService; _blogService = blogService;
_userService = userService;
_configurationService = configurationService; _configurationService = configurationService;
} }
@ -42,14 +46,14 @@ internal sealed class RssMiddleware
string excerpt = _blogService.GetExcerpt(blogPost, out _); string excerpt = _blogService.GetExcerpt(blogPost, out _);
var description = $"{excerpt}<p><a href=\"{url}\">Read more...</a></p>"; var description = $"{excerpt}<p><a href=\"{url}\">Read more...</a></p>";
_blogService.TryGetAuthor(blogPost, out Author? author); _userService.TryGetUser(blogPost.AuthorId, out User? author);
var item = new BlogItem var item = new BlogItem
{ {
Title = blogPost.Title, Title = blogPost.Title,
Link = url, Link = url,
Comments = $"{url}#disqus_thread", Comments = $"{url}#disqus_thread",
Creator = author?.Name ?? string.Empty, Creator = author?.DisplayName ?? string.Empty,
PubDate = blogPost.Published.ToString("R"), PubDate = blogPost.Published.ToString("R"),
Guid = $"{baseUrl}?pid={blogPost.Id}", Guid = $"{baseUrl}?pid={blogPost.Id}",
Description = description Description = description

View File

@ -4,7 +4,6 @@ using Markdig;
using NLog; using NLog;
using NLog.Extensions.Logging; using NLog.Extensions.Logging;
using OliverBooth.Data; using OliverBooth.Data;
using OliverBooth.Markdown;
using OliverBooth.Markdown.Template; using OliverBooth.Markdown.Template;
using OliverBooth.Markdown.Timestamp; using OliverBooth.Markdown.Timestamp;
using OliverBooth.Middleware; using OliverBooth.Middleware;
@ -19,6 +18,7 @@ builder.Logging.AddNLog();
builder.Services.AddHostedSingleton<LoggingService>(); builder.Services.AddHostedSingleton<LoggingService>();
builder.Services.AddSingleton<ConfigurationService>(); builder.Services.AddSingleton<ConfigurationService>();
builder.Services.AddSingleton<TemplateService>(); builder.Services.AddSingleton<TemplateService>();
builder.Services.AddSingleton<BlogUserService>();
builder.Services.AddSingleton(provider => new MarkdownPipelineBuilder() builder.Services.AddSingleton(provider => new MarkdownPipelineBuilder()
.Use<TimestampExtension>() .Use<TimestampExtension>()

View File

@ -64,44 +64,6 @@ public sealed class BlogService
return RenderContent(result).Trim(); return RenderContent(result).Trim();
} }
/// <summary>
/// Attempts to find the author by ID.
/// </summary>
/// <param name="id">The ID of the author.</param>
/// <param name="author">
/// When this method returns, contains the <see cref="Author" /> associated with the specified ID, if the author
/// is found; otherwise, <see langword="null" />.
/// </param>
/// <returns><see langword="true" /> if the author is found; otherwise, <see langword="false" />.</returns>
/// <exception cref="ArgumentNullException"><paramref name="post" /> is <see langword="null" />.</exception>
public bool TryGetAuthor(Guid id, [NotNullWhen(true)] out Author? author)
{
using BlogContext context = _dbContextFactory.CreateDbContext();
author = context.Authors.FirstOrDefault(a => a.Id == id);
return author is not null;
}
/// <summary>
/// Attempts to find the author of a blog post.
/// </summary>
/// <param name="post">The blog post.</param>
/// <param name="author">
/// When this method returns, contains the <see cref="Author" /> associated with the specified blog post, if the
/// author is found; otherwise, <see langword="null" />.
/// </param>
/// <returns><see langword="true" /> if the author is found; otherwise, <see langword="false" />.</returns>
/// <exception cref="ArgumentNullException"><paramref name="post" /> is <see langword="null" />.</exception>
public bool TryGetAuthor(BlogPost post, [NotNullWhen(true)] out Author? author)
{
if (post is null) throw new ArgumentNullException(nameof(post));
using BlogContext context = _dbContextFactory.CreateDbContext();
author = context.Authors.FirstOrDefault(a => a.Id == post.AuthorId);
return author is not null;
}
/// <summary> /// <summary>
/// Attempts to find a blog post by its publication date and slug. /// Attempts to find a blog post by its publication date and slug.
/// </summary> /// </summary>

View File

@ -0,0 +1,83 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.EntityFrameworkCore;
using OliverBooth.Data;
using OliverBooth.Data.Blog;
namespace OliverBooth.Services;
/// <summary>
/// Represents a service for managing blog users.
/// </summary>
public sealed class BlogUserService
{
private readonly IDbContextFactory<BlogContext> _dbContextFactory;
/// <summary>
/// Initializes a new instance of the <see cref="BlogUserService" /> class.
/// </summary>
/// <param name="dbContextFactory">The database context factory.</param>
public BlogUserService(IDbContextFactory<BlogContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
/// <summary>
/// Attempts to authenticate the user with the specified email address and password.
/// </summary>
/// <param name="emailAddress">The email address.</param>
/// <param name="password">The password.</param>
/// <param name="user">
/// When this method returns, contains the user with the specified email address and password, if the user
/// exists; otherwise, <see langword="null" />.
/// </param>
/// <returns>
/// <see langword="true" /> if the authentication was successful; otherwise, <see langword="false" />.
/// </returns>
public bool TryAuthenticateUser(string? emailAddress, string? password, [NotNullWhen(true)] out User? user)
{
if (string.IsNullOrWhiteSpace(emailAddress) || string.IsNullOrWhiteSpace(password))
{
user = null;
return false;
}
using BlogContext context = _dbContextFactory.CreateDbContext();
user = context.Users.FirstOrDefault(u => u.EmailAddress == emailAddress);
if (user is null)
{
return false;
}
string hashedPassword = BC.HashPassword(password, user.Salt);
return hashedPassword == user.Password;
}
/// <summary>
/// Attempts to retrieve the user with the specified user ID.
/// </summary>
/// <param name="userId">The user ID.</param>
/// <param name="user">
/// When this method returns, contains the user with the specified user ID, if the user exists; otherwise,
/// <see langword="null" />.
/// </param>
/// <returns><see langword="true" /> if the user exists; otherwise, <see langword="false" />.</returns>
public bool TryGetUser(Guid userId, [NotNullWhen(true)] out User? user)
{
using BlogContext context = _dbContextFactory.CreateDbContext();
user = context.Users.FirstOrDefault(u => u.Id == userId);
return user is not null;
}
/// <summary>
/// Returns a value indicating whether the specified user requires a password reset.
/// </summary>
/// <param name="user">The user.</param>
/// <returns>
/// <see langword="true" /> if the specified user requires a password reset; otherwise,
/// <see langword="false" />.
/// </returns>
public bool UserRequiresPasswordReset(User user)
{
return string.IsNullOrEmpty(user.Password) || string.IsNullOrEmpty(user.Salt);
}
}

View File

@ -1,7 +1,10 @@
using System.Buffers.Binary;
using Markdig;
using Markdig.Syntax;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using OliverBooth.Data; using OliverBooth.Data;
using OliverBooth.Data.Web; using OliverBooth.Data.Web;
using OliverBooth.Markdown; using OliverBooth.Formatting;
using OliverBooth.Markdown.Template; using OliverBooth.Markdown.Template;
using SmartFormat; using SmartFormat;
using SmartFormat.Extensions; using SmartFormat.Extensions;
@ -13,20 +16,25 @@ namespace OliverBooth.Services;
/// </summary> /// </summary>
public sealed class TemplateService public sealed class TemplateService
{ {
private static readonly Random Random = new();
private readonly IServiceProvider _serviceProvider;
private readonly IDbContextFactory<WebContext> _webContextFactory; private readonly IDbContextFactory<WebContext> _webContextFactory;
private readonly SmartFormatter _formatter; private readonly SmartFormatter _formatter;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="TemplateService" /> class. /// Initializes a new instance of the <see cref="TemplateService" /> class.
/// </summary> /// </summary>
/// <param name="serviceProvider">The <see cref="IServiceProvider" />.</param>
/// <param name="webContextFactory">The <see cref="WebContext" /> factory.</param> /// <param name="webContextFactory">The <see cref="WebContext" /> factory.</param>
public TemplateService(IDbContextFactory<WebContext> webContextFactory) public TemplateService(IServiceProvider serviceProvider, IDbContextFactory<WebContext> webContextFactory)
{ {
_formatter = Smart.CreateDefaultSmartFormat(); _formatter = Smart.CreateDefaultSmartFormat();
_formatter.AddExtensions(new DefaultSource()); _formatter.AddExtensions(new DefaultSource());
_formatter.AddExtensions(new ReflectionSource()); _formatter.AddExtensions(new ReflectionSource());
_formatter.AddExtensions(new DateFormatter()); _formatter.AddExtensions(new DateFormatter());
_formatter.AddExtensions(new MarkdownFormatter(serviceProvider));
_serviceProvider = serviceProvider;
_webContextFactory = webContextFactory; _webContextFactory = webContextFactory;
Current = this; Current = this;
} }
@ -44,6 +52,7 @@ public sealed class TemplateService
public string RenderTemplate(TemplateInline templateInline) public string RenderTemplate(TemplateInline templateInline)
{ {
if (templateInline is null) throw new ArgumentNullException(nameof(templateInline)); if (templateInline is null) throw new ArgumentNullException(nameof(templateInline));
using WebContext webContext = _webContextFactory.CreateDbContext(); using WebContext webContext = _webContextFactory.CreateDbContext();
ArticleTemplate? template = webContext.ArticleTemplates.Find(templateInline.Name); ArticleTemplate? template = webContext.ArticleTemplates.Find(templateInline.Name);
if (template is null) if (template is null)
@ -51,16 +60,21 @@ public sealed class TemplateService
return $"{{{{{templateInline.Name}}}}}"; return $"{{{{{templateInline.Name}}}}}";
} }
Span<byte> randomBytes = stackalloc byte[20];
Random.NextBytes(randomBytes);
var formatted = new var formatted = new
{ {
templateInline.ArgumentList, templateInline.ArgumentList,
templateInline.ArgumentString, templateInline.ArgumentString,
templateInline.Params, templateInline.Params,
RandomInt = BinaryPrimitives.ReadInt32LittleEndian(randomBytes[..4]),
RandomGuid = new Guid(randomBytes[4..]).ToString("N"),
}; };
try try
{ {
return Markdig.Markdown.ToHtml(_formatter.Format(template.FormatString, formatted)); return _formatter.Format(template.FormatString, formatted);
} }
catch catch
{ {