refactor: combine sites into one

CORS was "cors"ing some issues (heh).

But also it is easier to maintain this way. Development was made much more difficult when I separated it. Combining it all also improves SEO
This commit is contained in:
Oliver Booth 2023-08-13 17:33:54 +01:00
parent be44fb4b4b
commit 0a9c2e82d5
Signed by: oliverbooth
GPG Key ID: B89D139977693FED
60 changed files with 278 additions and 404 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +0,0 @@
@{
Layout = "_Layout";
}

View File

@ -1,39 +0,0 @@
using OliverBooth.Blog.Data;
using OliverBooth.Blog.Middleware;
using OliverBooth.Blog.Services;
using OliverBooth.Common;
using OliverBooth.Common.Extensions;
using OliverBooth.Common.Services;
using Serilog;
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.AddMarkdownPipeline();
builder.Services.ConfigureOptions<OliverBoothConfigureOptions>();
builder.Services.AddDbContextFactory<BlogContext>();
builder.Services.AddSingleton<IBlogPostService, BlogPostService>();
builder.Services.AddSingleton<IUserService, UserService>();
builder.Services.AddSingleton<ITemplateService, TemplateService>();
builder.Services.AddRazorPages().AddRazorRuntimeCompilation();
builder.Services.AddControllersWithViews();
builder.WebHost.AddCertificateFromEnvironment(2846, 5050);
WebApplication app = builder.Build();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.MapRssFeed("/feed");
app.MapRazorPages();
app.MapControllers();
app.Run();

View File

@ -1,70 +0,0 @@
using System.Buffers.Binary;
using Microsoft.EntityFrameworkCore;
using OliverBooth.Blog.Data;
using OliverBooth.Common.Formatting;
using OliverBooth.Common.Markdown;
using OliverBooth.Common.Services;
using SmartFormat;
using SmartFormat.Extensions;
namespace OliverBooth.Blog.Services;
/// <summary>
/// Represents a service that renders MediaWiki-style templates.
/// </summary>
internal sealed class TemplateService : ITemplateService
{
private static readonly Random Random = new();
private readonly IDbContextFactory<BlogContext> _webContextFactory;
private readonly SmartFormatter _formatter;
/// <summary>
/// Initializes a new instance of the <see cref="TemplateService" /> class.
/// </summary>
/// <param name="serviceProvider">The <see cref="IServiceProvider" />.</param>
/// <param name="webContextFactory">The <see cref="BlogContext" /> factory.</param>
public TemplateService(IServiceProvider serviceProvider, IDbContextFactory<BlogContext> webContextFactory)
{
_formatter = Smart.CreateDefaultSmartFormat();
_formatter.AddExtensions(new DefaultSource());
_formatter.AddExtensions(new ReflectionSource());
_formatter.AddExtensions(new DateFormatter());
_formatter.AddExtensions(new MarkdownFormatter(serviceProvider));
_webContextFactory = webContextFactory;
}
/// <inheritdoc />
public string RenderTemplate(TemplateInline templateInline)
{
if (templateInline is null) throw new ArgumentNullException(nameof(templateInline));
using BlogContext webContext = _webContextFactory.CreateDbContext();
Template? template = webContext.Templates.Find(templateInline.Name);
if (template is null)
{
return $"{{{{{templateInline.Name}}}}}";
}
Span<byte> randomBytes = stackalloc byte[20];
Random.NextBytes(randomBytes);
var formatted = new
{
templateInline.ArgumentList,
templateInline.ArgumentString,
templateInline.Params,
RandomInt = BinaryPrimitives.ReadInt32LittleEndian(randomBytes[..4]),
RandomGuid = new Guid(randomBytes[4..]).ToString("N"),
};
try
{
return _formatter.Format(template.FormatString, formatted);
}
catch
{
return $"{{{{{templateInline.Name}|{templateInline.ArgumentString}}}}}";
}
}
}

View File

