Compare commits

...

21 Commits

Author SHA1 Message Date
f60b9c754a
fix: remove ref to removed service 2023-08-13 00:53:22 +01:00
692d688dc3
refactor: remove unused config service 2023-08-12 22:38:28 +01:00
58799594ae
refactor: switch to serilog 2023-08-12 21:06:48 +01:00
ad59c3190a
fix: read BLOG_URL env var 2023-08-12 20:52:03 +01:00
43f0b38fd2
chore: add blog site to docker-compose 2023-08-12 20:45:09 +01:00
aca79b0e69
chore: use correct project name in Dockerfile 2023-08-12 20:44:24 +01:00
617f58afad
chore: add common files to sln 2023-08-12 20:43:06 +01:00
1432c8e0f1
fix: amend 4c86a43a84 2023-08-12 20:41:14 +01:00
419aae741d
fix: use shared assets in root site 2023-08-12 20:40:46 +01:00
4c86a43a84
chore: remove redundant gitignore 2023-08-12 20:40:31 +01:00
43c3670a40
fix: no wait, it should be 2846
Seriously oliver, config file.
2023-08-12 20:37:54 +01:00
904ea689a6
fix: add missing _View* files 2023-08-12 20:34:35 +01:00
5ecd915d72
fix: use the correct port. this should really be in a config file 2023-08-12 20:34:00 +01:00
a6a0adc419
chore: build assets to Common wwwroot 2023-08-12 20:33:00 +01:00
f49b8aee9c
chore: add blog project to sln 2023-08-12 20:32:39 +01:00
a55c657d91
fix: add missing refs for blog project 2023-08-12 20:32:27 +01:00
86bbf803b5
feat: share wwwroot assets 2023-08-12 20:30:10 +01:00
0b7218b11a
chore: bump AspNetCore refs to 7.0.10 2023-08-12 20:14:20 +01:00
e8bc50bbdf
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.
2023-08-12 20:13:47 +01:00
b3fd6e9420
chore: add toml config to common lib 2023-08-12 19:04:18 +01:00
67231c86af
refactor: delegate ssl cert read to common lib 2023-08-12 18:35:57 +01:00
51 changed files with 998 additions and 865 deletions

View File

@ -5,16 +5,16 @@ EXPOSE 443
FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build
WORKDIR /src WORKDIR /src
COPY ["oliverbooth.dev/oliverbooth.dev.csproj", "oliverbooth.dev/"] COPY ["OliverBooth/OliverBooth.csproj", "OliverBooth/"]
RUN dotnet restore "oliverbooth.dev/oliverbooth.dev.csproj" RUN dotnet restore "oliverbooth.dev/oliverbooth.dev.csproj"
COPY . . COPY . .
WORKDIR "/src/oliverbooth.dev" WORKDIR "/src/OliverBooth"
RUN dotnet build "oliverbooth.dev.csproj" -c Release -o /app/build RUN dotnet build "OliverBooth.csproj" -c Release -o /app/build
FROM build AS publish FROM build AS publish
RUN dotnet publish "oliverbooth.dev.csproj" -c Release -o /app/publish /p:UseAppHost=false RUN dotnet publish "OliverBooth.csproj" -c Release -o /app/publish /p:UseAppHost=false
FROM base AS final FROM base AS final
WORKDIR /app WORKDIR /app
COPY --from=publish /app/publish . COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "oliverbooth.dev.dll"] ENTRYPOINT ["dotnet", "OliverBooth.dll"]

View File

