refactor: move blog to separate app

I'd ideally like to keep the blog. subdomain the same, and while there are a few ways to achieve this it is much simpler to just dedicate a separate application for the subdomain.

This change also adjusts the webhost builder extensions to default to ports 443/80, and each app now explicitly sets the port it needs.
This commit is contained in:
Oliver Booth 2023-08-12 20:13:47 +01:00
parent b3fd6e9420
commit e8bc50bbdf
Signed by: oliverbooth
GPG Key ID: B89D139977693FED
38 changed files with 802 additions and 543 deletions

View File

@ -1,58 +1,66 @@
using Humanizer;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using OliverBooth.Data.Blog;
using OliverBooth.Services;
using OliverBooth.Blog.Data;
using OliverBooth.Blog.Services;
namespace OliverBooth.Controllers;
namespace OliverBooth.Blog.Controllers;
/// <summary>
/// Represents a controller for the blog API.
/// </summary>
[ApiController]
[Route("api/blog")]
[Route("api")]
[Produces("application/json")]
[EnableCors("BlogApi")]
[EnableCors("OliverBooth")]
public sealed class BlogApiController : ControllerBase
{
private readonly BlogService _blogService;
private readonly BlogUserService _blogUserService;
private readonly IBlogPostService _blogPostService;
private readonly IUserService _userService;
/// <summary>
/// Initializes a new instance of the <see cref="BlogApiController" /> class.
/// </summary>
/// <param name="blogService">The <see cref="BlogService" />.</param>
/// <param name="blogUserService">The <see cref="BlogUserService" />.</param>
public BlogApiController(BlogService blogService, BlogUserService blogUserService)
/// <param name="blogPostService">The <see cref="IBlogPostService" />.</param>
/// <param name="userService">The <see cref="IUserService" />.</param>
public BlogApiController(IBlogPostService blogPostService, IUserService userService)
{
_blogService = blogService;
_blogUserService = blogUserService;
_blogPostService = blogPostService;
_userService = userService;
}
[Route("count")]
public IActionResult Count()
{
if (!ValidateReferer()) return NotFound();
return Ok(new { count = _blogService.AllPosts.Count });
return Ok(new { count = _blogPostService.GetAllBlogPosts().Count });
}
[HttpGet("all/{skip:int?}/{take:int?}")]
public IActionResult GetAllBlogPosts(int skip = 0, int take = -1)
{
if (!ValidateReferer()) return NotFound();
if (take == -1) take = _blogService.AllPosts.Count;
return Ok(_blogService.AllPosts.Skip(skip).Take(take).Select(post => new
// TODO yes I'm aware I can use the new pagination I wrote, this will be added soon.
IReadOnlyList<IBlogPost> allPosts = _blogPostService.GetAllBlogPosts();
if (take == -1)
{
take = allPosts.Count;
}
return Ok(allPosts.Skip(skip).Take(take).Select(post => new
{
id = post.Id,
commentsEnabled = post.EnableComments,
identifier = post.GetDisqusIdentifier(),
author = post.AuthorId,
author = post.Author.Id,
title = post.Title,
published = post.Published.ToUnixTimeSeconds(),
formattedDate = post.Published.ToString("dddd, d MMMM yyyy HH:mm"),
updated = post.Updated?.ToUnixTimeSeconds(),
humanizedTimestamp = post.Updated?.Humanize() ?? post.Published.Humanize(),
excerpt = _blogService.GetExcerpt(post, out bool trimmed),
excerpt = _blogPostService.RenderExcerpt(post, out bool trimmed),
trimmed,
url = Url.Page("/Article",
new
@ -70,19 +78,19 @@ public sealed class BlogApiController : ControllerBase
public IActionResult GetAuthor(Guid id)
{
if (!ValidateReferer()) return NotFound();
if (!_blogUserService.TryGetUser(id, out User? author)) return NotFound();
if (!_userService.TryGetUser(id, out IUser? author)) return NotFound();
return Ok(new
{
id = author.Id,
name = author.DisplayName,
avatarHash = author.AvatarHash,
avatarUrl = author.AvatarUrl,
});
}
private bool ValidateReferer()
{
var referer = Request.Headers["Referer"].ToString();
return referer.StartsWith(Url.PageLink("/index", values: new { area = "blog" })!);
return referer.StartsWith(Url.PageLink("/index")!);
}
}

View File

@ -1,13 +1,12 @@
using Microsoft.EntityFrameworkCore;
using OliverBooth.Data.Blog;
using OliverBooth.Data.Blog.Configuration;
using OliverBooth.Blog.Data.Configuration;
namespace OliverBooth.Data;
namespace OliverBooth.Blog.Data;
/// <summary>
/// Represents a session with the blog database.
/// </summary>
public sealed class BlogContext : DbContext
internal sealed class BlogContext : DbContext
{
private readonly IConfiguration _configuration;
@ -21,16 +20,16 @@ public sealed class BlogContext : DbContext
}
/// <summary>
/// Gets the set of blog posts.
/// Gets the collection of blog posts in the database.
/// </summary>
/// <value>The set of blog posts.</value>
public DbSet<BlogPost> BlogPosts { get; internal set; } = null!;
/// <value>The collection of blog posts.</value>
public DbSet<BlogPost> BlogPosts { get; private set; } = null!;
/// <summary>
/// Gets the set of users.
/// Gets the collection of users in the database.
/// </summary>
/// <value>The set of users.</value>
public DbSet<User> Users { get; internal set; } = null!;
/// <value>The collection of users.</value>
public DbSet<User> Users { get; private set; } = null!;
/// <inheritdoc />
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)

View File

@ -0,0 +1,102 @@
using System.ComponentModel.DataAnnotations.Schema;
using SmartFormat;
namespace OliverBooth.Blog.Data;
/// <inheritdoc />
internal sealed class BlogPost : IBlogPost
{
/// <inheritdoc />
[NotMapped]
public IBlogAuthor Author { get; internal set; } = null!;
/// <inheritdoc />
public string Body { get; internal set; } = string.Empty;
/// <inheritdoc />
public bool EnableComments { get; internal set; }
/// <inheritdoc />
public Guid Id { get; private set; } = Guid.NewGuid();
/// <inheritdoc />
public bool IsRedirect { get; internal set; }
/// <inheritdoc />
public DateTimeOffset Published { get; internal set; }
/// <inheritdoc />
public Uri? RedirectUrl { get; internal set; }
/// <inheritdoc />
public string Slug { get; internal set; } = string.Empty;
/// <inheritdoc />
public string Title { get; internal set; } = string.Empty;
/// <inheritdoc />
public DateTimeOffset? Updated { get; internal set; }
/// <summary>
/// Gets or sets the ID of the author of this blog post.
/// </summary>
/// <value>The ID of the author of this blog post.</value>
internal Guid AuthorId { get; set; }
/// <summary>
/// Gets or sets the base URL of the Disqus comments for the blog post.
/// </summary>
/// <value>The Disqus base URL.</value>
internal string? DisqusDomain { get; set; }
/// <summary>
/// Gets or sets the identifier of the Disqus comments for the blog post.
/// </summary>
/// <value>The Disqus identifier.</value>
internal string? DisqusIdentifier { get; set; }
/// <summary>
/// Gets or sets the URL path of the Disqus comments for the blog post.
/// </summary>
/// <value>The Disqus URL path.</value>
internal string? DisqusPath { get; set; }
/// <summary>
/// Gets or sets the WordPress ID of this blog post.
/// </summary>
/// <value>The WordPress ID of this blog post.</value>
internal int? WordPressId { get; set; }
/// <summary>
/// Gets the Disqus domain for the blog post.
/// </summary>
/// <returns>The Disqus domain.</returns>
public string GetDisqusDomain()
{
return string.IsNullOrWhiteSpace(DisqusDomain)
? "https://oliverbooth.dev/blog"
: Smart.Format(DisqusDomain, this);
}
/// <inheritdoc />
public string GetDisqusIdentifier()
{
return string.IsNullOrWhiteSpace(DisqusIdentifier) ? $"post-{Id}" : Smart.Format(DisqusIdentifier, this);
}
/// <inheritdoc />
public string GetDisqusUrl()
{
string path = string.IsNullOrWhiteSpace(DisqusPath)
? $"{Published:yyyy/MM/dd}/{Slug}/"
: Smart.Format(DisqusPath, this);
return $"{GetDisqusDomain()}/{path}";
}
/// <inheritdoc />
public string GetDisqusPostId()
{
return WordPressId?.ToString() ?? Id.ToString();
}
}

View File

@ -1,11 +1,9 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace OliverBooth.Data.Blog.Configuration;
namespace OliverBooth.Blog.Data.Configuration;
/// <summary>
/// Represents the configuration for the <see cref="BlogPost" /> entity.
/// </summary>
internal sealed class BlogPostConfiguration : IEntityTypeConfiguration<BlogPost>
{
/// <inheritdoc />
@ -23,7 +21,7 @@ internal sealed class BlogPostConfiguration : IEntityTypeConfiguration<BlogPost>
builder.Property(e => e.Title).HasMaxLength(255).IsRequired();
builder.Property(e => e.Body).IsRequired();
builder.Property(e => e.IsRedirect).IsRequired();
builder.Property(e => e.RedirectUrl).IsRequired(false);
builder.Property(e => e.RedirectUrl).HasConversion<UriToStringConverter>().HasMaxLength(255).IsRequired(false);
builder.Property(e => e.EnableComments).IsRequired();
builder.Property(e => e.DisqusDomain).IsRequired(false);
builder.Property(e => e.DisqusIdentifier).IsRequired(false);

View File

@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace OliverBooth.Blog.Data.Configuration;
internal sealed class UserConfiguration : IEntityTypeConfiguration<User>
{
/// <inheritdoc />
public void Configure(EntityTypeBuilder<User> builder)
{
RelationalEntityTypeBuilderExtensions.ToTable((EntityTypeBuilder)builder, "User");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).IsRequired();
builder.Property(e => e.DisplayName).HasMaxLength(50).IsRequired();
builder.Property(e => e.EmailAddress).HasMaxLength(255).IsRequired();
builder.Property(e => e.Password).HasMaxLength(255).IsRequired();
builder.Property(e => e.Salt).HasMaxLength(255).IsRequired();
builder.Property(e => e.Registered).IsRequired();
}
}

View File

@ -0,0 +1,32 @@
namespace OliverBooth.Blog.Data;
/// <summary>
/// Represents the author of a blog post.
/// </summary>
public interface IBlogAuthor
{
/// <summary>
/// Gets the URL of the author's avatar.
/// </summary>
/// <value>The URL of the author's avatar.</value>
Uri AvatarUrl { get; }
/// <summary>
/// Gets the display name of the author.
/// </summary>
/// <value>The display name of the author.</value>
string DisplayName { get; }
/// <summary>
/// Gets the unique identifier of the author.
/// </summary>
/// <value>The unique identifier of the author.</value>
Guid Id { get; }
/// <summary>
/// Gets the URL of the author's avatar.
/// </summary>
/// <param name="size">The size of the avatar.</param>
/// <returns>The URL of the author's avatar.</returns>
Uri GetAvatarUrl(int size = 28);
}

View File

@ -0,0 +1,89 @@
namespace OliverBooth.Blog.Data;
/// <summary>
/// Represents a blog post.
/// </summary>
public interface IBlogPost
{
/// <summary>
/// Gets the author of the post.
/// </summary>
/// <value>The author of the post.</value>
IBlogAuthor Author { get; }
/// <summary>
/// Gets the body of the post.
/// </summary>
/// <value>The body of the post.</value>
string Body { get; }
/// <summary>
/// Gets a value indicating whether comments are enabled for the post.
/// </summary>
/// <value>
/// <see langword="true" /> if comments are enabled for the post; otherwise, <see langword="false" />.
/// </value>
bool EnableComments { get; }
/// <summary>
/// Gets the ID of the post.
/// </summary>
/// <value>The ID of the post.</value>
Guid Id { get; }
/// <summary>
/// Gets a value indicating whether the post redirects to another URL.
/// </summary>
/// <value>
/// <see langword="true" /> if the post redirects to another URL; otherwise, <see langword="false" />.
/// </value>
bool IsRedirect { get; }
/// <summary>
/// Gets the date and time the post was published.
/// </summary>
/// <value>The publication date and time.</value>
DateTimeOffset Published { get; }
/// <summary>
/// Gets the URL to which the post redirects.
/// </summary>
/// <value>The URL to which the post redirects, or <see langword="null" /> if the post does not redirect.</value>
Uri? RedirectUrl { get; }
/// <summary>
/// Gets the slug of the post.
/// </summary>
/// <value>The slug of the post.</value>
string Slug { get; }
/// <summary>
/// Gets the title of the post.
/// </summary>
/// <value>The title of the post.</value>
string Title { get; }
/// <summary>
/// Gets the date and time the post was last updated.
/// </summary>
/// <value>The update date and time, or <see langword="null" /> if the post has not been updated.</value>
DateTimeOffset? Updated { get; }
/// <summary>
/// Gets the Disqus identifier for the post.
/// </summary>
/// <returns>The Disqus identifier for the post.</returns>
string GetDisqusIdentifier();
/// <summary>
/// Gets the Disqus URL for the post.
/// </summary>
/// <returns>The Disqus URL for the post.</returns>
string GetDisqusUrl();
/// <summary>
/// Gets the Disqus post ID for the post.
/// </summary>
/// <returns>The Disqus post ID for the post.</returns>
string GetDisqusPostId();
}

View File

@ -0,0 +1,54 @@
namespace OliverBooth.Blog.Data;
/// <summary>
/// Represents a user which can log in to the blog.
/// </summary>
public interface IUser
{
/// <summary>
/// Gets the URL of the user's avatar.
/// </summary>
/// <value>The URL of the user's avatar.</value>
Uri AvatarUrl { get; }
/// <summary>
/// Gets the email address of the user.
/// </summary>
/// <value>The email address of the user.</value>
string EmailAddress { get; }
/// <summary>
/// Gets the display name of the author.
/// </summary>
/// <value>The display name of the author.</value>
string DisplayName { get; }
/// <summary>
/// Gets the unique identifier of the user.
/// </summary>
/// <value>The unique identifier of the user.</value>
Guid Id { get; }
/// <summary>
/// Gets the date and time the user registered.
/// </summary>
/// <value>The registration date and time.</value>
DateTimeOffset Registered { get; }
/// <summary>
/// Gets the URL of the user's avatar.
/// </summary>
/// <param name="size">The size of the avatar.</param>
/// <returns>The URL of the user's avatar.</returns>
Uri GetAvatarUrl(int size = 28);
/// <summary>
/// Returns a value indicating whether the specified password is valid for the user.
/// </summary>
/// <param name="password">The password to test.</param>
/// <returns>
/// <see langword="true" /> if the specified password is valid for the user; otherwise,
/// <see langword="false" />.
/// </returns>
bool TestCredentials(string password);
}

View File

@ -0,0 +1,73 @@
using System.ComponentModel.DataAnnotations.Schema;
using System.Security.Cryptography;
using System.Text;
using Cysharp.Text;
namespace OliverBooth.Blog.Data;
/// <summary>
/// Represents a user.
/// </summary>
internal sealed class User : IUser, IBlogAuthor
{
/// <inheritdoc cref="IUser.AvatarUrl" />
[NotMapped]
public Uri AvatarUrl => GetAvatarUrl();
/// <inheritdoc />
public string EmailAddress { get; set; } = string.Empty;
/// <inheritdoc cref="IUser.DisplayName" />
public string DisplayName { get; set; } = string.Empty;
/// <inheritdoc cref="IUser.Id" />
public Guid Id { get; private set; } = Guid.NewGuid();
/// <inheritdoc />
public DateTimeOffset Registered { get; private set; } = DateTimeOffset.UtcNow;
/// <summary>
/// Gets or sets the password hash.
/// </summary>
/// <value>The password hash.</value>
internal string Password { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the salt used to hash the password.
/// </summary>
/// <value>The salt used to hash the password.</value>
internal string Salt { get; set; } = string.Empty;
/// <inheritdoc cref="IUser.GetAvatarUrl" />
public Uri GetAvatarUrl(int size = 28)
{
if (string.IsNullOrWhiteSpace(EmailAddress))
{
return new Uri($"https://www.gravatar.com/avatar/0?size={size}");
}
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.TryHashData(bytes, hash, out _);
using Utf8ValueStringBuilder builder = ZString.CreateUtf8StringBuilder();
Span<char> hex = stackalloc char[2];
for (var index = 0; index < hash.Length; index++)
{
if (hash[index].TryFormat(hex, out _, "x2")) builder.Append(hex);
else builder.Append("00");
}
return new Uri($"https://www.gravatar.com/avatar/{builder}?size={size}");
}
/// <inheritdoc />
public bool TestCredentials(string password)
{
return false;
}
}

View File

@ -1,4 +1,4 @@
namespace OliverBooth.Middleware;
namespace OliverBooth.Blog.Middleware;
internal static class RssEndpointExtensions
{

View File

@ -1,35 +1,18 @@
using System.Diagnostics.CodeAnalysis;
using System.Xml.Serialization;
using OliverBooth.Data.Blog;
using OliverBooth.Blog.Data;
using OliverBooth.Blog.Services;
using OliverBooth.Data.Rss;
using OliverBooth.Services;
namespace OliverBooth.Middleware;
namespace OliverBooth.Blog.Middleware;
/// <summary>
/// Represents the RSS middleware.
/// </summary>
internal sealed class RssMiddleware
{
private readonly BlogService _blogService;
private readonly BlogUserService _userService;
private readonly ConfigurationService _configurationService;
private readonly IBlogPostService _blogPostService;
/// <summary>
/// Initializes a new instance of the <see cref="RssMiddleware" /> class.
/// </summary>
/// <param name="_">The request delegate.</param>
/// <param name="blogService">The blog service.</param>
/// <param name="userService">The user service.</param>
/// <param name="configurationService">The configuration service.</param>
public RssMiddleware(RequestDelegate _,
BlogService blogService,
BlogUserService userService,
ConfigurationService configurationService)
public RssMiddleware(RequestDelegate _, IBlogPostService blogPostService)
{
_blogService = blogService;
_userService = userService;
_configurationService = configurationService;
_blogPostService = blogPostService;
}
[SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "Middleware")]
@ -40,28 +23,25 @@ internal sealed class RssMiddleware
var baseUrl = $"https://{context.Request.Host}/blog";
var blogItems = new List<BlogItem>();
foreach (BlogPost blogPost in _blogService.AllPosts.OrderByDescending(p => p.Published))
foreach (IBlogPost post in _blogPostService.GetAllBlogPosts())
{
var url = $"{baseUrl}/{blogPost.Published:yyyy/MM/dd}/{blogPost.Slug}";
string excerpt = _blogService.GetExcerpt(blogPost, out _);
var url = $"{baseUrl}/{post.Published:yyyy/MM/dd}/{post.Slug}";
string excerpt = _blogPostService.RenderExcerpt(post, out _);
var description = $"{excerpt}<p><a href=\"{url}\">Read more...</a></p>";
_userService.TryGetUser(blogPost.AuthorId, out User? author);
var item = new BlogItem
{
Title = blogPost.Title,
Title = post.Title,
Link = url,
Comments = $"{url}#disqus_thread",
Creator = author?.DisplayName ?? string.Empty,
PubDate = blogPost.Published.ToString("R"),
Guid = $"{baseUrl}?pid={blogPost.Id}",
Creator = post.Author.DisplayName,
PubDate = post.Published.ToString("R"),
Guid = $"{baseUrl}?pid={post.Id}",
Description = description
};
blogItems.Add(item);
}
string siteTitle = _configurationService.GetSiteConfiguration("SiteTitle") ?? string.Empty;
var rss = new BlogRoot
{
Channel = new BlogChannel
@ -73,7 +53,7 @@ internal sealed class RssMiddleware
Description = $"{baseUrl}/",
LastBuildDate = DateTimeOffset.UtcNow.ToString("R"),
Link = $"{baseUrl}/",
Title = siteTitle,
Title = "Oliver Booth",
Generator = $"{baseUrl}/",
Items = blogItems
}
@ -90,6 +70,5 @@ internal sealed class RssMiddleware
await using var writer = new StreamWriter(context.Response.BodyWriter.AsStream());
serializer.Serialize(writer, rss, xmlNamespaces);
// await context.Response.WriteAsync(document.OuterXml);
}
}

View File

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OliverBooth.Common\OliverBooth.Common.csproj"/>
</ItemGroup>
</Project>

View File

@ -1,9 +1,7 @@
@page "/blog/{year:int}/{month:int}/{day:int}/{slug}"
@page "/{year:int}/{month:int}/{day:int}/{slug}"
@using Humanizer
@using OliverBooth.Data.Blog
@using OliverBooth.Services
@model OliverBooth.Areas.Blog.Pages.Article
@inject BlogService BlogService
@using OliverBooth.Blog.Data
@model Article
@if (Model.Post is not { } post)
{
@ -12,14 +10,14 @@
@{
ViewData["Title"] = post.Title;
User author = Model.Author;
IBlogAuthor author = post.Author;
DateTimeOffset published = post.Published;
}
<nav style="--bs-breadcrumb-divider: '>';" aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a asp-area="blog" asp-page="/index">Blog</a>
<a asp-page="/index">Blog</a>
</li>
<li class="breadcrumb-item active" aria-current="page">@post.Title</li>
</ol>
@ -27,7 +25,7 @@
<h1>@post.Title</h1>
<p class="text-muted">
<img class="blog-author-icon" src="https://gravatar.com/avatar/@author.AvatarHash?s=28" alt="@author.DisplayName">
<img class="blog-author-icon" src="@author.AvatarUrl" alt="@author.DisplayName">
@author.DisplayName &bull;
<abbr data-bs-toggle="tooltip" data-bs-title="@published.ToString("dddd, d MMMM yyyy HH:mm")">
@ -49,9 +47,9 @@
}
</p>
<article>
@Html.Raw(BlogService.GetContent(post))
@* @Html.Raw(BlogService.GetContent(post)) *@
@Html.Raw(post.Body)
</article>
<hr>
@ -64,7 +62,7 @@
this.page.url = "@post.GetDisqusUrl()";
this.page.identifier = "@post.GetDisqusIdentifier()";
this.page.title = "@post.Title";
this.page.postId = "@(post.WordPressId?.ToString() ?? post.Id.ToString())";
this.page.postId = "@post.GetDisqusPostId()";
};
(function() {

View File

@ -1,9 +1,9 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using OliverBooth.Data.Blog;
using OliverBooth.Services;
using OliverBooth.Blog.Data;
using OliverBooth.Blog.Services;
namespace OliverBooth.Areas.Blog.Pages;
namespace OliverBooth.Blog.Pages;
/// <summary>
/// Represents the page model for the <c>Article</c> page.
@ -11,26 +11,18 @@ namespace OliverBooth.Areas.Blog.Pages;
[Area("blog")]
public class Article : PageModel
{
private readonly BlogService _blogService;
private readonly BlogUserService _blogUserService;
private readonly IBlogPostService _blogPostService;
/// <summary>
/// Initializes a new instance of the <see cref="Article" /> class.
/// </summary>
/// <param name="blogService">The <see cref="BlogService" />.</param>
/// <param name="blogUserService">The <see cref="BlogUserService" />.</param>
public Article(BlogService blogService, BlogUserService blogUserService)
/// <param name="blogPostService">The <see cref="IBlogPostService" />.</param>
public Article(IBlogPostService blogPostService)
{
_blogService = blogService;
_blogUserService = blogUserService;
_blogPostService = blogPostService;
}
/// <summary>
/// Gets the author of the post.
/// </summary>
/// <value>The author of the post.</value>
public User Author { get; private set; } = null!;
/*
/// <summary>
/// Gets a value indicating whether the post is a legacy WordPress post.
/// </summary>
@ -38,23 +30,29 @@ public class Article : PageModel
/// <see langword="true" /> if the post is a legacy WordPress post; otherwise, <see langword="false" />.
/// </value>
public bool IsWordPressLegacyPost => Post.WordPressId.HasValue;
*/
/// <summary>
/// Gets the requested blog post.
/// </summary>
/// <value>The requested blog post.</value>
public BlogPost Post { get; private set; } = null!;
public IBlogPost Post { get; private set; } = null!;
public IActionResult OnGet(int year, int month, int day, string slug)
{
if (!_blogService.TryGetBlogPost(year, month, day, slug, out BlogPost? post))
var date = new DateOnly(year, month, day);
if (!_blogPostService.TryGetPost(date, slug, out IBlogPost? post))
{
Response.StatusCode = 404;
return NotFound();
}
if (post.IsRedirect)
{
return Redirect(post.RedirectUrl!.ToString());
}
Post = post;
Author = _blogUserService.TryGetUser(post.AuthorId, out User? author) ? author : null!;
return Page();
}
}

View File

@ -1,5 +1,5 @@
@page
@model OliverBooth.Areas.Blog.Pages.Index
@model Index
@{
ViewData["Title"] = "Blog";

View File

@ -1,18 +1,18 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using OliverBooth.Data.Blog;
using OliverBooth.Services;
using OliverBooth.Blog.Data;
using OliverBooth.Blog.Services;
namespace OliverBooth.Areas.Blog.Pages;
namespace OliverBooth.Blog.Pages;
[Area("blog")]
public class Index : PageModel
{
private readonly BlogService _blogService;
private readonly IBlogPostService _blogPostService;
public Index(BlogService blogService)
public Index(IBlogPostService blogPostService)
{
_blogService = blogService;
_blogPostService = blogPostService;
}
public IActionResult OnGet([FromQuery(Name = "pid")] Guid? postId = null,
@ -28,15 +28,15 @@ public class Index : PageModel
private IActionResult HandleNewRoute(Guid postId)
{
return _blogService.TryGetBlogPost(postId, out BlogPost? post) ? RedirectToPost(post) : NotFound();
return _blogPostService.TryGetPost(postId, out IBlogPost? post) ? RedirectToPost(post) : NotFound();
}
private IActionResult HandleWordPressRoute(int wpPostId)
{
return _blogService.TryGetWordPressBlogPost(wpPostId, out BlogPost? post) ? RedirectToPost(post) : NotFound();
return _blogPostService.TryGetPost(wpPostId, out IBlogPost? post) ? RedirectToPost(post) : NotFound();
}
private IActionResult RedirectToPost(BlogPost post)
private IActionResult RedirectToPost(IBlogPost post)
{
var route = new
{

View File

@ -0,0 +1,2 @@
@page "/{year:int}/{month:int}/{day:int}/{slug}/raw"
@model RawArticle

View File

@ -1,10 +1,10 @@
using Cysharp.Text;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using OliverBooth.Data.Blog;
using OliverBooth.Services;
using OliverBooth.Blog.Data;
using OliverBooth.Blog.Services;
namespace OliverBooth.Areas.Blog.Pages;
namespace OliverBooth.Blog.Pages;
/// <summary>
/// Represents the page model for the <c>RawArticle</c> page.
@ -12,23 +12,21 @@ namespace OliverBooth.Areas.Blog.Pages;
[Area("blog")]
public class RawArticle : PageModel
{
private readonly BlogService _blogService;
private readonly BlogUserService _blogUserService;
private readonly IBlogPostService _blogPostService;
/// <summary>
/// Initializes a new instance of the <see cref="RawArticle" /> class.
/// </summary>
/// <param name="blogService">The <see cref="BlogService" />.</param>
/// <param name="blogUserService">The <see cref="BlogUserService" />.</param>
public RawArticle(BlogService blogService, BlogUserService blogUserService)
/// <param name="blogPostService">The <see cref="IBlogPostService" />.</param>
public RawArticle(IBlogPostService blogPostService)
{
_blogService = blogService;
_blogUserService = blogUserService;
_blogPostService = blogPostService;
}
public IActionResult OnGet(int year, int month, int day, string slug)
{
if (!_blogService.TryGetBlogPost(year, month, day, slug, out BlogPost? post))
var date = new DateOnly(year, month, day);
if (!_blogPostService.TryGetPost(date, slug, out IBlogPost? post))
{
return NotFound();
}
@ -37,8 +35,7 @@ public class RawArticle : PageModel
using Utf8ValueStringBuilder builder = ZString.CreateUtf8StringBuilder();
builder.AppendLine("# " + post.Title);
if (_blogUserService.TryGetUser(post.AuthorId, out User? author))
builder.AppendLine($"Author: {author.DisplayName}");
builder.AppendLine($"Author: {post.Author.DisplayName}");
builder.AppendLine($"Published: {post.Published:R}");
if (post.Updated.HasValue)

View File

@ -0,0 +1,2 @@
@namespace OliverBooth.Blog.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

View File

@ -0,0 +1,26 @@
using OliverBooth.Blog.Data;
using OliverBooth.Blog.Middleware;
using OliverBooth.Blog.Services;
using OliverBooth.Common.Extensions;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddTomlFile("data/config.toml", true, true);
builder.Services.AddDbContextFactory<BlogContext>();
builder.Services.AddSingleton<IBlogPostService, BlogPostService>();
builder.Services.AddSingleton<IUserService, UserService>();
builder.Services.AddRazorPages().AddRazorRuntimeCompilation();
builder.Services.AddControllersWithViews();
builder.WebHost.AddCertificateFromEnvironment(2846, 5050);
WebApplication app = builder.Build();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.MapRssFeed("/feed");
app.MapRazorPages();
app.MapControllers();
app.Run();

View File

@ -0,0 +1,124 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.EntityFrameworkCore;
using OliverBooth.Blog.Data;
namespace OliverBooth.Blog.Services;
/// <summary>
/// Represents an implementation of <see cref="IBlogPostService" />.
/// </summary>
internal sealed class BlogPostService : IBlogPostService
{
private readonly IDbContextFactory<BlogContext> _dbContextFactory;
private readonly IUserService _userService;
/// <summary>
/// Initializes a new instance of the <see cref="BlogPostService" /> class.
/// </summary>
/// <param name="dbContextFactory">
/// The <see cref="IDbContextFactory{TContext}" /> used to create a <see cref="BlogContext" />.
/// </param>
/// <param name="userService">The <see cref="IUserService" />.</param>
public BlogPostService(IDbContextFactory<BlogContext> dbContextFactory, IUserService userService)
{
_dbContextFactory = dbContextFactory;
_userService = userService;
}
public IReadOnlyList<IBlogPost> GetAllBlogPosts(int limit = -1)
{
using BlogContext context = _dbContextFactory.CreateDbContext();
return context.BlogPosts
.OrderByDescending(post => post.Published)
.Take(limit)
.AsEnumerable().Select(CacheAuthor).ToArray();
}
/// <inheritdoc />
public IReadOnlyList<IBlogPost> GetBlogPosts(int page, int pageSize = 10)
{
using BlogContext context = _dbContextFactory.CreateDbContext();
return context.BlogPosts
.OrderByDescending(post => post.Published)
.Skip(page * pageSize)
.Take(pageSize)
.AsEnumerable().Select(CacheAuthor).ToArray();
}
/// <inheritdoc />
public string RenderExcerpt(IBlogPost post, out bool wasTrimmed)
{
// TODO implement excerpt trimming
wasTrimmed = false;
return post.Body;
}
/// <inheritdoc />
public string RenderPost(IBlogPost post)
{
// TODO render markdown
return post.Body;
}
/// <inheritdoc />
public bool TryGetPost(Guid id, [NotNullWhen(true)] out IBlogPost? post)
{
using BlogContext context = _dbContextFactory.CreateDbContext();
post = context.BlogPosts.Find(id);
if (post is null)
{
return false;
}
CacheAuthor((BlogPost)post);
return true;
}
/// <inheritdoc />
public bool TryGetPost(int id, [NotNullWhen(true)] out IBlogPost? post)
{
using BlogContext context = _dbContextFactory.CreateDbContext();
post = context.BlogPosts.FirstOrDefault(p => p.WordPressId == id);
if (post is null)
{
return false;
}
CacheAuthor((BlogPost)post);
return true;
}
/// <inheritdoc />
public bool TryGetPost(DateOnly publishDate, string slug, [NotNullWhen(true)] out IBlogPost? post)
{
using BlogContext context = _dbContextFactory.CreateDbContext();
post = context.BlogPosts.FirstOrDefault(post => post.Published.Year == publishDate.Year &&
post.Published.Month == publishDate.Month &&
post.Published.Day == publishDate.Day &&
post.Slug == slug);
if (post is null)
{
return false;
}
CacheAuthor((BlogPost)post);
return true;
}
private BlogPost CacheAuthor(BlogPost post)
{
// ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
if (post.Author is not null)
{
return post;
}
if (_userService.TryGetUser(post.AuthorId, out IUser? user) && user is IBlogAuthor author)
{
post.Author = author;
}
return post;
}
}

View File

@ -0,0 +1,91 @@
using System.Diagnostics.CodeAnalysis;
using OliverBooth.Blog.Data;
namespace OliverBooth.Blog.Services;
/// <summary>
/// Represents a service for managing blog posts.
/// </summary>
public interface IBlogPostService
{
/// <summary>
/// Returns a collection of all blog posts.
/// </summary>
/// <param name="limit">The maximum number of posts to return. A value of -1 returns all posts.</param>
/// <returns>A collection of all blog posts.</returns>
/// <remarks>
/// This method may slow down execution if there are a large number of blog posts being requested. It is
/// recommended to use <see cref="GetBlogPosts" /> instead.
/// </remarks>
IReadOnlyList<IBlogPost> GetAllBlogPosts(int limit = -1);
/// <summary>
/// Returns a collection of blog posts from the specified page, optionally limiting the number of posts
/// returned per page.
/// </summary>
/// <param name="page">The zero-based index of the page to return.</param>
/// <param name="pageSize">The maximum number of posts to return per page.</param>
/// <returns>A collection of blog posts.</returns>
IReadOnlyList<IBlogPost> GetBlogPosts(int page, int pageSize = 10);
/// <summary>
/// Renders the excerpt of the specified blog post.
/// </summary>
/// <param name="post">The blog post whose excerpt to render.</param>
/// <param name="wasTrimmed">
/// When this method returns, contains <see langword="true" /> if the excerpt was trimmed; otherwise,
/// <see langword="false" />.
/// </param>
/// <returns>The rendered HTML of the blog post's excerpt.</returns>
string RenderExcerpt(IBlogPost post, out bool wasTrimmed);
/// <summary>
/// Renders the body of the specified blog post.
/// </summary>
/// <param name="post">The blog post to render.</param>
/// <returns>The rendered HTML of the blog post.</returns>
string RenderPost(IBlogPost post);
/// <summary>
/// Attempts to find a blog post with the specified ID.
/// </summary>
/// <param name="id">The ID of the blog post to find.</param>
/// <param name="post">
/// When this method returns, contains the blog post with the specified ID, if the blog post is found;
/// otherwise, <see langword="null" />.
/// </param>
/// <returns>
/// <see langword="true" /> if a blog post with the specified ID is found; otherwise, <see langword="false" />.
/// </returns>
bool TryGetPost(Guid id, [NotNullWhen(true)] out IBlogPost? post);
/// <summary>
/// Attempts to find a blog post with the specified WordPress ID.
/// </summary>
/// <param name="id">The ID of the blog post to find.</param>
/// <param name="post">
/// When this method returns, contains the blog post with the specified WordPress ID, if the blog post is found;
/// otherwise, <see langword="null" />.
/// </param>
/// <returns>
/// <see langword="true" /> if a blog post with the specified WordPress ID is found; otherwise,
/// <see langword="false" />.
/// </returns>
bool TryGetPost(int id, [NotNullWhen(true)] out IBlogPost? post);
/// <summary>
/// Attempts to find a blog post with the specified publish date and URL slug.
/// </summary>
/// <param name="publishDate">The date the blog post was published.</param>
/// <param name="slug">The URL slug of the blog post to find.</param>
/// <param name="post">
/// When this method returns, contains the blog post with the specified publish date and URL slug, if the blog
/// post is found; otherwise, <see langword="null" />.
/// </param>
/// <returns>
/// <see langword="true" /> if a blog post with the specified publish date and URL slug is found; otherwise,
/// <see langword="false" />.
/// </returns>
/// <exception cref="ArgumentNullException"><paramref name="slug" /> is <see langword="null" />.</exception>
bool TryGetPost(DateOnly publishDate, string slug, [NotNullWhen(true)] out IBlogPost? post);
}

View File

@ -0,0 +1,23 @@
using System.Diagnostics.CodeAnalysis;
using OliverBooth.Blog.Data;
namespace OliverBooth.Blog.Services;
/// <summary>
/// Represents a service for managing users.
/// </summary>
public interface IUserService
{
/// <summary>
/// Attempts to find a user with the specified ID.
/// </summary>
/// <param name="id">The ID of the user to find.</param>
/// <param name="user">
/// When this method returns, contains the user with the specified ID, if the user is found; otherwise,
/// <see langword="null" />.
/// </param>
/// <returns>
/// <see langword="true" /> if a user with the specified ID is found; otherwise, <see langword="false" />.
/// </returns>
bool TryGetUser(Guid id, [NotNullWhen(true)] out IUser? user);
}

View File

@ -0,0 +1,32 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.EntityFrameworkCore;
using OliverBooth.Blog.Data;
namespace OliverBooth.Blog.Services;
/// <summary>
/// Represents an implementation of <see cref="IUserService" />.
/// </summary>
internal sealed class UserService : IUserService
{
private readonly IDbContextFactory<BlogContext> _dbContextFactory;
/// <summary>
/// Initializes a new instance of the <see cref="UserService" /> class.
/// </summary>
/// <param name="dbContextFactory">
/// The <see cref="IDbContextFactory{TContext}" /> used to create a <see cref="BlogContext" />.
/// </param>
public UserService(IDbContextFactory<BlogContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
/// <inheritdoc />
public bool TryGetUser(Guid id, [NotNullWhen(true)] out IUser? user)
{
using BlogContext context = _dbContextFactory.CreateDbContext();
user = context.Users.Find(id);
return user is not null;
}
}

View File

@ -4,23 +4,35 @@ using Microsoft.AspNetCore.Hosting;
namespace OliverBooth.Common.Extensions;
/// <summary>
/// Extension methods for <see cref="IWebHostBuilder" />.
/// </summary>
public static class WebHostBuilderExtensions
{
public static IWebHostBuilder AddCertificateFromEnvironment(this IWebHostBuilder builder)
/// <summary>
/// Adds a certificate to the <see cref="IWebHostBuilder" /> by reading the paths from environment variables.
/// </summary>
/// <param name="builder">The <see cref="IWebHostBuilder" />.</param>
/// <param name="httpsPort">The HTTPS port.</param>
/// <param name="httpPort">The HTTP port.</param>
/// <returns>The <see cref="IWebHostBuilder" />.</returns>
public static IWebHostBuilder AddCertificateFromEnvironment(this IWebHostBuilder builder,
int httpsPort = 443,
int httpPort = 80)
{
return builder.UseKestrel(options =>
{
string certPath = Environment.GetEnvironmentVariable("SSL_CERT_PATH")!;
if (!File.Exists(certPath))
{
options.ListenAnyIP(5049);
options.ListenAnyIP(httpPort);
return;
}
string? keyPath = Environment.GetEnvironmentVariable("SSL_KEY_PATH");
if (string.IsNullOrWhiteSpace(keyPath) || !File.Exists(keyPath)) keyPath = null;
options.ListenAnyIP(2845, options =>
options.ListenAnyIP(httpsPort, options =>
{
X509Certificate2 cert = CreateCertFromPemFile(certPath, keyPath);
options.UseHttps(cert);

View File

@ -16,6 +16,7 @@
<PackageReference Include="Alexinea.Extensions.Configuration.Toml" Version="7.0.0"/>
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="7.0.9"/>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="7.0.9"/>
<PackageReference Include="Markdig" Version="0.32.0"/>
</ItemGroup>
</Project>

View File

@ -1,2 +0,0 @@
@page "/blog/{year:int}/{month:int}/{day:int}/{slug}/raw"
@model OliverBooth.Areas.Blog.Pages.RawArticle

View File

@ -1,2 +0,0 @@
@namespace OliverBooth.Areas.Blog.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

View File

@ -1,186 +0,0 @@
using SmartFormat;
namespace OliverBooth.Data.Blog;
/// <summary>
/// Represents a blog post.
/// </summary>
public sealed class BlogPost : IEquatable<BlogPost>
{
/// <summary>
/// Gets the ID of the author.
/// </summary>
/// <value>The author ID.</value>
public Guid AuthorId { get; private set; }
/// <summary>
/// Gets or sets the body of the blog post.
/// </summary>
/// <value>The body.</value>
public string Body { get; set; } = string.Empty;
/// <summary>
/// Gets or sets a value indicating whether comments are enabled for the blog post.
/// </summary>
/// <value><see langword="true" /> if comments are enabled; otherwise, <see langword="false" />.</value>
public bool EnableComments { get; set; } = true;
/// <summary>
/// Gets or sets the base URL of the Disqus comments for the blog post.
/// </summary>
/// <value>The Disqus base URL.</value>
public string? DisqusDomain { get; set; }
/// <summary>
/// Gets or sets the identifier of the Disqus comments for the blog post.
/// </summary>
/// <value>The Disqus identifier.</value>
public string? DisqusIdentifier { get; set; }
/// <summary>
/// Gets or sets the URL path of the Disqus comments for the blog post.
/// </summary>
/// <value>The Disqus URL path.</value>
public string? DisqusPath { get; set; }
/// <summary>
/// Gets the ID of the blog post.
/// </summary>
/// <value>The ID.</value>
public Guid Id { get; private set; }
/// <summary>
/// Gets or sets a value indicating whether the blog post is a redirect.
/// </summary>
/// <value><see langword="true" /> if the blog post is a redirect; otherwise, <see langword="false" />.</value>
public bool IsRedirect { get; set; }
/// <summary>
/// Gets or sets the date and time at which the blog post was published.
/// </summary>
/// <value>The publish timestamp.</value>
public DateTimeOffset Published { get; set; }
/// <summary>
/// Gets or sets the redirect URL of the blog post.
/// </summary>
/// <value>The redirect URL.</value>
public string? RedirectUrl { get; set; }
/// <summary>
/// Gets or sets the URL slug of the blog post.
/// </summary>
/// <value>The URL slug.</value>
public string Slug { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the title of the blog post.
/// </summary>
/// <value>The title.</value>
public string Title { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the date and time at which the blog post was updated.
/// </summary>
/// <value>The update timestamp.</value>
public DateTimeOffset? Updated { get; set; }
/// <summary>
/// Gets or sets the legacy WordPress ID of the blog post.
/// </summary>
/// <value>The legacy WordPress ID.</value>
public int? WordPressId { get; set; }
/// <summary>
/// Returns a value indicating whether two instances of <see cref="BlogPost" /> are equal.
/// </summary>
/// <param name="left">The first instance of <see cref="BlogPost" /> to compare.</param>
/// <param name="right">The second instance of <see cref="BlogPost" /> to compare.</param>
/// <returns>
/// <see langword="true" /> if <paramref name="left" /> and <paramref name="right" /> are equal; otherwise,
/// <see langword="false" />.
/// </returns>
public static bool operator ==(BlogPost? left, BlogPost? right) => Equals(left, right);
/// <summary>
/// Returns a value indicating whether two instances of <see cref="BlogPost" /> are not equal.
/// </summary>
/// <param name="left">The first instance of <see cref="BlogPost" /> to compare.</param>
/// <param name="right">The second instance of <see cref="BlogPost" /> to compare.</param>
/// <returns>
/// <see langword="true" /> if <paramref name="left" /> and <paramref name="right" /> are not equal; otherwise,
/// <see langword="false" />.
/// </returns>
public static bool operator !=(BlogPost? left, BlogPost? right) => !(left == right);
/// <summary>
/// Returns a value indicating whether this instance of <see cref="BlogPost" /> is equal to another instance.
/// </summary>
/// <param name="other">An instance to compare with this instance.</param>
/// <returns>
/// <see langword="true" /> if <paramref name="other" /> is equal to this instance; otherwise,
/// <see langword="false" />.
/// </returns>
public bool Equals(BlogPost? other)
{
if (ReferenceEquals(null, other)) return false;
if (ReferenceEquals(this, other)) return true;
return Id == other.Id;
}
/// <summary>
/// Returns a value indicating whether this instance is equal to a specified object.
/// </summary>
/// <param name="obj">An object to compare with this instance.</param>
/// <returns>
/// <see langword="true" /> if <paramref name="obj" /> is an instance of <see cref="BlogPost" /> and equals the
/// value of this instance; otherwise, <see langword="false" />.
/// </returns>
public override bool Equals(object? obj)
{
return ReferenceEquals(this, obj) || obj is BlogPost other && Equals(other);
}
/// <summary>
/// Gets the Disqus identifier for the blog post.
/// </summary>
/// <returns>The Disqus identifier.</returns>
public string GetDisqusIdentifier()
{
return string.IsNullOrWhiteSpace(DisqusIdentifier) ? $"post-{Id}" : Smart.Format(DisqusIdentifier, this);
}
/// <summary>
/// Gets the Disqus domain for the blog post.
/// </summary>
/// <returns>The Disqus domain.</returns>
public string GetDisqusDomain()
{
return string.IsNullOrWhiteSpace(DisqusDomain)
? "https://oliverbooth.dev/blog"
: Smart.Format(DisqusDomain, this);
}
/// <summary>
/// Gets the Disqus URL for the blog post.
/// </summary>
/// <returns>The Disqus URL.</returns>
public string GetDisqusUrl()
{
string path = string.IsNullOrWhiteSpace(DisqusPath)
? $"{Published:yyyy/MM/dd}/{Slug}/"
: Smart.Format(DisqusPath, this);
return $"{GetDisqusDomain()}/{path}";
}
/// <summary>
/// Gets the hash code for this instance.
/// </summary>
/// <returns>The hash code.</returns>
public override int GetHashCode()
{
// ReSharper disable once NonReadonlyMemberInGetHashCode
return Id.GetHashCode();
}
}

View File

@ -15,7 +15,6 @@
<ItemGroup>
<PackageReference Include="Humanizer.Core" Version="2.14.1"/>
<PackageReference Include="Markdig" Version="0.32.0"/>
<PackageReference Include="NLog.Extensions.Logging" Version="5.3.3"/>
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="7.0.0"/>
<PackageReference Include="SmartFormat.NET" Version="3.2.2"/>

View File

@ -5,7 +5,6 @@ using OliverBooth.Common.Extensions;
using OliverBooth.Data;
using OliverBooth.Markdown.Template;
using OliverBooth.Markdown.Timestamp;
using OliverBooth.Middleware;
using OliverBooth.Services;
using X10D.Hosting.DependencyInjection;
@ -17,8 +16,6 @@ builder.Logging.AddNLog();
builder.Services.AddHostedSingleton<LoggingService>();
builder.Services.AddSingleton<ConfigurationService>();
builder.Services.AddSingleton<TemplateService>();
builder.Services.AddHostedSingleton<BlogSessionService>();
builder.Services.AddSingleton<BlogUserService>();
builder.Services.AddSingleton(provider => new MarkdownPipelineBuilder()
.Use<TimestampExtension>()
@ -29,9 +26,7 @@ builder.Services.AddSingleton(provider => new MarkdownPipelineBuilder()
.UseSmartyPants()
.Build());
builder.Services.AddDbContextFactory<BlogContext>();
builder.Services.AddDbContextFactory<WebContext>();
builder.Services.AddSingleton<BlogService>();
builder.Services.AddRazorPages().AddRazorRuntimeCompilation();
builder.Services.AddControllersWithViews();
builder.Services.AddCors(options => options.AddPolicy("BlogApi", policy => (builder.Environment.IsDevelopment()
@ -41,7 +36,7 @@ builder.Services.AddCors(options => options.AddPolicy("BlogApi", policy => (buil
.AllowAnyHeader()));
builder.Services.AddRouting(options => options.LowercaseUrls = true);
builder.WebHost.AddCertificateFromEnvironment();
builder.WebHost.AddCertificateFromEnvironment(2845, 5049);
WebApplication app = builder.Build();
@ -60,7 +55,6 @@ app.UseCors("BlogApi");
app.MapControllers();
app.MapRazorPages();
app.MapRssFeed("/blog/feed");
app.Run();

View File

@ -1,135 +0,0 @@
using System.Diagnostics.CodeAnalysis;
using Humanizer;
using Markdig;
using Microsoft.EntityFrameworkCore;
using OliverBooth.Data;
using OliverBooth.Data.Blog;
namespace OliverBooth.Services;
public sealed class BlogService
{
private readonly IDbContextFactory<BlogContext> _dbContextFactory;
private readonly MarkdownPipeline _markdownPipeline;
/// <summary>
/// Initializes a new instance of the <see cref="BlogService" /> class.
/// </summary>
/// <param name="dbContextFactory">The <see cref="IDbContextFactory{TContext}" />.</param>
/// <param name="markdownPipeline">The <see cref="MarkdownPipeline" />.</param>
public BlogService(IDbContextFactory<BlogContext> dbContextFactory, MarkdownPipeline markdownPipeline)
{
_dbContextFactory = dbContextFactory;
_markdownPipeline = markdownPipeline;
}
/// <summary>
/// Gets a read-only view of all blog posts.
/// </summary>
/// <returns>A read-only view of all blog posts.</returns>
public IReadOnlyCollection<BlogPost> AllPosts
{
get
{
using BlogContext context = _dbContextFactory.CreateDbContext();
return context.BlogPosts.OrderByDescending(p => p.Published).ToArray();
}
}
/// <summary>
/// Gets the processed content of a blog post.
/// </summary>
/// <param name="post">The blog post.</param>
/// <returns>The processed content of the blog post.</returns>
public string GetContent(BlogPost post)
{
return RenderContent(post.Body);
}
/// <summary>
/// Gets the processed excerpt of a blog post.
/// </summary>
/// <param name="post">The blog post.</param>
/// <param name="trimmed">
/// When this method returns, contains <see langword="true" /> if the content was trimmed; otherwise,
/// <see langword="false" />.
/// </param>
/// <returns>The processed excerpt of the blog post.</returns>
public string GetExcerpt(BlogPost post, out bool trimmed)
{
ReadOnlySpan<char> span = post.Body.AsSpan();
int moreIndex = span.IndexOf("<!--more-->", StringComparison.Ordinal);
trimmed = moreIndex != -1 || span.Length > 256;
string result = moreIndex != -1 ? span[..moreIndex].Trim().ToString() : post.Body.Truncate(256);
return RenderContent(result).Trim();
}
/// <summary>
/// Attempts to find a blog post by its publication date and slug.
/// </summary>
/// <param name="year">The year the post was published.</param>
/// <param name="month">The month the post was published.</param>
/// <param name="day">The day the post was published.</param>
/// <param name="slug">The slug of the post.</param>
/// <param name="post">
/// When this method returns, contains the <see cref="BlogPost" /> associated with the specified publication
/// date and slug, if the post is found; otherwise, <see langword="null" />.
/// </param>
/// <returns><see langword="true" /> if the post is found; otherwise, <see langword="false" />.</returns>
/// <exception cref="ArgumentNullException"><paramref name="slug" /> is <see langword="null" />.</exception>
public bool TryGetBlogPost(int year, int month, int day, string slug, [NotNullWhen(true)] out BlogPost? post)
{
if (slug is null) throw new ArgumentNullException(nameof(slug));
using BlogContext context = _dbContextFactory.CreateDbContext();
post = context.BlogPosts.FirstOrDefault(p =>
p.Published.Year == year && p.Published.Month == month && p.Published.Day == day &&
p.Slug == slug);
return post is not null;
}
/// <summary>
/// Attempts to find a blog post by new ID.
/// </summary>
/// <param name="postId">The new ID of the post.</param>
/// <param name="post">
/// When this method returns, contains the <see cref="BlogPost" /> associated with ID, if the post is found;
/// otherwise, <see langword="null" />.
/// </param>
/// <returns><see langword="true" /> if the post is found; otherwise, <see langword="false" />.</returns>
public bool TryGetBlogPost(Guid postId, [NotNullWhen(true)] out BlogPost? post)
{
using BlogContext context = _dbContextFactory.CreateDbContext();
post = context.BlogPosts.FirstOrDefault(p => p.Id == postId);
return post is not null;
}
/// <summary>
/// Attempts to find a blog post by its legacy WordPress ID.
/// </summary>
/// <param name="postId">The WordPress ID of the post.</param>
/// <param name="post">
/// When this method returns, contains the <see cref="BlogPost" /> associated with ID, if the post is found;
/// otherwise, <see langword="null" />.
/// </param>
/// <returns><see langword="true" /> if the post is found; otherwise, <see langword="false" />.</returns>
public bool TryGetWordPressBlogPost(int postId, [NotNullWhen(true)] out BlogPost? post)
{
using BlogContext context = _dbContextFactory.CreateDbContext();
post = context.BlogPosts.FirstOrDefault(p => p.WordPressId == postId);
return post is not null;
}
private string RenderContent(string content)
{
content = content.Replace("<!--more-->", string.Empty);
while (content.Contains("\n\n"))
{
content = content.Replace("\n\n", "\n");
}
return Markdig.Markdown.ToHtml(content.Trim(), _markdownPipeline);
}
}

View File

@ -1,83 +0,0 @@
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);
}
}