@ -1,28 +0,0 @@
using Markdig;
using Microsoft.Extensions.DependencyInjection;
using OliverBooth.Common.Markdown;
using OliverBooth.Common.Services;
namespace OliverBooth.Common.Extensions;
/// <summary>
/// Extension methods for <see cref="IServiceCollection" />.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds the Markdown pipeline to the <see cref="IServiceCollection" />.
/// </summary>
/// <param name="serviceCollection">The <see cref="IServiceCollection" />.</param>
/// <returns>The <see cref="IServiceCollection" />.</returns>
public static IServiceCollection AddMarkdownPipeline(this IServiceCollection serviceCollection)
{
return serviceCollection.AddSingleton(provider => new MarkdownPipelineBuilder()
.Use(new TemplateExtension(provider.GetRequiredService<ITemplateService>()))
.UseAdvancedExtensions()
.UseBootstrap()
.UseEmojiAndSmiley()
.UseSmartyPants()
.Build());
}
}

View File

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

View File

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

View File

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

View File

@ -34,10 +34,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
@ -48,14 +44,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}

View File

@ -0,0 +1,89 @@
using Humanizer;
using Microsoft.AspNetCore.Mvc;
using OliverBooth.Data.Blog;
using OliverBooth.Services;
namespace OliverBooth.Controllers.Blog;
/// <summary>
/// Represents a controller for the blog API.
/// </summary>
[ApiController]
[Route("api/blog")]
[Produces("application/json")]
public sealed class BlogApiController : ControllerBase
{
private readonly IBlogPostService _blogPostService;
private readonly IBlogUserService _userService;
/// <summary>
/// Initializes a new instance of the <see cref="BlogApiController" /> class.
/// </summary>
/// <param name="blogPostService">The <see cref="IBlogPostService" />.</param>
/// <param name="userService">The <see cref="IUserService" />.</param>
public BlogApiController(IBlogPostService blogPostService, IBlogUserService userService)
{
_blogPostService = blogPostService;
_userService = userService;
}
[Route("count")]
public IActionResult Count()
{
return Ok(new { count = _blogPostService.GetBlogPostCount() });
}
[HttpGet("posts/{page:int?}")]
public IActionResult GetAllBlogPosts(int page = 0)
{
const int itemsPerPage = 10;
IReadOnlyList<IBlogPost> allPosts = _blogPostService.GetBlogPosts(page, itemsPerPage);
return Ok(allPosts.Select(post => CreatePostObject(post)));
}
[HttpGet("author/{id:guid}")]
public IActionResult GetAuthor(Guid id)
{
if (!_userService.TryGetUser(id, out IUser? author)) return NotFound();
return Ok(new
{
id = author.Id,
name = author.DisplayName,
avatarUrl = author.AvatarUrl,
});
}
[HttpGet("post/{id:guid?}")]
public IActionResult GetPost(Guid id)
{
if (!_blogPostService.TryGetPost(id, out IBlogPost? post)) return NotFound();
return Ok(CreatePostObject(post, true));
}
private object CreatePostObject(IBlogPost post, bool includeContent = false)
{
return new
{
id = post.Id,
commentsEnabled = post.EnableComments,
identifier = post.GetDisqusIdentifier(),
author = post.Author.Id,
title = post.Title,
published = post.Published.ToUnixTimeSeconds(),
formattedDate = post.Published.ToString("dddd, d MMMM yyyy HH:mm"),
updated = post.Updated?.ToUnixTimeSeconds(),
humanizedTimestamp = post.Updated?.Humanize() ?? post.Published.Humanize(),
excerpt = _blogPostService.RenderExcerpt(post, out bool trimmed),
content = includeContent ? _blogPostService.RenderPost(post) : null,
trimmed,
url = new
{
year = post.Published.ToString("yyyy"),
month = post.Published.ToString("MM"),
day = post.Published.ToString("dd"),
slug = post.Slug
}
};
}
}

View File

@ -1,7 +1,7 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using OliverBooth.Blog.Data.Configuration; using OliverBooth.Data.Blog.Configuration;
namespace OliverBooth.Blog.Data; namespace OliverBooth.Data.Blog;
/// <summary> /// <summary>
/// Represents a session with the blog database. /// Represents a session with the blog database.

View File

@ -1,7 +1,7 @@
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using SmartFormat; using SmartFormat;
namespace OliverBooth.Blog.Data; namespace OliverBooth.Data.Blog;
/// <inheritdoc /> /// <inheritdoc />
internal sealed class BlogPost : IBlogPost internal sealed class BlogPost : IBlogPost

View File