@ -7,7 +7,7 @@ const terser = require('gulp-terser');
const webpack = require('webpack-stream'); const webpack = require('webpack-stream');
const srcDir = 'src'; const srcDir = 'src';
const destDir = 'OliverBooth/wwwroot'; const destDir = 'OliverBooth.Common/wwwroot';
function compileSCSS() { function compileSCSS() {
return gulp.src(`${srcDir}/scss/**/*.scss`) return gulp.src(`${srcDir}/scss/**/*.scss`)

View File

@ -1,58 +1,66 @@
using Humanizer; using Humanizer;
using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using OliverBooth.Data.Blog; using OliverBooth.Blog.Data;
using OliverBooth.Services; using OliverBooth.Blog.Services;
namespace OliverBooth.Controllers; namespace OliverBooth.Blog.Controllers;
/// <summary> /// <summary>
/// Represents a controller for the blog API. /// Represents a controller for the blog API.
/// </summary> /// </summary>
[ApiController] [ApiController]
[Route("api/blog")] [Route("api")]
[Produces("application/json")] [Produces("application/json")]
[EnableCors("BlogApi")] [EnableCors("OliverBooth")]
public sealed class BlogApiController : ControllerBase public sealed class BlogApiController : ControllerBase
{ {
private readonly BlogService _blogService; private readonly IBlogPostService _blogPostService;
private readonly BlogUserService _blogUserService; private readonly IUserService _userService;
/// <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="blogPostService">The <see cref="IBlogPostService" />.</param>
/// <param name="blogUserService">The <see cref="BlogUserService" />.</param> /// <param name="userService">The <see cref="IUserService" />.</param>
public BlogApiController(BlogService blogService, BlogUserService blogUserService) public BlogApiController(IBlogPostService blogPostService, IUserService userService)
{ {
_blogService = blogService; _blogPostService = blogPostService;
_blogUserService = blogUserService; _userService = userService;
} }
[Route("count")] [Route("count")]
public IActionResult Count() public IActionResult Count()
{ {
if (!ValidateReferer()) return NotFound(); if (!ValidateReferer()) return NotFound();
return Ok(new { count = _blogService.AllPosts.Count }); return Ok(new { count = _blogPostService.GetAllBlogPosts().Count });
} }
[HttpGet("all/{skip:int?}/{take:int?}")] [HttpGet("all/{skip:int?}/{take:int?}")]
public IActionResult GetAllBlogPosts(int skip = 0, int take = -1) public IActionResult GetAllBlogPosts(int skip = 0, int take = -1)
{ {
if (!ValidateReferer()) return NotFound(); 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, id = post.Id,
commentsEnabled = post.EnableComments, commentsEnabled = post.EnableComments,
identifier = post.GetDisqusIdentifier(), identifier = post.GetDisqusIdentifier(),
author = post.AuthorId, author = post.Author.Id,
title = post.Title, title = post.Title,
published = post.Published.ToUnixTimeSeconds(), published = post.Published.ToUnixTimeSeconds(),
formattedDate = post.Published.ToString("dddd, d MMMM yyyy HH:mm"), formattedDate = post.Published.ToString("dddd, d MMMM yyyy HH:mm"),
updated = post.Updated?.ToUnixTimeSeconds(), updated = post.Updated?.ToUnixTimeSeconds(),
humanizedTimestamp = post.Updated?.Humanize() ?? post.Published.Humanize(), humanizedTimestamp = post.Updated?.Humanize() ?? post.Published.Humanize(),
excerpt = _blogService.GetExcerpt(post, out bool trimmed), excerpt = _blogPostService.RenderExcerpt(post, out bool trimmed),
trimmed, trimmed,
url = Url.Page("/Article", url = Url.Page("/Article",
new new
@ -70,19 +78,19 @@ public sealed class BlogApiController : ControllerBase
public IActionResult GetAuthor(Guid id) public IActionResult GetAuthor(Guid id)
{ {
if (!ValidateReferer()) return NotFound(); 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 return Ok(new
{ {
id = author.Id, id = author.Id,
name = author.DisplayName, name = author.DisplayName,
avatarHash = author.AvatarHash, avatarUrl = author.AvatarUrl,
}); });
} }
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")!);
} }
} }

View File

@ -1,13 +1,12 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using OliverBooth.Data.Blog; using OliverBooth.Blog.Data.Configuration;
using OliverBooth.Data.Blog.Configuration;
namespace OliverBooth.Data; namespace OliverBooth.Blog.Data;
/// <summary> /// <summary>
/// Represents a session with the blog database. /// Represents a session with the blog database.
/// </summary> /// </summary>
public sealed class BlogContext : DbContext internal sealed class BlogContext : DbContext
{ {
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
@ -21,16 +20,16 @@ public sealed class BlogContext : DbContext
} }
/// <summary> /// <summary>
/// Gets the set of blog posts. /// Gets the collection of blog posts in the database.
/// </summary> /// </summary>
/// <value>The set of blog posts.</value> /// <value>The collection of blog posts.</value>
public DbSet<BlogPost> BlogPosts { get; internal set; } = null!; public DbSet<BlogPost> BlogPosts { get; private set; } = null!;
/// <summary> /// <summary>
/// Gets the set of users. /// Gets the collection of users in the database.
/// </summary> /// </summary>
/// <value>The set of users.</value> /// <value>The collection of users.</value>
public DbSet<User> Users { get; internal set; } = null!; public DbSet<User> Users { get; private set; } = null!;
/// <inheritdoc /> /// <inheritdoc />
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) 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;
using Microsoft.EntityFrameworkCore.Metadata.Builders; 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> internal sealed class BlogPostConfiguration : IEntityTypeConfiguration<BlogPost>
{ {
/// <inheritdoc /> /// <inheritdoc />
@ -23,7 +21,7 @@ internal sealed class BlogPostConfiguration : IEntityTypeConfiguration<BlogPost>
builder.Property(e => e.Title).HasMaxLength(255).IsRequired(); builder.Property(e => e.Title).HasMaxLength(255).IsRequired();
builder.Property(e => e.Body).IsRequired(); builder.Property(e => e.Body).IsRequired();
builder.Property(e => e.IsRedirect).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.EnableComments).IsRequired();
builder.Property(e => e.DisqusDomain).IsRequired(false); builder.Property(e => e.DisqusDomain).IsRequired(false);
builder.Property(e => e.DisqusIdentifier).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 internal static class RssEndpointExtensions
{ {

View File

@ -1,35 +1,18 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Xml.Serialization; using System.Xml.Serialization;
using OliverBooth.Data.Blog; using OliverBooth.Blog.Data;
using OliverBooth.Blog.Services;
using OliverBooth.Data.Rss; using OliverBooth.Data.Rss;
using OliverBooth.Services;
namespace OliverBooth.Middleware; namespace OliverBooth.Blog.Middleware;
/// <summary>
/// Represents the RSS middleware.
/// </summary>
internal sealed class RssMiddleware internal sealed class RssMiddleware
{ {
private readonly BlogService _blogService; private readonly IBlogPostService _blogPostService;
private readonly BlogUserService _userService;
private readonly ConfigurationService _configurationService;
/// <summary> public RssMiddleware(RequestDelegate _, IBlogPostService blogPostService)
/// 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)
{ {
_blogService = blogService; _blogPostService = blogPostService;
_userService = userService;
_configurationService = configurationService;
} }
[SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "Middleware")] [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 baseUrl = $"https://{context.Request.Host}/blog";
var blogItems = new List<BlogItem>(); 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}"; var url = $"{baseUrl}/{post.Published:yyyy/MM/dd}/{post.Slug}";
string excerpt = _blogService.GetExcerpt(blogPost, out _); string excerpt = _blogPostService.RenderExcerpt(post, out _);
var description = $"{excerpt}<p><a href=\"{url}\">Read more...</a></p>"; var description = $"{excerpt}<p><a href=\"{url}\">Read more...</a></p>";
_userService.TryGetUser(blogPost.AuthorId, out User? author);
var item = new BlogItem var item = new BlogItem
{ {
Title = blogPost.Title, Title = post.Title,
Link = url, Link = url,
Comments = $"{url}#disqus_thread", Comments = $"{url}#disqus_thread",
Creator = author?.DisplayName ?? string.Empty, Creator = post.Author.DisplayName,
PubDate = blogPost.Published.ToString("R"), PubDate = post.Published.ToString("R"),
Guid = $"{baseUrl}?pid={blogPost.Id}", Guid = $"{baseUrl}?pid={post.Id}",
Description = description Description = description
}; };
blogItems.Add(item); blogItems.Add(item);
} }
string siteTitle = _configurationService.GetSiteConfiguration("SiteTitle") ?? string.Empty;
var rss = new BlogRoot var rss = new BlogRoot
{ {
Channel = new BlogChannel Channel = new BlogChannel
@ -73,7 +53,7 @@ internal sealed class RssMiddleware
Description = $"{baseUrl}/", Description = $"{baseUrl}/",
LastBuildDate = DateTimeOffset.UtcNow.ToString("R"), LastBuildDate = DateTimeOffset.UtcNow.ToString("R"),
Link = $"{baseUrl}/", Link = $"{baseUrl}/",
Title = siteTitle, Title = "Oliver Booth",
Generator = $"{baseUrl}/", Generator = $"{baseUrl}/",
Items = blogItems Items = blogItems
} }
@ -90,6 +70,5 @@ internal sealed class RssMiddleware
await using var writer = new StreamWriter(context.Response.BodyWriter.AsStream()); await using var writer = new StreamWriter(context.Response.BodyWriter.AsStream());
serializer.Serialize(writer, rss, xmlNamespaces); 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 Humanizer
@using OliverBooth.Data.Blog @using OliverBooth.Blog.Data
@using OliverBooth.Services @model Article
@model OliverBooth.Areas.Blog.Pages.Article
@inject BlogService BlogService
@if (Model.Post is not { } post) @if (Model.Post is not { } post)
{ {
@ -12,14 +10,14 @@
@{ @{
ViewData["Title"] = post.Title; ViewData["Title"] = post.Title;
User author = Model.Author; IBlogAuthor author = post.Author;
DateTimeOffset published = post.Published; DateTimeOffset published = post.Published;
} }
<nav style="--bs-breadcrumb-divider: '>';" aria-label="breadcrumb"> <nav style="--bs-breadcrumb-divider: '>';" aria-label="breadcrumb">
<ol class="breadcrumb"> <ol class="breadcrumb">
<li class="breadcrumb-item"> <li class="breadcrumb-item">
<a asp-area="blog" asp-page="/index">Blog</a> <a asp-page="/index">Blog</a>
</li> </li>
<li class="breadcrumb-item active" aria-current="page">@post.Title</li> <li class="breadcrumb-item active" aria-current="page">@post.Title</li>
</ol> </ol>
@ -27,7 +25,7 @@
<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.DisplayName"> <img class="blog-author-icon" src="@author.AvatarUrl" alt="@author.DisplayName">
@author.DisplayName &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")">
@ -49,9 +47,9 @@
} }
</p> </p>
<article> <article>
@Html.Raw(BlogService.GetContent(post)) @* @Html.Raw(BlogService.GetContent(post)) *@
@Html.Raw(post.Body)
</article> </article>
<hr> <hr>
@ -64,7 +62,7 @@
this.page.url = "@post.GetDisqusUrl()"; this.page.url = "@post.GetDisqusUrl()";
this.page.identifier = "@post.GetDisqusIdentifier()"; this.page.identifier = "@post.GetDisqusIdentifier()";
this.page.title = "@post.Title"; this.page.title = "@post.Title";
this.page.postId = "@(post.WordPressId?.ToString() ?? post.Id.ToString())"; this.page.postId = "@post.GetDisqusPostId()";
}; };
(function() { (function() {

View File

@ -1,9 +1,9 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
using OliverBooth.Data.Blog; using OliverBooth.Blog.Data;
using OliverBooth.Services; using OliverBooth.Blog.Services;
namespace OliverBooth.Areas.Blog.Pages; namespace OliverBooth.Blog.Pages;
/// <summary> /// <summary>
/// Represents the page model for the <c>Article</c> page. /// Represents the page model for the <c>Article</c> page.
@ -11,26 +11,18 @@ namespace OliverBooth.Areas.Blog.Pages;
[Area("blog")] [Area("blog")]
public class Article : PageModel public class Article : PageModel
{ {
private readonly BlogService _blogService; private readonly IBlogPostService _blogPostService;
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="blogPostService">The <see cref="IBlogPostService" />.</param>
/// <param name="blogUserService">The <see cref="BlogUserService" />.</param> public Article(IBlogPostService blogPostService)
public Article(BlogService blogService, BlogUserService blogUserService)
{ {
_blogService = blogService; _blogPostService = blogPostService;
_blogUserService = blogUserService;
} }
/// <summary> /*
/// Gets the author of the post.
/// </summary>
/// <value>The author of the post.</value>
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.
/// </summary> /// </summary>
@ -38,23 +30,29 @@ public class Article : PageModel
/// <see langword="true" /> if the post is a legacy WordPress post; otherwise, <see langword="false" />. /// <see langword="true" /> if the post is a legacy WordPress post; otherwise, <see langword="false" />.
/// </value> /// </value>
public bool IsWordPressLegacyPost => Post.WordPressId.HasValue; public bool IsWordPressLegacyPost => Post.WordPressId.HasValue;
*/
/// <summary> /// <summary>
/// Gets the requested blog post. /// Gets the requested blog post.
/// </summary> /// </summary>
/// <value>The requested blog post.</value> /// <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) 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; Response.StatusCode = 404;
return NotFound(); return NotFound();
} }
if (post.IsRedirect)
{
return Redirect(post.RedirectUrl!.ToString());
}
Post = post; Post = post;
Author = _blogUserService.TryGetUser(post.AuthorId, out User? author) ? author : null!;
return Page(); return Page();
} }
} }

View File

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

View File

@ -1,18 +1,18 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
using OliverBooth.Data.Blog; using OliverBooth.Blog.Data;
using OliverBooth.Services; using OliverBooth.Blog.Services;
namespace OliverBooth.Areas.Blog.Pages; namespace OliverBooth.Blog.Pages;
[Area("blog")] [Area("blog")]
public class Index : PageModel 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, public IActionResult OnGet([FromQuery(Name = "pid")] Guid? postId = null,
@ -28,15 +28,15 @@ public class Index : PageModel
private IActionResult HandleNewRoute(Guid postId) 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) 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 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 Cysharp.Text;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
using OliverBooth.Data.Blog; using OliverBooth.Blog.Data;
using OliverBooth.Services; using OliverBooth.Blog.Services;
namespace OliverBooth.Areas.Blog.Pages; namespace OliverBooth.Blog.Pages;
/// <summary> /// <summary>
/// Represents the page model for the <c>RawArticle</c> page. /// Represents the page model for the <c>RawArticle</c> page.
@ -12,23 +12,21 @@ namespace OliverBooth.Areas.Blog.Pages;
[Area("blog")] [Area("blog")]
public class RawArticle : PageModel public class RawArticle : PageModel
{ {
private readonly BlogService _blogService; private readonly IBlogPostService _blogPostService;
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="blogPostService">The <see cref="IBlogPostService" />.</param>
/// <param name="blogUserService">The <see cref="BlogUserService" />.</param> public RawArticle(IBlogPostService blogPostService)
public RawArticle(BlogService blogService, BlogUserService blogUserService)
{ {
_blogService = blogService; _blogPostService = blogPostService;
_blogUserService = blogUserService;
} }
public IActionResult OnGet(int year, int month, int day, string slug) 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(); return NotFound();
} }
@ -37,8 +35,7 @@ public class RawArticle : PageModel
using Utf8ValueStringBuilder builder = ZString.CreateUtf8StringBuilder(); using Utf8ValueStringBuilder builder = ZString.CreateUtf8StringBuilder();
builder.AppendLine("# " + post.Title); builder.AppendLine("# " + post.Title);
if (_blogUserService.TryGetUser(post.AuthorId, out User? author)) builder.AppendLine($"Author: {post.Author.DisplayName}");
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

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

View File

@ -0,0 +1,37 @@
using OliverBooth.Blog.Data;
using OliverBooth.Blog.Middleware;
using OliverBooth.Blog.Services;
using OliverBooth.Common;
using OliverBooth.Common.Extensions;
using Serilog;
using X10D.Hosting.DependencyInjection;
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.WriteTo.File("logs/latest.log", rollingInterval: RollingInterval.Day)
.CreateLogger();
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddTomlFile("data/config.toml", true, true);
builder.Logging.ClearProviders();
builder.Logging.AddSerilog();
builder.Services.ConfigureOptions<OliverBoothConfigureOptions>();
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

@ -0,0 +1,53 @@
using System.Runtime.InteropServices;
using System.Security.Cryptography.X509Certificates;
using Microsoft.AspNetCore.Hosting;
namespace OliverBooth.Common.Extensions;
/// <summary>
/// Extension methods for <see cref="IWebHostBuilder" />.
/// </summary>
public static class WebHostBuilderExtensions
{
/// <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(httpPort);
return;
}
string? keyPath = Environment.GetEnvironmentVariable("SSL_KEY_PATH");
if (string.IsNullOrWhiteSpace(keyPath) || !File.Exists(keyPath)) keyPath = null;
options.ListenAnyIP(httpsPort, options =>
{
X509Certificate2 cert = CreateCertFromPemFile(certPath, keyPath);
options.UseHttps(cert);
});
return;
static X509Certificate2 CreateCertFromPemFile(string certPath, string? keyPath)
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
return X509Certificate2.CreateFromPemFile(certPath, keyPath);
//workaround for windows issue https://github.com/dotnet/runtime/issues/23749#issuecomment-388231655
using var cert = X509Certificate2.CreateFromPemFile(certPath, keyPath);
return new X509Certificate2(cert.Export(X509ContentType.Pkcs12));
}
});
}
}

