Compare commits

..

No commits in common. "f60b9c754a8712c41b8b3cdbe15bdb2a92cb38f5" and "9b9143632a7803d4af63bf4bc3b51da076e4bd4b" have entirely different histories.

51 changed files with 865 additions and 998 deletions

View File

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

View File

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

View File

@ -1,102 +0,0 @@
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,21 +0,0 @@
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

@ -1,32 +0,0 @@
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

@ -1,89 +0,0 @@
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

@ -1,54 +0,0 @@
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

@ -1,73 +0,0 @@
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,13 +0,0 @@
<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,2 +0,0 @@
@page "/{year:int}/{month:int}/{day:int}/{slug}/raw"
@model RawArticle

View File

@ -1,37 +0,0 @@
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

@ -1,124 +0,0 @@
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

@ -1,91 +0,0 @@
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

@ -1,23 +0,0 @@
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

@ -1,32 +0,0 @@
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

@ -1,53 +0,0 @@
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

@ -1,39 +0,0 @@
<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

@ -1,41 +0,0 @@
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,2 +0,0 @@
@namespace OliverBooth.Common.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

View File

@ -8,10 +8,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
package.json = package.json
Gulpfile.js = Gulpfile.js
tsconfig.json = tsconfig.json
.dockerignore = .dockerignore
docker-compose.yml = docker-compose.yml
global.json = global.json
Dockerfile = Dockerfile
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{8A323E64-E41E-4780-99FD-17BF58961FB5}"
@ -35,10 +31,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ts", "ts", "{BB9F76AC-292A-
src\ts\Input.ts = src\ts\Input.ts
EndProjectSection
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
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -49,14 +41,6 @@ Global
{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.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
GlobalSection(NestedProjects) = preSolution
{822F528E-3CA7-4B7D-9250-BD248ADA7BAE} = {8A323E64-E41E-4780-99FD-17BF58961FB5}

View File

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

View File

@ -1,9 +1,9 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using OliverBooth.Blog.Data;
using OliverBooth.Blog.Services;
using OliverBooth.Data.Blog;
using OliverBooth.Services;
namespace OliverBooth.Blog.Pages;
namespace OliverBooth.Areas.Blog.Pages;
/// <summary>
/// Represents the page model for the <c>Article</c> page.
@ -11,18 +11,26 @@ namespace OliverBooth.Blog.Pages;
[Area("blog")]
public class Article : PageModel
{
private readonly IBlogPostService _blogPostService;
private readonly BlogService _blogService;
private readonly BlogUserService _blogUserService;
/// <summary>
/// Initializes a new instance of the <see cref="Article" /> class.
/// </summary>
/// <param name="blogPostService">The <see cref="IBlogPostService" />.</param>
public Article(IBlogPostService blogPostService)
/// <param name="blogService">The <see cref="BlogService" />.</param>
/// <param name="blogUserService">The <see cref="BlogUserService" />.</param>
public Article(BlogService blogService, BlogUserService blogUserService)
{
_blogPostService = blogPostService;
_blogService = blogService;
_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>
/// Gets a value indicating whether the post is a legacy WordPress post.
/// </summary>
@ -30,29 +38,23 @@ 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 IBlogPost Post { get; private set; } = null!;
public BlogPost Post { get; private set; } = null!;
public IActionResult OnGet(int year, int month, int day, string slug)
{
var date = new DateOnly(year, month, day);
if (!_blogPostService.TryGetPost(date, slug, out IBlogPost? post))
if (!_blogService.TryGetBlogPost(year, month, day, slug, out BlogPost? 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 Index
@model OliverBooth.Areas.Blog.Pages.Index
@{
ViewData["Title"] = "Blog";

View File

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

View File

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

View File

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

View File

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

View File

@ -1,66 +1,58 @@
using Humanizer;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using OliverBooth.Blog.Data;
using OliverBooth.Blog.Services;
using OliverBooth.Data.Blog;
using OliverBooth.Services;
namespace OliverBooth.Blog.Controllers;
namespace OliverBooth.Controllers;
/// <summary>
/// Represents a controller for the blog API.
/// </summary>
[ApiController]
[Route("api")]
[Route("api/blog")]
[Produces("application/json")]
[EnableCors("OliverBooth")]
[EnableCors("BlogApi")]
public sealed class BlogApiController : ControllerBase
{
private readonly IBlogPostService _blogPostService;
private readonly IUserService _userService;
private readonly BlogService _blogService;
private readonly BlogUserService _blogUserService;
/// <summary>
/// Initializes a new instance of the <see cref="BlogApiController" /> class.
/// </summary>
/// <param name="blogPostService">The <see cref="IBlogPostService" />.</param>
/// <param name="userService">The <see cref="IUserService" />.</param>
public BlogApiController(IBlogPostService blogPostService, IUserService userService)
/// <param name="blogService">The <see cref="BlogService" />.</param>
/// <param name="blogUserService">The <see cref="BlogUserService" />.</param>
public BlogApiController(BlogService blogService, BlogUserService blogUserService)
{
_blogPostService = blogPostService;
_userService = userService;
_blogService = blogService;
_blogUserService = blogUserService;
}
[Route("count")]
public IActionResult Count()
{
if (!ValidateReferer()) return NotFound();
return Ok(new { count = _blogPostService.GetAllBlogPosts().Count });
return Ok(new { count = _blogService.AllPosts.Count });
}
[HttpGet("all/{skip:int?}/{take:int?}")]
public IActionResult GetAllBlogPosts(int skip = 0, int take = -1)
{
if (!ValidateReferer()) return NotFound();
// 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
if (take == -1) take = _blogService.AllPosts.Count;
return Ok(_blogService.AllPosts.Skip(skip).Take(take).Select(post => new
{
id = post.Id,
commentsEnabled = post.EnableComments,
identifier = post.GetDisqusIdentifier(),
author = post.Author.Id,
author = post.AuthorId,
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 = _blogPostService.RenderExcerpt(post, out bool trimmed),
excerpt = _blogService.GetExcerpt(post, out bool trimmed),
trimmed,
url = Url.Page("/Article",
new
@ -78,19 +70,19 @@ public sealed class BlogApiController : ControllerBase
public IActionResult GetAuthor(Guid id)
{
if (!ValidateReferer()) return NotFound();
if (!_userService.TryGetUser(id, out IUser? author)) return NotFound();
if (!_blogUserService.TryGetUser(id, out User? author)) return NotFound();
return Ok(new
{
id = author.Id,
name = author.DisplayName,
avatarUrl = author.AvatarUrl,
avatarHash = author.AvatarHash,
});
}
private bool ValidateReferer()
{
var referer = Request.Headers["Referer"].ToString();
return referer.StartsWith(Url.PageLink("/index")!);
return referer.StartsWith(Url.PageLink("/index", values: new { area = "blog" })!);
}
}

View File

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

View File

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

View File

@ -0,0 +1,39 @@
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

@ -0,0 +1,40 @@
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

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

View File

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

@ -8,7 +8,22 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OliverBooth.Common\OliverBooth.Common.csproj"/>
<Content Include="..\.dockerignore">
<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>
</Project>

View File

@ -1,7 +1,3 @@
@{
string rootUrl = Environment.GetEnvironmentVariable("ROOT_URL") ?? "https://oliverbooth.dev";
string blogUrl = Environment.GetEnvironmentVariable("BLOG_URL") ?? "https://blog.oliverbooth.dev";
}
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
@ -16,7 +12,7 @@
{
<title>Oliver Booth</title>
}
<link rel="shortcut icon" href="/img/favicon.png" asp-append-version="true">
<link rel="shortcut icon" href="~/img/favicon.png">
<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/font-awesome/6.4.2/css/all.min.css" integrity="sha512-z3gLpd7yknf1YoNbCzqRKc4qyor8gaKU1qmn+CShxbuBusANI9QpRohGBreCFkKxLhei6S9CQXFEbbKuqLg0DA==" crossorigin="anonymous" referrerpolicy="no-referrer">
@ -25,9 +21,9 @@
<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=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/app.min.css" asp-append-version="true">
<link rel="stylesheet" href="~/css/app.min.css" asp-append-version="true"/>
</head>
<body>
<header class="container" style="margin-top: 20px;">
@ -40,22 +36,22 @@
<nav>
<ul class="site-nav">
<li>
<a href="@rootUrl">About</a>
<a asp-area="" asp-page="/index">About</a>
</li>
<li>
<a href="@blogUrl">Blog</a>
<a asp-area="blog" asp-page="/index">Blog</a>
</li>
<li>
<a href="@rootUrl/tutorials">Tutorials</a>
<a asp-area="" asp-page="/tutorials/index">Tutorials</a>
</li>
<li>
<a href="@rootUrl/projects">Projects</a>
<a asp-area="" asp-page="/projects/index">Projects</a>
</li>
<li>
<a href="@rootUrl/contact">Contact</a>
<a asp-area="" asp-page="/contact/index">Contact</a>
</li>
<li>
<a href="@rootUrl/donate">Donate</a>
<a asp-area="" asp-page="/donate">Donate</a>
</li>
</ul>
</nav>
@ -72,11 +68,11 @@
<div class="container text-center">
&copy; @DateTime.UtcNow.Year
&bullet;
<a href="@rootUrl/privacy/index">Privacy</a>
<a asp-area="" asp-page="/privacy/index">Privacy</a>
&bullet;
<a href="https://mastodon.olivr.me/@@oliver" rel="me">Mastodon</a>
&bullet;
<a href="@blogUrl/feed"><i class="fa-solid fa-rss text-orange"></i></a>
<a href="/blog/feed"><i class="fa-solid fa-rss text-orange"></i></a>
</div>
</footer>

View File

@ -0,0 +1,48 @@
/* 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 OliverBooth.Common;
using OliverBooth.Common.Extensions;
using NLog;
using NLog.Extensions.Logging;
using OliverBooth.Data;
using OliverBooth.Markdown.Template;
using OliverBooth.Markdown.Timestamp;
using OliverBooth.Middleware;
using OliverBooth.Services;
using Serilog;
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.WriteTo.File("logs/latest.log", rollingInterval: RollingInterval.Day)
.CreateLogger();
using X10D.Hosting.DependencyInjection;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddTomlFile("data/config.toml", true, true);
builder.Logging.ClearProviders();
builder.Logging.AddSerilog();
builder.Services.ConfigureOptions<OliverBoothConfigureOptions>();
builder.Logging.ClearProviders();
builder.Logging.AddNLog();
builder.Services.AddHostedSingleton<LoggingService>();
builder.Services.AddSingleton<ConfigurationService>();
builder.Services.AddSingleton<TemplateService>();
builder.Services.AddSingleton<BlogUserService>();
builder.Services.AddSingleton(provider => new MarkdownPipelineBuilder()
.Use<TimestampExtension>()
@ -29,7 +29,9 @@ 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()
@ -39,7 +41,35 @@ builder.Services.AddCors(options => options.AddPolicy("BlogApi", policy => (buil
.AllowAnyHeader()));
builder.Services.AddRouting(options => options.LowercaseUrls = true);
builder.WebHost.AddCertificateFromEnvironment(2845, 5049);
builder.WebHost.UseKestrel(kestrel =>
{
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();
@ -58,5 +88,8 @@ app.UseCors("BlogApi");
app.MapControllers();
app.MapRazorPages();
app.MapRssFeed("/blog/feed");
app.Run();
LogManager.Shutdown();

View File

@ -0,0 +1,135 @@
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

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

View File

@ -0,0 +1,34 @@
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

@ -0,0 +1,95 @@
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,21 +17,3 @@ services:
- MYSQL_DATABASE=${MYSQL_DATABASE}
- MYSQL_USER=${MYSQL_USER}
- 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}