@ -2,7 +2,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace OliverBooth.Blog.Data.Configuration; namespace OliverBooth.Data.Blog.Configuration;
internal sealed class BlogPostConfiguration : IEntityTypeConfiguration<BlogPost> internal sealed class BlogPostConfiguration : IEntityTypeConfiguration<BlogPost>
{ {

View File

@ -1,7 +1,7 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace OliverBooth.Blog.Data.Configuration; namespace OliverBooth.Data.Blog.Configuration;
/// <summary> /// <summary>
/// Represents the configuration for the <see cref="Template" /> entity. /// Represents the configuration for the <see cref="Template" /> entity.

View File

@ -1,7 +1,7 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace OliverBooth.Blog.Data.Configuration; namespace OliverBooth.Data.Blog.Configuration;
internal sealed class UserConfiguration : IEntityTypeConfiguration<User> internal sealed class UserConfiguration : IEntityTypeConfiguration<User>
{ {

View File

@ -1,4 +1,4 @@
namespace OliverBooth.Blog.Data; namespace OliverBooth.Data.Blog;
/// <summary> /// <summary>
/// Represents the author of a blog post. /// Represents the author of a blog post.

View File

@ -1,4 +1,4 @@
namespace OliverBooth.Blog.Data; namespace OliverBooth.Data.Blog;
/// <summary> /// <summary>
/// Represents a blog post. /// Represents a blog post.

View File

@ -1,4 +1,4 @@
namespace OliverBooth.Blog.Data; namespace OliverBooth.Data.Blog;
/// <summary> /// <summary>
/// Represents a user which can log in to the blog. /// Represents a user which can log in to the blog.

View File

@ -1,6 +1,6 @@
using System.Xml.Serialization; using System.Xml.Serialization;
namespace OliverBooth.Data.Rss; namespace OliverBooth.Data.Blog.Rss;
public sealed class AtomLink public sealed class AtomLink
{ {

View File

@ -1,6 +1,6 @@
using System.Xml.Serialization; using System.Xml.Serialization;
namespace OliverBooth.Data.Rss; namespace OliverBooth.Data.Blog.Rss;
public sealed class BlogChannel public sealed class BlogChannel
{ {

View File

@ -1,6 +1,6 @@
using System.Xml.Serialization; using System.Xml.Serialization;
namespace OliverBooth.Data.Rss; namespace OliverBooth.Data.Blog.Rss;
public sealed class BlogItem public sealed class BlogItem
{ {

View File

@ -1,6 +1,6 @@
using System.Xml.Serialization; using System.Xml.Serialization;
namespace OliverBooth.Data.Rss; namespace OliverBooth.Data.Blog.Rss;
[XmlRoot("rss")] [XmlRoot("rss")]
public sealed class BlogRoot public sealed class BlogRoot

View File

@ -1,6 +1,4 @@
using OliverBooth.Common.Data; namespace OliverBooth.Data.Blog;
namespace OliverBooth.Blog.Data;
/// <summary> /// <summary>
/// Represents a MediaWiki-style template. /// Represents a MediaWiki-style template.

View File

@ -3,7 +3,7 @@ using System.Security.Cryptography;
using System.Text; using System.Text;
using Cysharp.Text; using Cysharp.Text;
namespace OliverBooth.Blog.Data; namespace OliverBooth.Data.Blog;
/// <summary> /// <summary>
/// Represents a user. /// Represents a user.

View File

@ -1,4 +1,4 @@
namespace OliverBooth.Common.Data; namespace OliverBooth.Data;
/// <summary> /// <summary>
/// Represents a template. /// Represents a template.

View File

@ -1,5 +1,3 @@
using OliverBooth.Common.Data;
namespace OliverBooth.Data.Web; namespace OliverBooth.Data.Web;
/// <summary> /// <summary>

View File

@ -1,8 +1,7 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using OliverBooth.Data.Web;
using OliverBooth.Data.Web.Configuration; using OliverBooth.Data.Web.Configuration;
namespace OliverBooth.Data; namespace OliverBooth.Data.Web;
/// <summary> /// <summary>
/// Represents a session with the web database. /// Represents a session with the web database.

View File

@ -1,8 +1,6 @@
using System.Runtime.InteropServices;
using System.Security.Cryptography.X509Certificates; using System.Security.Cryptography.X509Certificates;
using Microsoft.AspNetCore.Hosting;
namespace OliverBooth.Common.Extensions; namespace OliverBooth.Extensions;
/// <summary> /// <summary>
/// Extension methods for <see cref="IWebHostBuilder" />. /// Extension methods for <see cref="IWebHostBuilder" />.
@ -23,31 +21,35 @@ public static class WebHostBuilderExtensions
return builder.UseKestrel(options => return builder.UseKestrel(options =>
{ {
string certPath = Environment.GetEnvironmentVariable("SSL_CERT_PATH")!; string certPath = Environment.GetEnvironmentVariable("SSL_CERT_PATH")!;
if (string.IsNullOrWhiteSpace(certPath))
{
Console.WriteLine("Certificate path not specified. Using HTTP");
options.ListenAnyIP(httpPort);
return;
}
if (!File.Exists(certPath)) if (!File.Exists(certPath))
{ {
Console.Error.WriteLine("Certificate not found. Using HTTP");
options.ListenAnyIP(httpPort); options.ListenAnyIP(httpPort);
return; return;
} }
string? keyPath = Environment.GetEnvironmentVariable("SSL_KEY_PATH"); string? keyPath = Environment.GetEnvironmentVariable("SSL_KEY_PATH");
if (string.IsNullOrWhiteSpace(keyPath) || !File.Exists(keyPath)) keyPath = null; if (string.IsNullOrWhiteSpace(keyPath))
options.ListenAnyIP(httpsPort, options =>
{ {
X509Certificate2 cert = CreateCertFromPemFile(certPath, keyPath); Console.WriteLine("Certificate found, but no key provided. Using certificate only");
options.UseHttps(cert); keyPath = null;
});
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));
} }
else if (!File.Exists(keyPath))
{
Console.Error.WriteLine("Certificate found, but the provided key was not. Using certificate only");
keyPath = null;
}
Console.WriteLine($"Using HTTPS with certificate found at {certPath}:{keyPath}");
var certificate = X509Certificate2.CreateFromPemFile(certPath, keyPath);
options.ListenAnyIP(httpsPort, configure => configure.UseHttps(certificate));
}); });
} }
} }

View File

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

View File

@ -1,8 +1,7 @@
using Markdig; using Markdig;
using Microsoft.Extensions.DependencyInjection;
using SmartFormat.Core.Extensions; using SmartFormat.Core.Extensions;
namespace OliverBooth.Common.Formatting; namespace OliverBooth.Formatting;
/// <summary> /// <summary>
/// Represents a SmartFormat formatter that formats markdown. /// Represents a SmartFormat formatter that formats markdown.

View File

@ -1,8 +1,8 @@
using Markdig; using Markdig;
using Markdig.Renderers; using Markdig.Renderers;
using OliverBooth.Common.Services; using OliverBooth.Services;
namespace OliverBooth.Common.Markdown; namespace OliverBooth.Markdown.Template;
/// <summary> /// <summary>
/// Represents a Markdown extension that adds support for MediaWiki-style templates. /// Represents a Markdown extension that adds support for MediaWiki-style templates.

View File

@ -1,6 +1,6 @@
using Markdig.Syntax.Inlines; using Markdig.Syntax.Inlines;
namespace OliverBooth.Common.Markdown; namespace OliverBooth.Markdown.Template;
/// <summary> /// <summary>
/// Represents a Markdown inline element that represents a MediaWiki-style template. /// Represents a Markdown inline element that represents a MediaWiki-style template.

View File

@ -2,7 +2,7 @@ using Cysharp.Text;
using Markdig.Helpers; using Markdig.Helpers;
using Markdig.Parsers; using Markdig.Parsers;
namespace OliverBooth.Common.Markdown; namespace OliverBooth.Markdown.Template;
/// <summary> /// <summary>
/// Represents a Markdown inline parser that handles MediaWiki-style templates. /// Represents a Markdown inline parser that handles MediaWiki-style templates.

View File

@ -1,8 +1,8 @@
using Markdig.Renderers; using Markdig.Renderers;
using Markdig.Renderers.Html; using Markdig.Renderers.Html;
using OliverBooth.Common.Services; using OliverBooth.Services;
namespace OliverBooth.Common.Markdown; namespace OliverBooth.Markdown.Template;
/// <summary> /// <summary>
/// Represents a Markdown object renderer that handles <see cref="TemplateInline" /> elements. /// Represents a Markdown object renderer that handles <see cref="TemplateInline" /> elements.

View File

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

View File

@ -1,10 +1,10 @@
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.Blog.Rss;
using OliverBooth.Data.Rss; using OliverBooth.Services;
namespace OliverBooth.Blog.Middleware; namespace OliverBooth.Middleware;
internal sealed class RssMiddleware internal sealed class RssMiddleware
{ {

View File

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

View File

@ -1,6 +1,6 @@
@page "/{year:int}/{month:int}/{day:int}/{slug}" @page "/{year:int}/{month:int}/{day:int}/{slug}"
@using Humanizer @using Humanizer
@using OliverBooth.Blog.Data @using OliverBooth.Data.Blog
@model Article @model Article
@if (Model.Post is not { } post) @if (Model.Post is not { } post)
@ -47,9 +47,8 @@
} }
</p> </p>
<article> <article data-blog-post="true" data-blog-id="@post.Id.ToString("D")">
@* @Html.Raw(BlogService.GetContent(post)) *@ <p class="text-center">Loading ...</p>
@Html.Raw(post.Body)
</article> </article>
<hr> <hr>

View File

@ -1,9 +1,9 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
using OliverBooth.Blog.Data; using OliverBooth.Data.Blog;
using OliverBooth.Blog.Services; using OliverBooth.Services;
namespace OliverBooth.Blog.Pages; namespace OliverBooth.Pages.Blog;
/// <summary> /// <summary>
/// Represents the page model for the <c>Article</c> page. /// Represents the page model for the <c>Article</c> page.

View File

@ -1,9 +1,9 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
using OliverBooth.Blog.Data; using OliverBooth.Data.Blog;
using OliverBooth.Blog.Services; using OliverBooth.Services;
namespace OliverBooth.Blog.Pages; namespace OliverBooth.Pages.Blog;
[Area("blog")] [Area("blog")]
public class Index : PageModel public class Index : PageModel

View File

@ -1,10 +1,10 @@
using Cysharp.Text; using Cysharp.Text;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
using OliverBooth.Blog.Data; using OliverBooth.Data.Blog;
using OliverBooth.Blog.Services; using OliverBooth.Services;
namespace OliverBooth.Blog.Pages; namespace OliverBooth.Pages.Blog;
/// <summary> /// <summary>
/// Represents the page model for the <c>RawArticle</c> page. /// Represents the page model for the <c>RawArticle</c> page.

View File

@ -1,7 +1,9 @@
using OliverBooth.Common; using Markdig;
using OliverBooth.Common.Extensions; using OliverBooth.Data.Blog;
using OliverBooth.Common.Services; using OliverBooth.Data.Web;
using OliverBooth.Data; using OliverBooth.Extensions;
using OliverBooth.Markdown.Template;
using OliverBooth.Markdown.Timestamp;
using OliverBooth.Services; using OliverBooth.Services;
using Serilog; using Serilog;
@ -15,15 +17,28 @@ builder.Configuration.AddTomlFile("data/config.toml", true, true);
builder.Logging.ClearProviders(); builder.Logging.ClearProviders();
builder.Logging.AddSerilog(); builder.Logging.AddSerilog();
builder.Services.AddMarkdownPipeline(); builder.Services.AddSingleton(provider => new MarkdownPipelineBuilder()
builder.Services.ConfigureOptions<OliverBoothConfigureOptions>(); .Use<TimestampExtension>()
builder.Services.AddSingleton<ITemplateService, TemplateService>(); .Use(new TemplateExtension(provider.GetRequiredService<ITemplateService>()))
.UseAdvancedExtensions()
.UseBootstrap()
.UseEmojiAndSmiley()
.UseSmartyPants()
.Build());
builder.Services.AddDbContextFactory<BlogContext>();
builder.Services.AddDbContextFactory<WebContext>(); builder.Services.AddDbContextFactory<WebContext>();
builder.Services.AddSingleton<ITemplateService, TemplateService>();
builder.Services.AddSingleton<IBlogPostService, BlogPostService>();
builder.Services.AddSingleton<IBlogUserService, BlogUserService>();
builder.Services.AddRazorPages().AddRazorRuntimeCompilation(); builder.Services.AddRazorPages().AddRazorRuntimeCompilation();
builder.Services.AddControllersWithViews(); builder.Services.AddControllersWithViews();
builder.Services.AddRouting(options => options.LowercaseUrls = true); builder.Services.AddRouting(options => options.LowercaseUrls = true);
builder.WebHost.AddCertificateFromEnvironment(2845, 5049); if (builder.Environment.IsProduction())
{
builder.WebHost.AddCertificateFromEnvironment(2845, 5049);
}
WebApplication app = builder.Build(); WebApplication app = builder.Build();

View File

@ -1,8 +1,10 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Humanizer;
using Markdig;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using OliverBooth.Blog.Data; using OliverBooth.Data.Blog;
namespace OliverBooth.Blog.Services; namespace OliverBooth.Services;
/// <summary> /// <summary>
/// Represents an implementation of <see cref="IBlogPostService" />. /// Represents an implementation of <see cref="IBlogPostService" />.
@ -10,7 +12,8 @@ namespace OliverBooth.Blog.Services;
internal sealed class BlogPostService : IBlogPostService internal sealed class BlogPostService : IBlogPostService
{ {
private readonly IDbContextFactory<BlogContext> _dbContextFactory; private readonly IDbContextFactory<BlogContext> _dbContextFactory;
private readonly IUserService _userService; private readonly IBlogUserService _blogUserService;
private readonly MarkdownPipeline _markdownPipeline;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="BlogPostService" /> class. /// Initializes a new instance of the <see cref="BlogPostService" /> class.
@ -18,20 +21,35 @@ internal sealed class BlogPostService : IBlogPostService
/// <param name="dbContextFactory"> /// <param name="dbContextFactory">
/// The <see cref="IDbContextFactory{TContext}" /> used to create a <see cref="BlogContext" />. /// The <see cref="IDbContextFactory{TContext}" /> used to create a <see cref="BlogContext" />.
/// </param> /// </param>
/// <param name="userService">The <see cref="IUserService" />.</param> /// <param name="blogUserService">The <see cref="IBlogUserService" />.</param>
public BlogPostService(IDbContextFactory<BlogContext> dbContextFactory, IUserService userService) /// <param name="markdownPipeline">The <see cref="MarkdownPipeline" />.</param>
public BlogPostService(IDbContextFactory<BlogContext> dbContextFactory,
IBlogUserService blogUserService,
MarkdownPipeline markdownPipeline)
{ {
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
_userService = userService; _blogUserService = blogUserService;
_markdownPipeline = markdownPipeline;
} }
/// <inheritdoc />
public int GetBlogPostCount()
{
using BlogContext context = _dbContextFactory.CreateDbContext();
return context.BlogPosts.Count();
}
/// <inheritdoc />
public IReadOnlyList<IBlogPost> GetAllBlogPosts(int limit = -1) public IReadOnlyList<IBlogPost> GetAllBlogPosts(int limit = -1)
{ {
using BlogContext context = _dbContextFactory.CreateDbContext(); using BlogContext context = _dbContextFactory.CreateDbContext();
return context.BlogPosts IQueryable<BlogPost> ordered = context.BlogPosts.OrderByDescending(post => post.Published);
.OrderByDescending(post => post.Published) if (limit > -1)
.Take(limit) {
.AsEnumerable().Select(CacheAuthor).ToArray(); ordered = ordered.Take(limit);
}
return ordered.AsEnumerable().Select(CacheAuthor).ToArray();
} }
/// <inheritdoc /> /// <inheritdoc />
@ -42,22 +60,30 @@ internal sealed class BlogPostService : IBlogPostService
.OrderByDescending(post => post.Published) .OrderByDescending(post => post.Published)
.Skip(page * pageSize) .Skip(page * pageSize)
.Take(pageSize) .Take(pageSize)
.AsEnumerable().Select(CacheAuthor).ToArray(); .ToArray().Select(CacheAuthor).ToArray();
} }
/// <inheritdoc /> /// <inheritdoc />
public string RenderExcerpt(IBlogPost post, out bool wasTrimmed) public string RenderExcerpt(IBlogPost post, out bool wasTrimmed)
{ {
// TODO implement excerpt trimming string body = post.Body;
wasTrimmed = false; int moreIndex = body.IndexOf("<!--more-->", StringComparison.Ordinal);
return post.Body;
if (moreIndex == -1)
{
string excerpt = body.Truncate(255, "...");
wasTrimmed = body.Length > 255;
return Markdig.Markdown.ToHtml(excerpt, _markdownPipeline);
}
wasTrimmed = true;
return Markdig.Markdown.ToHtml(body[..moreIndex], _markdownPipeline);
} }
/// <inheritdoc /> /// <inheritdoc />
public string RenderPost(IBlogPost post) public string RenderPost(IBlogPost post)
{ {
// TODO render markdown return Markdig.Markdown.ToHtml(post.Body, _markdownPipeline);
return post.Body;
} }
/// <inheritdoc /> /// <inheritdoc />
@ -114,7 +140,7 @@ internal sealed class BlogPostService : IBlogPostService
return post; return post;
} }
if (_userService.TryGetUser(post.AuthorId, out IUser? user) && user is IBlogAuthor author) if (_blogUserService.TryGetUser(post.AuthorId, out IUser? user) && user is IBlogAuthor author)
{ {
post.Author = author; post.Author = author;
} }

View File

@ -1,23 +1,23 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using OliverBooth.Blog.Data; using OliverBooth.Data.Blog;
namespace OliverBooth.Blog.Services; namespace OliverBooth.Services;
/// <summary> /// <summary>
/// Represents an implementation of <see cref="IUserService" />. /// Represents an implementation of <see cref="IBlogUserService" />.
/// </summary> /// </summary>
internal sealed class UserService : IUserService internal sealed class BlogUserService : IBlogUserService
{ {
private readonly IDbContextFactory<BlogContext> _dbContextFactory; private readonly IDbContextFactory<BlogContext> _dbContextFactory;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="UserService" /> class. /// Initializes a new instance of the <see cref="BlogUserService" /> class.
/// </summary> /// </summary>
/// <param name="dbContextFactory"> /// <param name="dbContextFactory">
/// The <see cref="IDbContextFactory{TContext}" /> used to create a <see cref="BlogContext" />. /// The <see cref="IDbContextFactory{TContext}" /> used to create a <see cref="BlogContext" />.
/// </param> /// </param>
public UserService(IDbContextFactory<BlogContext> dbContextFactory) public BlogUserService(IDbContextFactory<BlogContext> dbContextFactory)
{ {
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
} }

View File

@ -1,7 +1,7 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using OliverBooth.Blog.Data; using OliverBooth.Data.Blog;
namespace OliverBooth.Blog.Services; namespace OliverBooth.Services;
/// <summary> /// <summary>
/// Represents a service for managing blog posts. /// Represents a service for managing blog posts.
@ -19,6 +19,12 @@ public interface IBlogPostService
/// </remarks> /// </remarks>
IReadOnlyList<IBlogPost> GetAllBlogPosts(int limit = -1); IReadOnlyList<IBlogPost> GetAllBlogPosts(int limit = -1);
/// <summary>
/// Returns the total number of blog posts.
/// </summary>
/// <returns>The total number of blog posts.</returns>
int GetBlogPostCount();
/// <summary> /// <summary>
/// Returns a collection of blog posts from the specified page, optionally limiting the number of posts /// Returns a collection of blog posts from the specified page, optionally limiting the number of posts
/// returned per page. /// returned per page.

View File

@ -1,12 +1,12 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using OliverBooth.Blog.Data; using OliverBooth.Data.Blog;
namespace OliverBooth.Blog.Services; namespace OliverBooth.Services;
/// <summary> /// <summary>
/// Represents a service for managing users. /// Represents a service for managing users.
/// </summary> /// </summary>
public interface IUserService public interface IBlogUserService
{ {
/// <summary> /// <summary>
/// Attempts to find a user with the specified ID. /// Attempts to find a user with the specified ID.

View File

@ -1,6 +1,6 @@
using OliverBooth.Common.Markdown; using OliverBooth.Markdown.Template;
namespace OliverBooth.Common.Services; namespace OliverBooth.Services;
/// <summary> /// <summary>
/// Represents a service that renders MediaWiki-style templates. /// Represents a service that renders MediaWiki-style templates.

View File

@ -1,10 +1,8 @@
using System.Buffers.Binary; using System.Buffers.Binary;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using OliverBooth.Common.Formatting; using OliverBooth.Data.Blog;
using OliverBooth.Common.Markdown; using OliverBooth.Formatting;
using OliverBooth.Common.Services; using OliverBooth.Markdown.Template;
using OliverBooth.Data;
using OliverBooth.Data.Web;
using SmartFormat; using SmartFormat;
using SmartFormat.Extensions; using SmartFormat.Extensions;
@ -16,15 +14,15 @@ namespace OliverBooth.Services;
internal sealed class TemplateService : ITemplateService internal sealed class TemplateService : ITemplateService
{ {
private static readonly Random Random = new(); private static readonly Random Random = new();
private readonly IDbContextFactory<WebContext> _webContextFactory; private readonly IDbContextFactory<BlogContext> _webContextFactory;
private readonly SmartFormatter _formatter; private readonly SmartFormatter _formatter;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="TemplateService" /> class. /// Initializes a new instance of the <see cref="TemplateService" /> class.
/// </summary> /// </summary>
/// <param name="serviceProvider">The <see cref="IServiceProvider" />.</param> /// <param name="serviceProvider">The <see cref="IServiceProvider" />.</param>
/// <param name="webContextFactory">The <see cref="WebContext" /> factory.</param> /// <param name="webContextFactory">The <see cref="BlogContext" /> factory.</param>
public TemplateService(IServiceProvider serviceProvider, IDbContextFactory<WebContext> webContextFactory) public TemplateService(IServiceProvider serviceProvider, IDbContextFactory<BlogContext> webContextFactory)
{ {
_formatter = Smart.CreateDefaultSmartFormat(); _formatter = Smart.CreateDefaultSmartFormat();
_formatter.AddExtensions(new DefaultSource()); _formatter.AddExtensions(new DefaultSource());
@ -40,7 +38,7 @@ internal sealed class TemplateService : ITemplateService
{ {
if (templateInline is null) throw new ArgumentNullException(nameof(templateInline)); if (templateInline is null) throw new ArgumentNullException(nameof(templateInline));
using WebContext webContext = _webContextFactory.CreateDbContext(); using BlogContext webContext = _webContextFactory.CreateDbContext();
Template? template = webContext.Templates.Find(templateInline.Name); Template? template = webContext.Templates.Find(templateInline.Name);
if (template is null) if (template is null)
{ {

View File

@ -16,20 +16,6 @@ services:
ports: ports:
- "2845:2845" - "2845:2845"
restart: always restart: always
environment:
oliverbooth-blog: - SSL_CERT_PATH=${SSL_CERT_PATH}
container_name: blog.oliverbooth.dev - SSL_KEY_PATH=${SSL_KEY_PATH}
pull_policy: build
build:
context: .
dockerfile: OliverBooth/Dockerfile
volumes:
- type: bind
source: /var/log/oliverbooth/blog
target: /app/logs
- type: bind
source: /etc/oliverbooth/blog
target: /app/data
ports:
- "2846:2846"
restart: always

View File

@ -2,7 +2,7 @@ import BlogPost from "./BlogPost";
import Author from "./Author"; import Author from "./Author";
class API { class API {
private static readonly BASE_URL: string = "https://api.oliverbooth.dev"; private static readonly BASE_URL: string = "/api";
private static readonly BLOG_URL: string = "/blog"; private static readonly BLOG_URL: string = "/blog";
static async getBlogPostCount(): Promise<number> { static async getBlogPostCount(): Promise<number> {

View File

@ -7,6 +7,10 @@ declare const katex: any;
declare const Prism: any; declare const Prism: any;
class UI { class UI {
public static get blogPost(): HTMLDivElement {
return document.querySelector("article[data-blog-post='true']");
}
public static get blogPostContainer(): HTMLDivElement { public static get blogPostContainer(): HTMLDivElement {
return document.querySelector("#all-blog-posts"); return document.querySelector("#all-blog-posts");
} }

View File

@ -34,6 +34,15 @@ declare const Prism: any;
window.open("https://www.youtube.com/watch?v=dQw4w9WgXcQ", "_blank"); window.open("https://www.youtube.com/watch?v=dQw4w9WgXcQ", "_blank");
}); });
const blogPost = UI.blogPost;
if (blogPost) {
const id = blogPost.dataset.blogId;
API.getBlogPost(id).then((post) => {
blogPost.innerHTML = post.content;
UI.updateUI(blogPost);
});
}
const blogPostContainer = UI.blogPostContainer; const blogPostContainer = UI.blogPostContainer;
if (blogPostContainer) { if (blogPostContainer) {
const authors = []; const authors = [];