View File

@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AddRazorSupportForMvc>true</AddRazorSupportForMvc>
<GenerateEmbeddedFilesManifest>true</GenerateEmbeddedFilesManifest>
</PropertyGroup>
<ItemGroup>
<SupportedPlatform Include="browser"/>
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
<PackageReference Include="Alexinea.Extensions.Configuration.Toml" Version="7.0.0"/>
<PackageReference Include="Humanizer.Core" Version="2.14.1"/>
<PackageReference Include="Markdig" Version="0.32.0"/>
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="7.0.10"/>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="7.0.10"/>
<PackageReference Include="Microsoft.Extensions.FileProviders.Embedded" Version="7.0.10"/>
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="7.0.0"/>
<PackageReference Include="Serilog" Version="3.0.1"/>
<PackageReference Include="Serilog.AspNetCore" Version="7.0.0"/>
<PackageReference Include="Serilog.Extensions.Logging" Version="7.0.0"/>
<PackageReference Include="Serilog.Sinks.Console" Version="4.1.0"/>
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0"/>
<PackageReference Include="SmartFormat.NET" Version="3.2.2"/>
<PackageReference Include="X10D" Version="3.2.2"/>
<PackageReference Include="X10D.Hosting" Version="3.2.2"/>
<PackageReference Include="ZString" Version="2.5.0"/>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="wwwroot\**\*"/>
</ItemGroup>
</Project>

