Compare commits

...

2 Commits

Author SHA1 Message Date
67d89c1831
fix(blog): fix reading of url due to schema change 2023-08-13 17:35:20 +01:00
0a9c2e82d5
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
2023-08-13 17:34:38 +01:00
62 changed files with 318 additions and 409 deletions

View File

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

View File

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

@ -1,4 +1,4 @@

Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OliverBooth", "OliverBooth\OliverBooth.csproj", "{A58A6FA3-480C-400B-822A-3786741BF39C}"
EndProject
@ -32,12 +32,9 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ts", "ts", "{BB9F76AC-292A-
src\ts\TimeUtility.ts = src\ts\TimeUtility.ts
src\ts\UI.ts = src\ts\UI.ts
src\ts\Input.ts = src\ts\Input.ts
src\ts\BlogUrl.ts = src\ts\BlogUrl.ts
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OliverBooth.Blog", "OliverBooth.Blog\OliverBooth.Blog.csproj", "{B114A2ED-3015-43C5-B0CE-B755B18F49D0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OliverBooth.Common", "OliverBooth.Common\OliverBooth.Common.csproj", "{38DEB2FA-3DF4-4D37-A12D-22CAEEA3A8AB}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -48,14 +45,6 @@ Global
{A58A6FA3-480C-400B-822A-3786741BF39C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A58A6FA3-480C-400B-822A-3786741BF39C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A58A6FA3-480C-400B-822A-3786741BF39C}.Release|Any CPU.Build.0 = Release|Any CPU
{B114A2ED-3015-43C5-B0CE-B755B18F49D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B114A2ED-3015-43C5-B0CE-B755B18F49D0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B114A2ED-3015-43C5-B0CE-B755B18F49D0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B114A2ED-3015-43C5-B0CE-B755B18F49D0}.Release|Any CPU.Build.0 = Release|Any CPU
{38DEB2FA-3DF4-4D37-A12D-22CAEEA3A8AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{38DEB2FA-3DF4-4D37-A12D-22CAEEA3A8AB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{38DEB2FA-3DF4-4D37-A12D-22CAEEA3A8AB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{38DEB2FA-3DF4-4D37-A12D-22CAEEA3A8AB}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{822F528E-3CA7-4B7D-9250-BD248ADA7BAE} = {8A323E64-E41E-4780-99FD-17BF58961FB5}

View File

@ -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 OliverBooth.Blog.Data.Configuration;
using OliverBooth.Data.Blog.Configuration;
namespace OliverBooth.Blog.Data;
namespace OliverBooth.Data.Blog;
/// <summary>
/// Represents a session with the blog database.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,8 +1,6 @@
using System.Runtime.InteropServices;
using System.Security.Cryptography.X509Certificates;
using Microsoft.AspNetCore.Hosting;
namespace OliverBooth.Common.Extensions;
namespace OliverBooth.Extensions;
/// <summary>
/// Extension methods for <see cref="IWebHostBuilder" />.
@ -23,31 +21,35 @@ public static class WebHostBuilderExtensions
return builder.UseKestrel(options =>
{
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))
{
Console.Error.WriteLine("Certificate not found. Using HTTP");
options.ListenAnyIP(httpPort);
return;
}
string? keyPath = Environment.GetEnvironmentVariable("SSL_KEY_PATH");
if (string.IsNullOrWhiteSpace(keyPath) || !File.Exists(keyPath)) keyPath = null;
options.ListenAnyIP(httpsPort, options =>
if (string.IsNullOrWhiteSpace(keyPath))
{
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));
Console.WriteLine("Certificate found, but no key provided. Using certificate only");
keyPath = null;
}
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 SmartFormat.Core.Extensions;
namespace OliverBooth.Common.Formatting;
namespace OliverBooth.Formatting;
/// <summary>
/// Represents a SmartFormat formatter that formats a date.

View File

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

View File

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

View File

@ -1,6 +1,6 @@
using Markdig.Syntax.Inlines;
namespace OliverBooth.Common.Markdown;
namespace OliverBooth.Markdown.Template;
/// <summary>
/// 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.Parsers;
namespace OliverBooth.Common.Markdown;
namespace OliverBooth.Markdown.Template;
/// <summary>
/// Represents a Markdown inline parser that handles MediaWiki-style templates.

View File

@ -1,8 +1,8 @@
using Markdig.Renderers;
using Markdig.Renderers.Html;
using OliverBooth.Common.Services;
using OliverBooth.Services;
namespace OliverBooth.Common.Markdown;
namespace OliverBooth.Markdown.Template;
/// <summary>
/// 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
{

View File

@ -1,10 +1,10 @@
using System.Diagnostics.CodeAnalysis;
using System.Xml.Serialization;
using OliverBooth.Blog.Data;
using OliverBooth.Blog.Services;
using OliverBooth.Data.Rss;
using OliverBooth.Data.Blog;
using OliverBooth.Data.Blog.Rss;
using OliverBooth.Services;
namespace OliverBooth.Blog.Middleware;
namespace OliverBooth.Middleware;
internal sealed class RssMiddleware
{

View File

@ -8,7 +8,22 @@
</PropertyGroup>
<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>
</Project>

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,9 @@
using OliverBooth.Common;
using OliverBooth.Common.Extensions;
using OliverBooth.Common.Services;
using OliverBooth.Data;
using Markdig;
using OliverBooth.Data.Blog;
using OliverBooth.Data.Web;
using OliverBooth.Extensions;
using OliverBooth.Markdown.Template;
using OliverBooth.Markdown.Timestamp;
using OliverBooth.Services;
using Serilog;
@ -15,15 +17,28 @@ builder.Configuration.AddTomlFile("data/config.toml", true, true);
builder.Logging.ClearProviders();
builder.Logging.AddSerilog();
builder.Services.AddMarkdownPipeline();
builder.Services.ConfigureOptions<OliverBoothConfigureOptions>();
builder.Services.AddSingleton<ITemplateService, TemplateService>();
builder.Services.AddSingleton(provider => new MarkdownPipelineBuilder()
.Use<TimestampExtension>()
.Use(new TemplateExtension(provider.GetRequiredService<ITemplateService>()))
.UseAdvancedExtensions()
.UseBootstrap()
.UseEmojiAndSmiley()
.UseSmartyPants()
.Build());
builder.Services.AddDbContextFactory<BlogContext>();
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.AddControllersWithViews();
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();

View File

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

View File

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

View File

@ -1,7 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using OliverBooth.Blog.Data;
using OliverBooth.Data.Blog;
namespace OliverBooth.Blog.Services;
namespace OliverBooth.Services;
/// <summary>
/// Represents a service for managing blog posts.
@ -19,6 +19,12 @@ public interface IBlogPostService
/// </remarks>
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>
/// Returns a collection of blog posts from the specified page, optionally limiting the number of posts
/// returned per page.

View File

@ -1,12 +1,12 @@
using System.Diagnostics.CodeAnalysis;
using OliverBooth.Blog.Data;
using OliverBooth.Data.Blog;
namespace OliverBooth.Blog.Services;
namespace OliverBooth.Services;
/// <summary>
/// Represents a service for managing users.
/// </summary>
public interface IUserService
public interface IBlogUserService
{
/// <summary>
/// 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>
/// Represents a service that renders MediaWiki-style templates.

View File

@ -1,10 +1,8 @@
using System.Buffers.Binary;
using Microsoft.EntityFrameworkCore;
using OliverBooth.Common.Formatting;
using OliverBooth.Common.Markdown;
using OliverBooth.Common.Services;
using OliverBooth.Data;
using OliverBooth.Data.Web;
using OliverBooth.Data.Blog;
using OliverBooth.Formatting;
using OliverBooth.Markdown.Template;
using SmartFormat;
using SmartFormat.Extensions;
@ -16,15 +14,15 @@ namespace OliverBooth.Services;
internal sealed class TemplateService : ITemplateService
{
private static readonly Random Random = new();
private readonly IDbContextFactory<WebContext> _webContextFactory;
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="WebContext" /> factory.</param>
public TemplateService(IServiceProvider serviceProvider, IDbContextFactory<WebContext> webContextFactory)
/// <param name="webContextFactory">The <see cref="BlogContext" /> factory.</param>
public TemplateService(IServiceProvider serviceProvider, IDbContextFactory<BlogContext> webContextFactory)
{
_formatter = Smart.CreateDefaultSmartFormat();
_formatter.AddExtensions(new DefaultSource());
@ -40,7 +38,7 @@ internal sealed class TemplateService : ITemplateService
{
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);
if (template is null)
{

View File

@ -16,20 +16,6 @@ services:
ports:
- "2845:2845"
restart: always
oliverbooth-blog:
container_name: blog.oliverbooth.dev
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
environment:
- SSL_CERT_PATH=${SSL_CERT_PATH}
- SSL_KEY_PATH=${SSL_KEY_PATH}

View File

@ -2,7 +2,7 @@ import BlogPost from "./BlogPost";
import Author from "./Author";
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";
static async getBlogPostCount(): Promise<number> {

View File

@ -1,3 +1,5 @@
import BlogUrl from "./BlogUrl";
class BlogPost {
private readonly _id: string;
private readonly _commentsEnabled: boolean;
@ -7,7 +9,7 @@ class BlogPost {
private readonly _authorId: string;
private readonly _published: Date;
private readonly _updated?: Date;
private readonly _url: string;
private readonly _url: BlogUrl;
private readonly _trimmed: boolean;
private readonly _identifier: string;
private readonly _humanizedTimestamp: string;
@ -22,7 +24,7 @@ class BlogPost {
this._authorId = json.author;
this._published = new Date(json.published * 1000);
this._updated = (json.updated && new Date(json.updated * 1000)) || null;
this._url = json.url;
this._url = new BlogUrl(json.url);
this._trimmed = json.trimmed;
this._identifier = json.identifier;
this._humanizedTimestamp = json.humanizedTimestamp;
@ -61,7 +63,7 @@ class BlogPost {
return this._updated;
}
get url(): string {
get url(): BlogUrl {
return this._url;
}

32
src/ts/BlogUrl.ts Normal file
View File

@ -0,0 +1,32 @@
class BlogUrl {
private readonly _year: string;
private readonly _month: string;
private readonly _day: string;
private readonly _slug: string;
constructor(json: any) {
this._year = json.year;
this._month = json.month;
this._day = json.day;
this._slug = json.slug;
}
get year(): string {
return this._year;
}
get month(): string {
return this._month;
}
get day(): string {
return this._day;
}
get slug(): string {
return this._slug;
}
}
export default BlogUrl;

View File

@ -7,6 +7,10 @@ declare const katex: any;
declare const Prism: any;
class UI {
public static get blogPost(): HTMLDivElement {
return document.querySelector("article[data-blog-post='true']");
}
public static get blogPostContainer(): HTMLDivElement {
return document.querySelector("#all-blog-posts");
}
@ -44,7 +48,7 @@ class UI {
post: {
title: post.title,
excerpt: post.excerpt,
url: post.url,
url: `${post.url.year}/${post.url.month}/${post.url.day}/${post.url.slug}`,
date: TimeUtility.formatRelativeTimestamp(post.published),
formattedDate: post.formattedDate,
date_humanized: `${post.updated ? "Updated" : "Published"} ${post.humanizedTimestamp}`,

View File

@ -34,6 +34,15 @@ declare const Prism: any;
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;
if (blogPostContainer) {
const authors = [];