Compare commits
No commits in common. "f60b9c754a8712c41b8b3cdbe15bdb2a92cb38f5" and "9b9143632a7803d4af63bf4bc3b51da076e4bd4b" have entirely different histories.
f60b9c754a
...
9b9143632a
10
Dockerfile
10
Dockerfile
@ -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/OliverBooth.csproj", "OliverBooth/"]
|
COPY ["oliverbooth.dev/oliverbooth.dev.csproj", "oliverbooth.dev/"]
|
||||||
RUN dotnet restore "oliverbooth.dev/oliverbooth.dev.csproj"
|
RUN dotnet restore "oliverbooth.dev/oliverbooth.dev.csproj"
|
||||||
COPY . .
|
COPY . .
|
||||||
WORKDIR "/src/OliverBooth"
|
WORKDIR "/src/oliverbooth.dev"
|
||||||
RUN dotnet build "OliverBooth.csproj" -c Release -o /app/build
|
RUN dotnet build "oliverbooth.dev.csproj" -c Release -o /app/build
|
||||||
|
|
||||||
FROM build AS publish
|
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
|
FROM base AS final
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=publish /app/publish .
|
COPY --from=publish /app/publish .
|
||||||
ENTRYPOINT ["dotnet", "OliverBooth.dll"]
|
ENTRYPOINT ["dotnet", "oliverbooth.dev.dll"]
|
||||||
|
@ -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.Common/wwwroot';
|
const destDir = 'OliverBooth/wwwroot';
|
||||||
|
|
||||||
function compileSCSS() {
|
function compileSCSS() {
|
||||||
return gulp.src(`${srcDir}/scss/**/*.scss`)
|
return gulp.src(`${srcDir}/scss/**/*.scss`)
|
||||||
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
@ -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);
|
|
||||||
}
|
|
@ -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();
|
|
||||||
}
|
|
@ -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);
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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>
|
|
@ -1,2 +0,0 @@
|
|||||||
@page "/{year:int}/{month:int}/{day:int}/{slug}/raw"
|
|
||||||
@model RawArticle
|
|
@ -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();
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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);
|
|
||||||
}
|
|
@ -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);
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
@ -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>
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,2 +0,0 @@
|
|||||||
@namespace OliverBooth.Common.Pages
|
|
||||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
|
@ -8,10 +8,6 @@ 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}"
|
||||||
@ -35,10 +31,6 @@ 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
|
||||||
@ -49,14 +41,6 @@ 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}
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
@page "/{year:int}/{month:int}/{day:int}/{slug}"
|
@page "/blog/{year:int}/{month:int}/{day:int}/{slug}"
|
||||||
@using Humanizer
|
@using Humanizer
|
||||||
@using OliverBooth.Blog.Data
|
@using OliverBooth.Data.Blog
|
||||||
@model Article
|
@using OliverBooth.Services
|
||||||
|
@model OliverBooth.Areas.Blog.Pages.Article
|
||||||
|
@inject BlogService BlogService
|
||||||
|
|
||||||
@if (Model.Post is not { } post)
|
@if (Model.Post is not { } post)
|
||||||
{
|
{
|
||||||
@ -10,14 +12,14 @@
|
|||||||
|
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = post.Title;
|
ViewData["Title"] = post.Title;
|
||||||
IBlogAuthor author = post.Author;
|
User author = Model.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-page="/index">Blog</a>
|
<a asp-area="blog" 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>
|
||||||
@ -25,7 +27,7 @@
|
|||||||
|
|
||||||
<h1>@post.Title</h1>
|
<h1>@post.Title</h1>
|
||||||
<p class="text-muted">
|
<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 •
|
@author.DisplayName •
|
||||||
|
|
||||||
<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")">
|
||||||
@ -47,9 +49,9 @@
|
|||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
<article>
|
<article>
|
||||||
@* @Html.Raw(BlogService.GetContent(post)) *@
|
@Html.Raw(BlogService.GetContent(post))
|
||||||
@Html.Raw(post.Body)
|
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
@ -62,7 +64,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.GetDisqusPostId()";
|
this.page.postId = "@(post.WordPressId?.ToString() ?? post.Id.ToString())";
|
||||||
};
|
};
|
||||||
|
|
||||||
(function() {
|
(function() {
|
@ -1,9 +1,9 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
using OliverBooth.Blog.Data;
|
using OliverBooth.Data.Blog;
|
||||||
using OliverBooth.Blog.Services;
|
using OliverBooth.Services;
|
||||||
|
|
||||||
namespace OliverBooth.Blog.Pages;
|
namespace OliverBooth.Areas.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,18 +11,26 @@ namespace OliverBooth.Blog.Pages;
|
|||||||
[Area("blog")]
|
[Area("blog")]
|
||||||
public class Article : PageModel
|
public class Article : PageModel
|
||||||
{
|
{
|
||||||
private readonly IBlogPostService _blogPostService;
|
private readonly BlogService _blogService;
|
||||||
|
private readonly BlogUserService _blogUserService;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="Article" /> class.
|
/// Initializes a new instance of the <see cref="Article" /> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="blogPostService">The <see cref="IBlogPostService" />.</param>
|
/// <param name="blogService">The <see cref="BlogService" />.</param>
|
||||||
public Article(IBlogPostService blogPostService)
|
/// <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>
|
/// <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>
|
||||||
@ -30,29 +38,23 @@ 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 IBlogPost Post { get; private set; } = null!;
|
public BlogPost 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)
|
||||||
{
|
{
|
||||||
var date = new DateOnly(year, month, day);
|
if (!_blogService.TryGetBlogPost(year, month, day, slug, out BlogPost? post))
|
||||||
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,5 +1,5 @@
|
|||||||
@page
|
@page
|
||||||
@model Index
|
@model OliverBooth.Areas.Blog.Pages.Index
|
||||||
|
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Blog";
|
ViewData["Title"] = "Blog";
|
@ -1,18 +1,18 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
using OliverBooth.Blog.Data;
|
using OliverBooth.Data.Blog;
|
||||||
using OliverBooth.Blog.Services;
|
using OliverBooth.Services;
|
||||||
|
|
||||||
namespace OliverBooth.Blog.Pages;
|
namespace OliverBooth.Areas.Blog.Pages;
|
||||||
|
|
||||||
[Area("blog")]
|
[Area("blog")]
|
||||||
public class Index : PageModel
|
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,
|
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 _blogPostService.TryGetPost(postId, out IBlogPost? post) ? RedirectToPost(post) : NotFound();
|
return _blogService.TryGetBlogPost(postId, out BlogPost? post) ? RedirectToPost(post) : NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
private IActionResult HandleWordPressRoute(int wpPostId)
|
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
|
var route = new
|
||||||
{
|
{
|
2
OliverBooth/Areas/Blog/Pages/RawArticle.cshtml
Normal file
2
OliverBooth/Areas/Blog/Pages/RawArticle.cshtml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
@page "/blog/{year:int}/{month:int}/{day:int}/{slug}/raw"
|
||||||
|
@model OliverBooth.Areas.Blog.Pages.RawArticle
|
@ -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.Blog.Data;
|
using OliverBooth.Data.Blog;
|
||||||
using OliverBooth.Blog.Services;
|
using OliverBooth.Services;
|
||||||
|
|
||||||
namespace OliverBooth.Blog.Pages;
|
namespace OliverBooth.Areas.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,21 +12,23 @@ namespace OliverBooth.Blog.Pages;
|
|||||||
[Area("blog")]
|
[Area("blog")]
|
||||||
public class RawArticle : PageModel
|
public class RawArticle : PageModel
|
||||||
{
|
{
|
||||||
private readonly IBlogPostService _blogPostService;
|
private readonly BlogService _blogService;
|
||||||
|
private readonly BlogUserService _blogUserService;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="RawArticle" /> class.
|
/// Initializes a new instance of the <see cref="RawArticle" /> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="blogPostService">The <see cref="IBlogPostService" />.</param>
|
/// <param name="blogService">The <see cref="BlogService" />.</param>
|
||||||
public RawArticle(IBlogPostService blogPostService)
|
/// <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)
|
public IActionResult OnGet(int year, int month, int day, string slug)
|
||||||
{
|
{
|
||||||
var date = new DateOnly(year, month, day);
|
if (!_blogService.TryGetBlogPost(year, month, day, slug, out BlogPost? post))
|
||||||
if (!_blogPostService.TryGetPost(date, slug, out IBlogPost? post))
|
|
||||||
{
|
{
|
||||||
return NotFound();
|
return NotFound();
|
||||||
}
|
}
|
||||||
@ -35,7 +37,8 @@ public class RawArticle : PageModel
|
|||||||
|
|
||||||
using Utf8ValueStringBuilder builder = ZString.CreateUtf8StringBuilder();
|
using Utf8ValueStringBuilder builder = ZString.CreateUtf8StringBuilder();
|
||||||
builder.AppendLine("# " + post.Title);
|
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}");
|
builder.AppendLine($"Published: {post.Published:R}");
|
||||||
if (post.Updated.HasValue)
|
if (post.Updated.HasValue)
|
@ -1,2 +1,2 @@
|
|||||||
@namespace OliverBooth.Blog.Pages
|
@namespace OliverBooth.Areas.Blog.Pages
|
||||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
@ -1,66 +1,58 @@
|
|||||||
using Humanizer;
|
using Humanizer;
|
||||||
using Microsoft.AspNetCore.Cors;
|
using Microsoft.AspNetCore.Cors;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using OliverBooth.Blog.Data;
|
using OliverBooth.Data.Blog;
|
||||||
using OliverBooth.Blog.Services;
|
using OliverBooth.Services;
|
||||||
|
|
||||||
namespace OliverBooth.Blog.Controllers;
|
namespace OliverBooth.Controllers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents a controller for the blog API.
|
/// Represents a controller for the blog API.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api")]
|
[Route("api/blog")]
|
||||||
[Produces("application/json")]
|
[Produces("application/json")]
|
||||||
[EnableCors("OliverBooth")]
|
[EnableCors("BlogApi")]
|
||||||
public sealed class BlogApiController : ControllerBase
|
public sealed class BlogApiController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IBlogPostService _blogPostService;
|
private readonly BlogService _blogService;
|
||||||
private readonly IUserService _userService;
|
private readonly BlogUserService _blogUserService;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="BlogApiController" /> class.
|
/// Initializes a new instance of the <see cref="BlogApiController" /> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="blogPostService">The <see cref="IBlogPostService" />.</param>
|
/// <param name="blogService">The <see cref="BlogService" />.</param>
|
||||||
/// <param name="userService">The <see cref="IUserService" />.</param>
|
/// <param name="blogUserService">The <see cref="BlogUserService" />.</param>
|
||||||
public BlogApiController(IBlogPostService blogPostService, IUserService userService)
|
public BlogApiController(BlogService blogService, BlogUserService blogUserService)
|
||||||
{
|
{
|
||||||
_blogPostService = blogPostService;
|
_blogService = blogService;
|
||||||
_userService = userService;
|
_blogUserService = blogUserService;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Route("count")]
|
[Route("count")]
|
||||||
public IActionResult Count()
|
public IActionResult Count()
|
||||||
{
|
{
|
||||||
if (!ValidateReferer()) return NotFound();
|
if (!ValidateReferer()) return NotFound();
|
||||||
return Ok(new { count = _blogPostService.GetAllBlogPosts().Count });
|
return Ok(new { count = _blogService.AllPosts.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;
|
||||||
// TODO yes I'm aware I can use the new pagination I wrote, this will be added soon.
|
return Ok(_blogService.AllPosts.Skip(skip).Take(take).Select(post => new
|
||||||
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.Author.Id,
|
author = post.AuthorId,
|
||||||
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 = _blogPostService.RenderExcerpt(post, out bool trimmed),
|
excerpt = _blogService.GetExcerpt(post, out bool trimmed),
|
||||||
trimmed,
|
trimmed,
|
||||||
url = Url.Page("/Article",
|
url = Url.Page("/Article",
|
||||||
new
|
new
|
||||||
@ -78,19 +70,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 (!_userService.TryGetUser(id, out IUser? author)) return NotFound();
|
if (!_blogUserService.TryGetUser(id, out User? author)) return NotFound();
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
id = author.Id,
|
id = author.Id,
|
||||||
name = author.DisplayName,
|
name = author.DisplayName,
|
||||||
avatarUrl = author.AvatarUrl,
|
avatarHash = author.AvatarHash,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool ValidateReferer()
|
private bool ValidateReferer()
|
||||||
{
|
{
|
||||||
var referer = Request.Headers["Referer"].ToString();
|
var referer = Request.Headers["Referer"].ToString();
|
||||||
return referer.StartsWith(Url.PageLink("/index")!);
|
return referer.StartsWith(Url.PageLink("/index", values: new { area = "blog" })!);
|
||||||
}
|
}
|
||||||
}
|
}
|
186
OliverBooth/Data/Blog/BlogPost.cs
Normal file
186
OliverBooth/Data/Blog/BlogPost.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
@ -1,9 +1,11 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
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>
|
internal sealed class BlogPostConfiguration : IEntityTypeConfiguration<BlogPost>
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@ -21,7 +23,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).HasConversion<UriToStringConverter>().HasMaxLength(255).IsRequired(false);
|
builder.Property(e => e.RedirectUrl).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);
|
@ -1,12 +1,13 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
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>
|
/// <summary>
|
||||||
/// Represents a session with the blog database.
|
/// Represents a session with the blog database.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class BlogContext : DbContext
|
public sealed class BlogContext : DbContext
|
||||||
{
|
{
|
||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
|
|
||||||
@ -20,16 +21,16 @@ internal sealed class BlogContext : DbContext
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the collection of blog posts in the database.
|
/// Gets the set of blog posts.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <value>The collection of blog posts.</value>
|
/// <value>The set of blog posts.</value>
|
||||||
public DbSet<BlogPost> BlogPosts { get; private set; } = null!;
|
public DbSet<BlogPost> BlogPosts { get; internal set; } = null!;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the collection of users in the database.
|
/// Gets the set of users.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <value>The collection of users.</value>
|
/// <value>The set of users.</value>
|
||||||
public DbSet<User> Users { get; private set; } = null!;
|
public DbSet<User> Users { get; internal set; } = null!;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
39
OliverBooth/Logging/ColorfulConsoleTarget.cs
Normal file
39
OliverBooth/Logging/ColorfulConsoleTarget.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
40
OliverBooth/Logging/LogFileTarget.cs
Normal file
40
OliverBooth/Logging/LogFileTarget.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
namespace OliverBooth.Blog.Middleware;
|
namespace OliverBooth.Middleware;
|
||||||
|
|
||||||
internal static class RssEndpointExtensions
|
internal static class RssEndpointExtensions
|
||||||
{
|
{
|
@ -1,18 +1,35 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Xml.Serialization;
|
using System.Xml.Serialization;
|
||||||
using OliverBooth.Blog.Data;
|
using OliverBooth.Data.Blog;
|
||||||
using OliverBooth.Blog.Services;
|
|
||||||
using OliverBooth.Data.Rss;
|
using OliverBooth.Data.Rss;
|
||||||
|
using OliverBooth.Services;
|
||||||
|
|
||||||
namespace OliverBooth.Blog.Middleware;
|
namespace OliverBooth.Middleware;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the RSS middleware.
|
||||||
|
/// </summary>
|
||||||
internal sealed class RssMiddleware
|
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")]
|
[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 baseUrl = $"https://{context.Request.Host}/blog";
|
||||||
var blogItems = new List<BlogItem>();
|
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}";
|
var url = $"{baseUrl}/{blogPost.Published:yyyy/MM/dd}/{blogPost.Slug}";
|
||||||
string excerpt = _blogPostService.RenderExcerpt(post, out _);
|
string excerpt = _blogService.GetExcerpt(blogPost, out _);
|
||||||
var description = $"{excerpt}<p><a href=\"{url}\">Read more...</a></p>";
|
var description = $"{excerpt}<p><a href=\"{url}\">Read more...</a></p>";
|
||||||
|
|
||||||
|
_userService.TryGetUser(blogPost.AuthorId, out User? author);
|
||||||
|
|
||||||
var item = new BlogItem
|
var item = new BlogItem
|
||||||
{
|
{
|
||||||
Title = post.Title,
|
Title = blogPost.Title,
|
||||||
Link = url,
|
Link = url,
|
||||||
Comments = $"{url}#disqus_thread",
|
Comments = $"{url}#disqus_thread",
|
||||||
Creator = post.Author.DisplayName,
|
Creator = author?.DisplayName ?? string.Empty,
|
||||||
PubDate = post.Published.ToString("R"),
|
PubDate = blogPost.Published.ToString("R"),
|
||||||
Guid = $"{baseUrl}?pid={post.Id}",
|
Guid = $"{baseUrl}?pid={blogPost.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
|
||||||
@ -53,7 +73,7 @@ internal sealed class RssMiddleware
|
|||||||
Description = $"{baseUrl}/",
|
Description = $"{baseUrl}/",
|
||||||
LastBuildDate = DateTimeOffset.UtcNow.ToString("R"),
|
LastBuildDate = DateTimeOffset.UtcNow.ToString("R"),
|
||||||
Link = $"{baseUrl}/",
|
Link = $"{baseUrl}/",
|
||||||
Title = "Oliver Booth",
|
Title = siteTitle,
|
||||||
Generator = $"{baseUrl}/",
|
Generator = $"{baseUrl}/",
|
||||||
Items = blogItems
|
Items = blogItems
|
||||||
}
|
}
|
||||||
@ -70,5 +90,6 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -8,7 +8,22 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<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>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
@ -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>
|
<!DOCTYPE html>
|
||||||
<html lang="en" data-bs-theme="dark">
|
<html lang="en" data-bs-theme="dark">
|
||||||
<head>
|
<head>
|
||||||
@ -16,7 +12,7 @@
|
|||||||
{
|
{
|
||||||
<title>Oliver Booth</title>
|
<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/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">
|
||||||
@ -25,9 +21,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;">
|
||||||
@ -40,22 +36,22 @@
|
|||||||
<nav>
|
<nav>
|
||||||
<ul class="site-nav">
|
<ul class="site-nav">
|
||||||
<li>
|
<li>
|
||||||
<a href="@rootUrl">About</a>
|
<a asp-area="" asp-page="/index">About</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="@blogUrl">Blog</a>
|
<a asp-area="blog" asp-page="/index">Blog</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="@rootUrl/tutorials">Tutorials</a>
|
<a asp-area="" asp-page="/tutorials/index">Tutorials</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="@rootUrl/projects">Projects</a>
|
<a asp-area="" asp-page="/projects/index">Projects</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="@rootUrl/contact">Contact</a>
|
<a asp-area="" asp-page="/contact/index">Contact</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="@rootUrl/donate">Donate</a>
|
<a asp-area="" asp-page="/donate">Donate</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
@ -72,11 +68,11 @@
|
|||||||
<div class="container text-center">
|
<div class="container text-center">
|
||||||
© @DateTime.UtcNow.Year
|
© @DateTime.UtcNow.Year
|
||||||
•
|
•
|
||||||
<a href="@rootUrl/privacy/index">Privacy</a>
|
<a asp-area="" asp-page="/privacy/index">Privacy</a>
|
||||||
•
|
•
|
||||||
<a href="https://mastodon.olivr.me/@@oliver" rel="me">Mastodon</a>
|
<a href="https://mastodon.olivr.me/@@oliver" rel="me">Mastodon</a>
|
||||||
•
|
•
|
||||||
<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>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
48
OliverBooth/Pages/Shared/_Layout.cshtml.css
Normal file
48
OliverBooth/Pages/Shared/_Layout.cshtml.css
Normal 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;
|
||||||
|
}
|
@ -1,24 +1,24 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
using Markdig;
|
using Markdig;
|
||||||
using OliverBooth.Common;
|
using NLog;
|
||||||
using OliverBooth.Common.Extensions;
|
using NLog.Extensions.Logging;
|
||||||
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 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);
|
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.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<TemplateService>();
|
||||||
|
builder.Services.AddSingleton<BlogUserService>();
|
||||||
|
|
||||||
builder.Services.AddSingleton(provider => new MarkdownPipelineBuilder()
|
builder.Services.AddSingleton(provider => new MarkdownPipelineBuilder()
|
||||||
.Use<TimestampExtension>()
|
.Use<TimestampExtension>()
|
||||||
@ -29,7 +29,9 @@ 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()
|
||||||
@ -39,7 +41,35 @@ 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.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();
|
WebApplication app = builder.Build();
|
||||||
|
|
||||||
@ -58,5 +88,8 @@ app.UseCors("BlogApi");
|
|||||||
|
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
app.MapRazorPages();
|
app.MapRazorPages();
|
||||||
|
app.MapRssFeed("/blog/feed");
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
LogManager.Shutdown();
|
||||||
|
135
OliverBooth/Services/BlogService.cs
Normal file
135
OliverBooth/Services/BlogService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
83
OliverBooth/Services/BlogUserService.cs
Normal file
83
OliverBooth/Services/BlogUserService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
34
OliverBooth/Services/ConfigurationService.cs
Normal file
34
OliverBooth/Services/ConfigurationService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
95
OliverBooth/Services/LoggingService.cs
Normal file
95
OliverBooth/Services/LoggingService.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
@ -17,21 +17,3 @@ 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}
|
|
||||||
|
Loading…
Reference in New Issue
Block a user