View File

@ -0,0 +1,41 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Options;
namespace OliverBooth.Common;
/// <summary>
/// Represents the middleware to configure static file options.
/// </summary>
public sealed class OliverBoothConfigureOptions : IPostConfigureOptions<StaticFileOptions>
{
private readonly IWebHostEnvironment _environment;
/// <summary>
/// Initializes a new instance of the <see cref="OliverBoothConfigureOptions" /> class.
/// </summary>
/// <param name="environment">The <see cref="IWebHostEnvironment" />.</param>
public OliverBoothConfigureOptions(IWebHostEnvironment environment)
{
_environment = environment;
}
/// <inheritdoc />
public void PostConfigure(string? name, StaticFileOptions options)
{
// ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract
options.ContentTypeProvider ??= new FileExtensionContentTypeProvider();
if (options.FileProvider == null && _environment.WebRootFileProvider == null)
{
throw new InvalidOperationException("Missing FileProvider.");
}
options.FileProvider ??= _environment.WebRootFileProvider;
var filesProvider = new ManifestEmbeddedFileProvider(GetType().Assembly, "wwwroot");
options.FileProvider = new CompositeFileProvider(options.FileProvider, filesProvider);
}
}

View File

@ -1,3 +1,7 @@
@{
string rootUrl = Environment.GetEnvironmentVariable("ROOT_URL") ?? "https://oliverbooth.dev";
string blogUrl = Environment.GetEnvironmentVariable("BLOG_URL") ?? "https://blog.oliverbooth.dev";
}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-bs-theme="dark"> <html lang="en" data-bs-theme="dark">
<head> <head>
@ -12,7 +16,7 @@
{ {
<title>Oliver Booth</title> <title>Oliver Booth</title>
} }
<link rel="shortcut icon" href="~/img/favicon.png"> <link rel="shortcut icon" href="/img/favicon.png" asp-append-version="true">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.1/css/bootstrap.min.css" integrity="sha512-Z/def5z5u2aR89OuzYcxmDJ0Bnd5V1cKqBEbvLOiUNWdg9PQeXVvXLI90SE4QOHGlfLqUnDNVAYyZi8UwUTmWQ==" crossorigin="anonymous" referrerpolicy="no-referrer"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.1/css/bootstrap.min.css" integrity="sha512-Z/def5z5u2aR89OuzYcxmDJ0Bnd5V1cKqBEbvLOiUNWdg9PQeXVvXLI90SE4QOHGlfLqUnDNVAYyZi8UwUTmWQ==" crossorigin="anonymous" referrerpolicy="no-referrer">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.8/katex.min.css" integrity="sha512-7nTa5CnxbzfQgjQrNmHXB7bxGTUVO/DcYX6rpgt06MkzM0rVXP3EYCv/Ojxg5H0dKbY7llbbYaqgfZjnGOAWGA==" crossorigin="anonymous" referrerpolicy="no-referrer"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.8/katex.min.css" integrity="sha512-7nTa5CnxbzfQgjQrNmHXB7bxGTUVO/DcYX6rpgt06MkzM0rVXP3EYCv/Ojxg5H0dKbY7llbbYaqgfZjnGOAWGA==" crossorigin="anonymous" referrerpolicy="no-referrer">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css" integrity="sha512-z3gLpd7yknf1YoNbCzqRKc4qyor8gaKU1qmn+CShxbuBusANI9QpRohGBreCFkKxLhei6S9CQXFEbbKuqLg0DA==" crossorigin="anonymous" referrerpolicy="no-referrer"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css" integrity="sha512-z3gLpd7yknf1YoNbCzqRKc4qyor8gaKU1qmn+CShxbuBusANI9QpRohGBreCFkKxLhei6S9CQXFEbbKuqLg0DA==" crossorigin="anonymous" referrerpolicy="no-referrer">
@ -21,9 +25,9 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@100;400;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@100;400;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@200;400;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@200;400;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="~/css/prism.min.css" asp-append-version="true"/> <link rel="stylesheet" href="~/css/prism.min.css" asp-append-version="true">
<link rel="stylesheet" href="~/css/prism.vs.min.css" asp-append-version="true"> <link rel="stylesheet" href="~/css/prism.vs.min.css" asp-append-version="true">
<link rel="stylesheet" href="~/css/app.min.css" asp-append-version="true"/> <link rel="stylesheet" href="~/css/app.min.css" asp-append-version="true">
</head> </head>
<body> <body>
<header class="container" style="margin-top: 20px;"> <header class="container" style="margin-top: 20px;">
@ -36,22 +40,22 @@
<nav> <nav>
<ul class="site-nav"> <ul class="site-nav">
<li> <li>
<a asp-area="" asp-page="/index">About</a> <a href="@rootUrl">About</a>
</li> </li>
<li> <li>
<a asp-area="blog" asp-page="/index">Blog</a> <a href="@blogUrl">Blog</a>
</li> </li>
<li> <li>
<a asp-area="" asp-page="/tutorials/index">Tutorials</a> <a href="@rootUrl/tutorials">Tutorials</a>
</li> </li>
<li> <li>
<a asp-area="" asp-page="/projects/index">Projects</a> <a href="@rootUrl/projects">Projects</a>
</li> </li>
<li> <li>
<a asp-area="" asp-page="/contact/index">Contact</a> <a href="@rootUrl/contact">Contact</a>
</li> </li>
<li> <li>
<a asp-area="" asp-page="/donate">Donate</a> <a href="@rootUrl/donate">Donate</a>
</li> </li>
</ul> </ul>
</nav> </nav>
@ -68,11 +72,11 @@
<div class="container text-center"> <div class="container text-center">
&copy; @DateTime.UtcNow.Year &copy; @DateTime.UtcNow.Year
&bullet; &bullet;
<a asp-area="" asp-page="/privacy/index">Privacy</a> <a href="@rootUrl/privacy/index">Privacy</a>
&bullet; &bullet;
<a href="https://mastodon.olivr.me/@@oliver" rel="me">Mastodon</a> <a href="https://mastodon.olivr.me/@@oliver" rel="me">Mastodon</a>
&bullet; &bullet;
<a href="/blog/feed"><i class="fa-solid fa-rss text-orange"></i></a> <a href="@blogUrl/feed"><i class="fa-solid fa-rss text-orange"></i></a>
</div> </div>
</footer> </footer>

View File

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

View File

@ -8,6 +8,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
package.json = package.json package.json = package.json
Gulpfile.js = Gulpfile.js Gulpfile.js = Gulpfile.js
tsconfig.json = tsconfig.json tsconfig.json = tsconfig.json
.dockerignore = .dockerignore
docker-compose.yml = docker-compose.yml
global.json = global.json
Dockerfile = Dockerfile
EndProjectSection EndProjectSection
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{8A323E64-E41E-4780-99FD-17BF58961FB5}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{8A323E64-E41E-4780-99FD-17BF58961FB5}"
@ -31,6 +35,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ts", "ts", "{BB9F76AC-292A-
src\ts\Input.ts = src\ts\Input.ts src\ts\Input.ts = src\ts\Input.ts
EndProjectSection EndProjectSection
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OliverBooth.Blog", "OliverBooth.Blog\OliverBooth.Blog.csproj", "{B114A2ED-3015-43C5-B0CE-B755B18F49D0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OliverBooth.Common", "OliverBooth.Common\OliverBooth.Common.csproj", "{38DEB2FA-3DF4-4D37-A12D-22CAEEA3A8AB}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@ -41,6 +49,14 @@ Global
{A58A6FA3-480C-400B-822A-3786741BF39C}.Debug|Any CPU.Build.0 = Debug|Any CPU {A58A6FA3-480C-400B-822A-3786741BF39C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A58A6FA3-480C-400B-822A-3786741BF39C}.Release|Any CPU.ActiveCfg = Release|Any CPU {A58A6FA3-480C-400B-822A-3786741BF39C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A58A6FA3-480C-400B-822A-3786741BF39C}.Release|Any CPU.Build.0 = Release|Any CPU {A58A6FA3-480C-400B-822A-3786741BF39C}.Release|Any CPU.Build.0 = Release|Any CPU
{B114A2ED-3015-43C5-B0CE-B755B18F49D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B114A2ED-3015-43C5-B0CE-B755B18F49D0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B114A2ED-3015-43C5-B0CE-B755B18F49D0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B114A2ED-3015-43C5-B0CE-B755B18F49D0}.Release|Any CPU.Build.0 = Release|Any CPU
{38DEB2FA-3DF4-4D37-A12D-22CAEEA3A8AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{38DEB2FA-3DF4-4D37-A12D-22CAEEA3A8AB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{38DEB2FA-3DF4-4D37-A12D-22CAEEA3A8AB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{38DEB2FA-3DF4-4D37-A12D-22CAEEA3A8AB}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(NestedProjects) = preSolution GlobalSection(NestedProjects) = preSolution
{822F528E-3CA7-4B7D-9250-BD248ADA7BAE} = {8A323E64-E41E-4780-99FD-17BF58961FB5} {822F528E-3CA7-4B7D-9250-BD248ADA7BAE} = {8A323E64-E41E-4780-99FD-17BF58961FB5}

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,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

@ -1,39 +0,0 @@
using System.Text;
using NLog;
using NLog.Targets;
using LogLevel = NLog.LogLevel;
namespace OliverBooth.Logging;
/// <summary>
/// Represents an NLog target which supports colorful output to stdout.
/// </summary>
internal sealed class ColorfulConsoleTarget : TargetWithLayout
{
/// <summary>
/// Initializes a new instance of the <see cref="ColorfulConsoleTarget" /> class.
/// </summary>
/// <param name="name">The name of the log target.</param>
public ColorfulConsoleTarget(string name)
{
Name = name;
}
/// <inheritdoc />
protected override void Write(LogEventInfo logEvent)
{
var message = new StringBuilder();
message.Append(Layout.Render(logEvent));
if (logEvent.Level == LogLevel.Warn)
Console.ForegroundColor = ConsoleColor.Yellow;
else if (logEvent.Level == LogLevel.Error || logEvent.Level == LogLevel.Fatal)
Console.ForegroundColor = ConsoleColor.Red;
if (logEvent.Exception is { } exception)
message.Append($": {exception}");
Console.WriteLine(message);
Console.ResetColor();
}
}

View File

@ -1,40 +0,0 @@
using System.Text;
using NLog;
using NLog.Targets;
using OliverBooth.Services;
namespace OliverBooth.Logging;
/// <summary>
/// Represents an NLog target which writes its output to a log file on disk.
/// </summary>
internal sealed class LogFileTarget : TargetWithLayout
{
private readonly LoggingService _loggingService;
/// <summary>
/// Initializes a new instance of the <see cref="LogFileTarget" /> class.
/// </summary>
/// <param name="name">The name of the log target.</param>
/// <param name="loggingService">The <see cref="LoggingService" />.</param>
public LogFileTarget(string name, LoggingService loggingService)
{
_loggingService = loggingService;
Name = name;
}
/// <inheritdoc />
protected override void Write(LogEventInfo logEvent)
{
_loggingService.ArchiveLogFilesAsync(false).GetAwaiter().GetResult();
using FileStream stream = _loggingService.LogFile.Open(FileMode.Append, FileAccess.Write);
using var writer = new StreamWriter(stream, Encoding.UTF8);
writer.Write(Layout.Render(logEvent));
if (logEvent.Exception is { } exception)
writer.Write($": {exception}");
writer.WriteLine();
}
}

View File

@ -8,22 +8,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<Content Include="..\.dockerignore"> <ProjectReference Include="..\OliverBooth.Common\OliverBooth.Common.csproj"/>
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Alexinea.Extensions.Configuration.Toml" Version="7.0.0"/>
<PackageReference Include="Humanizer.Core" Version="2.14.1"/>
<PackageReference Include="Markdig" Version="0.32.0"/>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="7.0.9"/>
<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"/>
<PackageReference Include="X10D" Version="3.2.2"/>
<PackageReference Include="X10D.Hosting" Version="3.2.2"/>
<PackageReference Include="ZString" Version="2.5.0"/>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -1,48 +0,0 @@
/* Please see documentation at https://docs.microsoft.com/aspnet/core/client-side/bundling-and-minification
for details on configuring this project to bundle and minify static web assets. */
a.navbar-brand {
white-space: normal;
text-align: center;
word-break: break-all;
}
a {
color: #0077cc;
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.nav-pills .nav-link.active, .nav-pills .show > .nav-link {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.border-top {
border-top: 1px solid #e5e5e5;
}
.border-bottom {
border-bottom: 1px solid #e5e5e5;
}
.box-shadow {
box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .05);
}
button.accept-policy {
font-size: 1rem;
line-height: inherit;
}
.footer {
position: absolute;
bottom: 0;
width: 100%;
white-space: nowrap;
line-height: 60px;
}

View File

@ -1,24 +1,24 @@
using System.Runtime.InteropServices;
using System.Security.Cryptography.X509Certificates;
using Markdig; using Markdig;
using NLog; using OliverBooth.Common;
using NLog.Extensions.Logging; using OliverBooth.Common.Extensions;
using OliverBooth.Data; using OliverBooth.Data;
using OliverBooth.Markdown.Template; using OliverBooth.Markdown.Template;
using OliverBooth.Markdown.Timestamp; using OliverBooth.Markdown.Timestamp;
using OliverBooth.Middleware;
using OliverBooth.Services; using OliverBooth.Services;
using X10D.Hosting.DependencyInjection; using Serilog;
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.WriteTo.File("logs/latest.log", rollingInterval: RollingInterval.Day)
.CreateLogger();
WebApplicationBuilder builder = WebApplication.CreateBuilder(args); WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddTomlFile("data/config.toml", true, true); builder.Configuration.AddTomlFile("data/config.toml", true, true);
builder.Logging.ClearProviders(); builder.Logging.ClearProviders();
builder.Logging.AddNLog(); builder.Logging.AddSerilog();
builder.Services.AddHostedSingleton<LoggingService>();
builder.Services.AddSingleton<ConfigurationService>(); builder.Services.ConfigureOptions<OliverBoothConfigureOptions>();
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>()
@ -29,9 +29,7 @@ builder.Services.AddSingleton(provider => new MarkdownPipelineBuilder()
.UseSmartyPants() .UseSmartyPants()
.Build()); .Build());
builder.Services.AddDbContextFactory<BlogContext>();
builder.Services.AddDbContextFactory<WebContext>(); builder.Services.AddDbContextFactory<WebContext>();
builder.Services.AddSingleton<BlogService>();
builder.Services.AddRazorPages().AddRazorRuntimeCompilation(); builder.Services.AddRazorPages().AddRazorRuntimeCompilation();
builder.Services.AddControllersWithViews(); builder.Services.AddControllersWithViews();
builder.Services.AddCors(options => options.AddPolicy("BlogApi", policy => (builder.Environment.IsDevelopment() builder.Services.AddCors(options => options.AddPolicy("BlogApi", policy => (builder.Environment.IsDevelopment()
@ -41,35 +39,7 @@ builder.Services.AddCors(options => options.AddPolicy("BlogApi", policy => (buil
.AllowAnyHeader())); .AllowAnyHeader()));
builder.Services.AddRouting(options => options.LowercaseUrls = true); builder.Services.AddRouting(options => options.LowercaseUrls = true);
builder.WebHost.UseKestrel(kestrel => builder.WebHost.AddCertificateFromEnvironment(2845, 5049);
{
string certPath = Environment.GetEnvironmentVariable("SSL_CERT_PATH")!;
if (!File.Exists(certPath))
{
kestrel.ListenAnyIP(5049);
return;
}
string? keyPath = Environment.GetEnvironmentVariable("SSL_KEY_PATH");
if (string.IsNullOrWhiteSpace(keyPath) || !File.Exists(keyPath)) keyPath = null;
kestrel.ListenAnyIP(2845, options =>
{
X509Certificate2 cert = CreateCertFromPemFile(certPath, keyPath);
options.UseHttps(cert);
});
return;
static X509Certificate2 CreateCertFromPemFile(string certPath, string? keyPath)
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
return X509Certificate2.CreateFromPemFile(certPath, keyPath);
//workaround for windows issue https://github.com/dotnet/runtime/issues/23749#issuecomment-388231655
using var cert = X509Certificate2.CreateFromPemFile(certPath, keyPath);
return new X509Certificate2(cert.Export(X509ContentType.Pkcs12));
}
});
WebApplication app = builder.Build(); WebApplication app = builder.Build();
@ -88,8 +58,5 @@ app.UseCors("BlogApi");
app.MapControllers(); app.MapControllers();
app.MapRazorPages(); app.MapRazorPages();
app.MapRssFeed("/blog/feed");
app.Run(); app.Run();
LogManager.Shutdown();

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);
}
}

View File

@ -1,34 +0,0 @@
using Microsoft.EntityFrameworkCore;
using OliverBooth.Data;
namespace OliverBooth.Services;
/// <summary>
/// Represents a service for retrieving configuration values.
/// </summary>
public sealed class ConfigurationService
{
private readonly IDbContextFactory<WebContext> _webContextFactory;
/// <summary>
/// Initializes a new instance of the <see cref="ConfigurationService" /> class.
/// </summary>
/// <param name="webContextFactory">
/// The <see cref="IDbContextFactory{TContext}" /> for the <see cref="WebContext" />.
/// </param>
public ConfigurationService(IDbContextFactory<WebContext> webContextFactory)
{
_webContextFactory = webContextFactory;
}
/// <summary>
/// Gets the value of a site configuration key.
/// </summary>
/// <param name="key">The key of the site configuration item.</param>
/// <returns>The value of the site configuration item.</returns>
public string? GetSiteConfiguration(string key)
{
using WebContext context = _webContextFactory.CreateDbContext();
return context.SiteConfiguration.Find(key)?.Value;
}
}

View File

@ -1,95 +0,0 @@
using System.IO.Compression;
using NLog;
using NLog.Config;
using NLog.Layouts;
using OliverBooth.Logging;
using LogLevel = NLog.LogLevel;
namespace OliverBooth.Services;
/// <summary>
/// Represents a class which implements a logging service that supports multiple log targets.
/// </summary>
/// <remarks>
/// This class implements a logging structure similar to that of Minecraft, where historic logs are compressed to a .gz and
/// the latest log is found in <c>logs/latest.log</c>.
/// </remarks>
internal sealed class LoggingService : BackgroundService
{
private const string LogFileName = "logs/latest.log";
/// <summary>
/// Initializes a new instance of the <see cref="LoggingService" /> class.
/// </summary>
public LoggingService()
{
LogFile = new FileInfo(LogFileName);
}
/// <summary>
/// Gets or sets the log file.
/// </summary>
/// <value>The log file.</value>
public FileInfo LogFile { get; set; }
/// <summary>
/// Archives any existing log files.
/// </summary>
public async Task ArchiveLogFilesAsync(bool archiveToday = true)
{
var latestFile = new FileInfo(LogFile.FullName);
if (!latestFile.Exists) return;
DateTime lastWrite = latestFile.LastWriteTime;
string lastWriteDate = $"{lastWrite:yyyy-MM-dd}";
var version = 0;
string name;
if (!archiveToday && lastWrite.Date == DateTime.Today) return;
while (File.Exists(name = Path.Combine(LogFile.Directory!.FullName, $"{lastWriteDate}-{++version}.log.gz")))
{
// body ignored
}
await using (FileStream source = latestFile.OpenRead())
{
await using FileStream output = File.Create(name);
await using var gzip = new GZipStream(output, CompressionMode.Compress);
await source.CopyToAsync(gzip);
}
latestFile.Delete();
}
/// <inheritdoc />
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
LogFile.Directory?.Create();
LogManager.Setup(builder => builder.SetupExtensions(extensions =>
{
extensions.RegisterLayoutRenderer("TheTime", info => info.TimeStamp.ToString("HH:mm:ss"));
extensions.RegisterLayoutRenderer("ServiceName", info => info.LoggerName);
}));
Layout? layout = Layout.FromString("[${TheTime} ${level:uppercase=true}] [${ServiceName}] ${message}");
var config = new LoggingConfiguration();
var fileLogger = new LogFileTarget("FileLogger", this) { Layout = layout };
var consoleLogger = new ColorfulConsoleTarget("ConsoleLogger") { Layout = layout };
#if DEBUG
LogLevel minLevel = LogLevel.Debug;
#else
LogLevel minLevel = LogLevel.Info;
if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("ENABLE_DEBUG_LOGGING")))
minLevel = LogLevel.Debug;
#endif
config.AddRule(minLevel, LogLevel.Fatal, consoleLogger);
config.AddRule(minLevel, LogLevel.Fatal, fileLogger);
LogManager.Configuration = config;
return ArchiveLogFilesAsync();
}
}

View File

@ -17,3 +17,21 @@ services:
- MYSQL_DATABASE=${MYSQL_DATABASE} - MYSQL_DATABASE=${MYSQL_DATABASE}
- MYSQL_USER=${MYSQL_USER} - MYSQL_USER=${MYSQL_USER}
- MYSQL_PASSWORD=${MYSQL_PASSWORD} - MYSQL_PASSWORD=${MYSQL_PASSWORD}
oliverbooth-blog:
container_name: blog.oliverbooth.dev
pull_policy: build
build: .
volumes:
- type: bind
source: /var/log/oliverbooth-blog/site
target: /app/logs
- type: bind
source: /etc/oliverbooth-blog/site
target: /app/data
restart: always
environment:
- MYSQL_HOST=${MYSQL_HOST}
- MYSQL_DATABASE=${MYSQL_DATABASE}
- MYSQL_USER=${MYSQL_USER}
- MYSQL_PASSWORD=${MYSQL_PASSWORD}