Compare commits

..

68 Commits

Author SHA1 Message Date
5d10f251a8
fix: move sender name to Reply-To 2024-10-20 04:07:36 +01:00
ee8a2cb569
feat: add configurable SMTP settings 2024-10-20 04:02:54 +01:00
fdf8f9c32d
[ci skip] docs: add detailed README 2024-10-18 23:11:15 +01:00
fa087a513d
style: write as first person 2024-09-17 20:46:49 +01:00
771ccc52ad
refactor: haha I'm a student now
dear god help me
2024-09-17 20:45:36 +01:00
59e42ff7cd
perf: use http utility to decode url 2024-07-15 19:44:02 +01:00
d32d46e221
fix: fix tag links 2024-07-15 19:38:56 +01:00
0bebcb69fe
fix: fix display of single-page tabs 2024-07-15 19:38:34 +01:00
d3ac89d071
refactor: use mastodon info from configuration 2024-07-05 17:56:42 +01:00
901a8347b9
refactor: move to new mastodon instance 2024-07-05 00:46:40 +01:00
8dbfeb8d38
chore: drop version in docker-compose 2024-06-16 19:29:37 +01:00
eb67c25e09
refactor: C != C++ 2024-06-16 19:16:48 +01:00
15e28bd223
feat: add keystroke class 2024-05-12 13:31:36 +01:00
acb6b32938
style: begin to organise app.scss monolith
colors now defined separately
2024-05-12 13:31:08 +01:00
cf4d92c035
fix: oops, page 2 was completely missing.
The model contains a 1-based page number, whereas GetBlogPosts wants 0-based index, causing an entire page to be missing.
2024-05-06 17:40:03 +01:00
58797b82ca
fix: don't include redirected posts in count 2024-05-06 17:39:28 +01:00
9991ecf173
Merge branch 'feature/cleanup' 2024-05-06 15:02:19 +01:00
7d21bc0b85
style: use custom post tag styling 2024-05-06 14:59:37 +01:00
c9b64cc778
style: remove border from mastodon card, reduce border-radius 2024-05-06 14:54:22 +01:00
23d3950695
chore: add launchSettings.json to make development not suck
This workflow allows the possibility to reload a changed page without reloading the entire app. I hate launchSettings.json, but whatever.
2024-05-05 21:10:23 +01:00
83beffe685
perf: remove redundant StatusCode set 2024-05-05 20:55:32 +01:00
f08a3d3607
feat: add real http status pages 2024-05-05 20:55:13 +01:00
746b4d8728
fix: don't use 0-based page index in blog index 2024-05-05 18:16:17 +01:00
435a69b27a
perf: add pagination to blog post list
removes the need for API controller accessed via JS
2024-05-05 18:13:06 +01:00
435cae95db
refactor: move Mastodon status to partial 2024-05-05 13:48:01 +01:00
2ec2c0befc
chore: move Markdig reference to Markdig extension project 2024-05-05 02:27:21 +01:00
99ff3124c3
refactor: move SmartFormat extensions to separate project 2024-05-05 02:27:04 +01:00
dec9307f1d
refactor: amend 6ec4103a3a
Remove unused Markdown classes from within core web app project
2024-05-05 02:21:40 +01:00
6ec4103a3a
refactor: separate Markdig extensions from project
Also introduces .Common project to house common references and types
2024-05-05 02:18:20 +01:00
e0037fbff2
refactor: use bootstrap's collapse functionality
they do it better than me I guess
2024-05-04 13:34:04 +01:00
ad12d6b836
style: revert 5bfe5a044d 2024-05-04 13:20:09 +01:00
5bfe5a044d
style: swap "important" and "question" callout colours 2024-05-04 13:14:23 +01:00
7ede8b13fa
feat: add support for collapsible callouts 2024-05-04 13:11:49 +01:00
29ed46eb9e
feat: add "important" callout 2024-05-04 01:10:37 +01:00
b0f0658148
style: use callouts instead of alerts site-wide 2024-05-04 00:59:02 +01:00
35a82a9663
feat: allow markdown in callout titles 2024-05-04 00:40:59 +01:00
01031057e0
feat: add support for Obsidian-style callouts 2024-05-03 23:31:47 +01:00
16618cc135
Merge branch 'feature/legacy-comments' 2024-05-01 16:47:51 +01:00
a7426b008b
feat: show legacy disqus comments beneath gisqus
No Disqus connection here. I just saved them to DB
2024-05-01 16:47:31 +01:00
217aaf2f79
fix(style): use correct styling for link <code> elements 2024-05-01 12:55:34 +01:00
b1f31f7850
fix(style): don't apply padding to full codeblock 2024-05-01 02:57:42 +01:00
818173b806
style: don't use bootstraps weird pink <code> style 2024-04-30 23:04:55 +01:00
cd304aa09b
style: hide line numbers, fix codeblock bg color 2024-04-30 22:58:35 +01:00
98c923b07b
style: hide empty section for articles with no other parts 2024-04-27 17:00:20 +01:00
91249029dc
style: remove comment count from article meta 2024-04-27 16:45:59 +01:00
55b9f79e46
style: remove comment counter from post cards 2024-04-27 16:27:08 +01:00
96e63a3088
feat: replace Disqus with gisqus
GitHub discussion integration doesn't serve ads. I'm so fucking sorry
2024-04-27 16:21:11 +01:00
1919b1d5c8
refactor: remove april fool's nonsense 2024-04-27 16:03:06 +01:00
a1dd6ef6ff
feat: add <meta> tag creation for tutorial articles 2024-04-27 15:59:39 +01:00
985acf7bc3
feat: pave the way for adding comments to tutorials 2024-04-27 15:59:05 +01:00
879ff6a295
feat: add support for excerpts on blog posts / tutorial articles 2024-04-27 15:41:19 +01:00
cd6bbec1a5
feat: add <meta> tag creation abstraction 2024-04-27 15:36:13 +01:00
b119861eee
fix: prevent anchor link from being a link 2024-04-27 00:31:11 +01:00
720b636439
Merge branch 'feature/code_snippets' 2024-04-27 00:26:04 +01:00
14cac1e38d
feat: add support for multi-lingual code snippets 2024-04-27 00:25:32 +01:00
ba09fa22df
chore: update actions/setup-dotnet@v4
Some checks failed
.NET / Build & Test (push) Failing after 14m51s
2024-04-26 18:45:35 +01:00
d527fce02c
ci: update actions/checkout@v4 2024-04-26 18:45:10 +01:00
81e1d25a7c
ci: remove github nuget source 2024-04-26 18:44:49 +01:00
e548758608
fix: don't show redirected posts
Some checks failed
.NET / Build & Test (push) Failing after 1m4s
2024-04-26 17:30:42 +01:00
0b1066c273
Merge branch 'feature/tutorials'
Some checks failed
.NET / Build & Test (push) Failing after 1m13s
2024-04-26 17:24:08 +01:00
05638e5deb
fix: hide non-published entries
Some checks failed
.NET / Build & Test (push) Failing after 44s
2024-04-26 17:23:53 +01:00
238f519e0c
feat: add hack to remove line numbers in codeblocks 2024-04-26 17:22:35 +01:00
817019ad16
fix: use EnumToStringConverter for Visibility fields 2024-02-23 17:57:08 +00:00
bd1e9dac1f
refactor: add MaxLength to folder fields 2024-02-23 17:51:22 +00:00
9074cf5210
feat: add visibility to tutorial folders and articles 2024-02-23 17:50:40 +00:00
577f3b0148
refactor: move BlogPostVisibility to Visibility
shared enum for blog posts and tutorials, and anything else that may need it
2024-02-23 16:50:43 +00:00
8629f8f963
Merge branch 'main' into feature/tutorials 2024-02-23 15:47:21 +00:00
9e0410f100
feat: add tutorials page 2024-02-20 20:36:23 +00:00
257 changed files with 4177 additions and 6847 deletions

View File

@ -10,16 +10,19 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v3
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- name: Add NuGet source
run: dotnet nuget add source --username oliverbooth --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name github "https://nuget.pkg.github.com/oliverbooth/index.json"
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore --configuration Release
- name: Test
run: dotnet test --no-build --verbosity normal

View File

@ -12,16 +12,13 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v3
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- name: Add GitHub NuGet source
run: dotnet nuget add source --username oliverbooth --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name github "https://nuget.pkg.github.com/oliverbooth/index.json"
- name: Restore dependencies
run: dotnet restore

View File

@ -12,16 +12,13 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v3
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- name: Add GitHub NuGet source
run: dotnet nuget add source --username oliverbooth --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name github "https://nuget.pkg.github.com/oliverbooth/index.json"
- name: Restore dependencies
run: dotnet restore

54
Gulpfile.js Normal file
View File

@ -0,0 +1,54 @@
const gulp = require('gulp');
const sass = require('gulp-sass')(require('sass'));
const cleanCSS = require('gulp-clean-css');
const rename = require('gulp-rename');
const ts = require('gulp-typescript');
const terser = require('gulp-terser');
const webpack = require('webpack-stream');
const srcDir = 'src';
const destDir = 'OliverBooth/wwwroot';
function compileSCSS() {
return gulp.src(`${srcDir}/scss/**/*.scss`)
.pipe(sass().on('error', sass.logError))
.pipe(cleanCSS({ compatibility: 'ie11' }))
.pipe(rename({ suffix: '.min' }))
.pipe(gulp.dest(`${destDir}/css`));
}
function compileTS() {
return gulp.src(`${srcDir}/ts/**/*.ts`)
.pipe(ts("tsconfig.json"))
.pipe(terser())
.pipe(gulp.dest(`tmp/js`));
}
function bundleJS() {
return gulp.src('tmp/js/*.js')
.pipe(webpack({ mode: 'production', output: { filename: 'app.min.js' } }))
.pipe(gulp.dest(`${destDir}/js`));
}
function copyJS() {
return gulp.src(`${srcDir}/ts/**/*.js`)
.pipe(rename({ suffix: '.min' }))
.pipe(gulp.dest(`${destDir}/js`));
}
function copyCSS() {
return gulp.src(`${srcDir}/scss/**/*.css`)
.pipe(rename({ suffix: '.min' }))
.pipe(gulp.dest(`${destDir}/css`));
}
function copyImages() {
return gulp.src(`${srcDir}/img/**/*.*`)
.pipe(gulp.dest(`${destDir}/img`));
}
exports.assets = copyImages;
exports.styles = gulp.parallel(compileSCSS, copyCSS);
exports.scripts = gulp.parallel(copyJS, gulp.series(compileTS, bundleJS));
exports.default = gulp.parallel(exports.styles, exports.scripts, exports.assets);

View File

@ -1,99 +0,0 @@
import fs from "fs";
import gulp from "gulp";
import cleanCSS from "gulp-clean-css";
import {deleteSync} from "del";
import noop from "gulp-noop";
import rename from "gulp-rename";
import gulpSass from "gulp-sass";
import * as nodeSass from "sass";
const sass = gulpSass(nodeSass);
import sourcemaps from "gulp-sourcemaps";
import ts from "gulp-typescript";
import webpack from "webpack-stream";
import vinylPaths from "vinyl-paths";
const srcDir = "src";
const tmpDir = "tmp";
const destDir = "OliverBooth/wwwroot";
const isDevelopment = !!process.env.DEVELOPMENT;
function cleanTMP() {
return gulp.src(tmpDir, {allowEmpty: true})
.pipe(vinylPaths(deleteSync));
}
function cleanWWWRoot() {
return gulp.src(destDir, {allowEmpty: true})
.pipe(vinylPaths(deleteSync));
}
function compileSCSS() {
return gulp.src(`${srcDir}/scss/**/*.scss`)
.pipe(isDevelopment ? sourcemaps.init() : noop())
.pipe(sass().on("error", sass.logError))
.pipe(cleanCSS({compatibility: "ie11"}))
.pipe(rename({suffix: ".min"}))
.pipe(isDevelopment ? sourcemaps.write() : noop())
.pipe(gulp.dest(`${destDir}/css`));
}
function compileTS() {
return gulp.src(`${srcDir}/ts/**/*.ts`)
.pipe(isDevelopment ? sourcemaps.init() : noop())
.pipe(ts("tsconfig.json"))
.pipe(isDevelopment ? sourcemaps.write("./", { includeContent: true }) : noop())
.pipe(gulp.dest(`${tmpDir}/js`));
}
function bundleJS(done) {
const tasks = fs.readdirSync(`${tmpDir}/js`, {withFileTypes: true})
.filter(dirent => dirent.isDirectory())
.map(d => bundleDir(d.name));
return gulp.parallel(...tasks, writeSourcemaps)(done);
function bundleDir(directory) {
return () => gulp.src(`${tmpDir}/js/${directory}/${directory}.js`)
.pipe(isDevelopment ? sourcemaps.init() : noop())
.pipe(webpack({mode: "production", output: {filename: `${directory}.min.js`}, devtool: "source-map"}))
.pipe(isDevelopment ? sourcemaps.write("./", { includeContent: true }) : noop())
.pipe(gulp.dest(`${destDir}/js`));
}
function writeSourcemaps() {
return gulp.src(`${destDir}/js/**/*.js`)
.pipe(isDevelopment ? sourcemaps.init({ loadMaps: true }) : noop())
.pipe(isDevelopment ? sourcemaps.write("./", { includeContent: true }) : noop())
.pipe(gulp.dest(`${destDir}/js`));
}
}
function copyJS() {
return gulp.src(`${srcDir}/ts/**/*.js`)
.pipe(rename({suffix: ".min"}))
.pipe(gulp.dest(`${destDir}/js`));
}
function copyCSS() {
return gulp.src(`${srcDir}/scss/**/*.css`)
.pipe(rename({suffix: ".min"}))
.pipe(gulp.dest(`${destDir}/css`));
}
function copyImages() {
return gulp.src(`${srcDir}/img/**/*.*`)
.pipe(gulp.dest(`${destDir}/img`));
}
function exists(path) {
try {
return fs.existsSync(path);
} catch (err) {
return false;
}
}
gulp.task("clean", gulp.parallel(cleanTMP, cleanWWWRoot));
gulp.task("assets", copyImages);
gulp.task("styles", gulp.parallel(compileSCSS, copyCSS));
gulp.task("scripts", gulp.parallel(copyJS, gulp.series(compileTS, bundleJS)));
gulp.task("default", gulp.series("clean", gulp.parallel("styles", "scripts", "assets"), cleanTMP));

View File

@ -1,22 +0,0 @@
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 80
EXPOSE 443
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["OliverBooth.Admin/OliverBooth.Admin.csproj", "OliverBooth.Admin/"]
COPY . .
WORKDIR "/src/OliverBooth.Admin"
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "OliverBooth.Admin.csproj" -c Release -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "OliverBooth.Admin.dll"]

View File

@ -1,37 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<VersionPrefix>1.0.0</VersionPrefix>
</PropertyGroup>
<PropertyGroup Condition="'$(VersionSuffix)' != '' And '$(BuildNumber)' == ''">
<Version>$(VersionPrefix)-$(VersionSuffix)</Version>
<AssemblyVersion>$(VersionPrefix).0</AssemblyVersion>
<FileVersion>$(VersionPrefix).0</FileVersion>
</PropertyGroup>
<PropertyGroup Condition="'$(VersionSuffix)' != '' And '$(BuildNumber)' != ''">
<Version>$(VersionPrefix)-$(VersionSuffix).$(BuildNumber)</Version>
<AssemblyVersion>$(VersionPrefix).$(BuildNumber)</AssemblyVersion>
<FileVersion>$(VersionPrefix).$(BuildNumber)</FileVersion>
</PropertyGroup>
<PropertyGroup Condition="'$(VersionSuffix)' == ''">
<Version>$(VersionPrefix)</Version>
<AssemblyVersion>$(VersionPrefix).0</AssemblyVersion>
<FileVersion>$(VersionPrefix).0</FileVersion>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OliverBooth.Common\OliverBooth.Common.csproj"/>
</ItemGroup>
<ItemGroup>
<None Remove="Properties\launchSettings.json" />
</ItemGroup>
</Project>

View File

@ -1,26 +0,0 @@
@page
@model ErrorModel
@{
ViewData["Title"] = "Error";
}
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (Model.ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@Model.RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to the <strong>Development</strong> environment displays detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>

View File

@ -1,26 +0,0 @@
using System.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace OliverBooth.Admin.Pages;
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
[IgnoreAntiforgeryToken]
public class ErrorModel : PageModel
{
public string? RequestId { get; set; }
public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
private readonly ILogger<ErrorModel> _logger;
public ErrorModel(ILogger<ErrorModel> logger)
{
_logger = logger;
}
public void OnGet()
{
RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
}
}

View File

@ -1,10 +0,0 @@
@page
@model IndexModel
@{
ViewData["Title"] = "Home page";
}
<div class="text-center">
<h1 class="display-4">Welcome</h1>
<p>Learn about <a href="https://learn.microsoft.com/aspnet/core">building Web apps with ASP.NET Core</a>.</p>
</div>

View File

@ -1,18 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace OliverBooth.Admin.Pages;
public class IndexModel : PageModel
{
private readonly ILogger<IndexModel> _logger;
public IndexModel(ILogger<IndexModel> logger)
{
_logger = logger;
}
public void OnGet()
{
}
}

View File

@ -1,8 +0,0 @@
@page
@model PrivacyModel
@{
ViewData["Title"] = "Privacy Policy";
}
<h1>@ViewData["Title"]</h1>
<p>Use this page to detail your site's privacy policy.</p>

View File

@ -1,18 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace OliverBooth.Admin.Pages;
public class PrivacyModel : PageModel
{
private readonly ILogger<PrivacyModel> _logger;
public PrivacyModel(ILogger<PrivacyModel> logger)
{
_logger = logger;
}
public void OnGet()
{
}
}

View File

@ -1,51 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>@ViewData["Title"] - OliverBooth.Admin</title>
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css"/>
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true"/>
<link rel="stylesheet" href="~/OliverBooth.Admin.styles.css" asp-append-version="true"/>
</head>
<body>
<header>
<nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light bg-white border-bottom box-shadow mb-3">
<div class="container">
<a class="navbar-brand" asp-area="" asp-page="/Index">OliverBooth.Admin</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse" aria-controls="navbarSupportedContent"
aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="navbar-collapse collapse d-sm-inline-flex justify-content-between">
<ul class="navbar-nav flex-grow-1">
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-page="/Index">Home</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-page="/Privacy">Privacy</a>
</li>
</ul>
</div>
</div>
</nav>
</header>
<div class="container">
<main role="main" class="pb-3">
@RenderBody()
</main>
</div>
<footer class="border-top footer text-muted">
<div class="container">
&copy; 2024 - OliverBooth.Admin - <a asp-area="" asp-page="/Privacy">Privacy</a>
</div>
</footer>
<script src="~/lib/jquery/dist/jquery.min.js"></script>
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<script src="~/js/site.js" asp-append-version="true"></script>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>

View File

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

View File

@ -1,2 +0,0 @@
<script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script>
<script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"></script>

View File

@ -1,3 +0,0 @@
@using OliverBooth.Admin
@namespace OliverBooth.Admin.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

View File

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

View File

@ -1,41 +0,0 @@
using Serilog;
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.WriteTo.File("logs/latest.log", rollingInterval: RollingInterval.Day)
#if DEBUG
.MinimumLevel.Debug()
#endif
.CreateLogger();
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddTomlFile("data/config.toml", true, true);
builder.Logging.ClearProviders();
builder.Logging.AddSerilog();
builder.Services.AddRazorPages();
builder.Services.AddControllersWithViews();
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
builder.Services.AddServerSideBlazor().AddInteractiveServerComponents();
builder.Services.AddRouting(options => options.LowercaseUrls = true);
WebApplication app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
app.MapControllers();
app.MapRazorPages();
app.MapBlazorHub();
app.Run();

View File

@ -1,22 +0,0 @@
html {
font-size: 14px;
}
@media (min-width: 768px) {
html {
font-size: 16px;
}
}
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
}
html {
position: relative;
min-height: 100%;
}
body {
margin-bottom: 60px;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

View File

@ -1,4 +0,0 @@
// Please see documentation at https://learn.microsoft.com/aspnet/core/client-side/bundling-and-minification
// for details on configuring this project to bundle and minify static web assets.
// Write your JavaScript code.

View File

@ -1,22 +0,0 @@
The MIT License (MIT)
Copyright (c) 2011-2021 Twitter, Inc.
Copyright (c) 2011-2021 The Bootstrap Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@ -1,23 +0,0 @@
The MIT License (MIT)
Copyright (c) .NET Foundation and Contributors
All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,435 +0,0 @@
/**
* @license
* Unobtrusive validation support library for jQuery and jQuery Validate
* Copyright (c) .NET Foundation. All rights reserved.
* Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
* @version v4.0.0
*/
/*jslint white: true, browser: true, onevar: true, undef: true, nomen: true, eqeqeq: true, plusplus: true, bitwise: true, regexp: true, newcap: true, immed: true, strict: false */
/*global document: false, jQuery: false */
(function (factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define("jquery.validate.unobtrusive", ['jquery-validation'], factory);
} else if (typeof module === 'object' && module.exports) {
// CommonJS-like environments that support module.exports
module.exports = factory(require('jquery-validation'));
} else {
// Browser global
jQuery.validator.unobtrusive = factory(jQuery);
}
}(function ($) {
var $jQval = $.validator,
adapters,
data_validation = "unobtrusiveValidation";
function setValidationValues(options, ruleName, value) {
options.rules[ruleName] = value;
if (options.message) {
options.messages[ruleName] = options.message;
}
}
function splitAndTrim(value) {
return value.replace(/^\s+|\s+$/g, "").split(/\s*,\s*/g);
}
function escapeAttributeValue(value) {
// As mentioned on http://api.jquery.com/category/selectors/
return value.replace(/([!"#$%&'()*+,./:;<=>?@\[\\\]^`{|}~])/g, "\\$1");
}
function getModelPrefix(fieldName) {
return fieldName.substr(0, fieldName.lastIndexOf(".") + 1);
}
function appendModelPrefix(value, prefix) {
if (value.indexOf("*.") === 0) {
value = value.replace("*.", prefix);
}
return value;
}
function onError(error, inputElement) { // 'this' is the form element
var container = $(this).find("[data-valmsg-for='" + escapeAttributeValue(inputElement[0].name) + "']"),
replaceAttrValue = container.attr("data-valmsg-replace"),
replace = replaceAttrValue ? $.parseJSON(replaceAttrValue) !== false : null;
container.removeClass("field-validation-valid").addClass("field-validation-error");
error.data("unobtrusiveContainer", container);
if (replace) {
container.empty();
error.removeClass("input-validation-error").appendTo(container);
}
else {
error.hide();
}
}
function onErrors(event, validator) { // 'this' is the form element
var container = $(this).find("[data-valmsg-summary=true]"),
list = container.find("ul");
if (list && list.length && validator.errorList.length) {
list.empty();
container.addClass("validation-summary-errors").removeClass("validation-summary-valid");
$.each(validator.errorList, function () {
$("<li />").html(this.message).appendTo(list);
});
}
}
function onSuccess(error) { // 'this' is the form element
var container = error.data("unobtrusiveContainer");
if (container) {
var replaceAttrValue = container.attr("data-valmsg-replace"),
replace = replaceAttrValue ? $.parseJSON(replaceAttrValue) : null;
container.addClass("field-validation-valid").removeClass("field-validation-error");
error.removeData("unobtrusiveContainer");
if (replace) {
container.empty();
}
}
}
function onReset(event) { // 'this' is the form element
var $form = $(this),
key = '__jquery_unobtrusive_validation_form_reset';
if ($form.data(key)) {
return;
}
// Set a flag that indicates we're currently resetting the form.
$form.data(key, true);
try {
$form.data("validator").resetForm();
} finally {
$form.removeData(key);
}
$form.find(".validation-summary-errors")
.addClass("validation-summary-valid")
.removeClass("validation-summary-errors");
$form.find(".field-validation-error")
.addClass("field-validation-valid")
.removeClass("field-validation-error")
.removeData("unobtrusiveContainer")
.find(">*") // If we were using valmsg-replace, get the underlying error
.removeData("unobtrusiveContainer");
}
function validationInfo(form) {
var $form = $(form),
result = $form.data(data_validation),
onResetProxy = $.proxy(onReset, form),
defaultOptions = $jQval.unobtrusive.options || {},
execInContext = function (name, args) {
var func = defaultOptions[name];
func && $.isFunction(func) && func.apply(form, args);
};
if (!result) {
result = {
options: { // options structure passed to jQuery Validate's validate() method
errorClass: defaultOptions.errorClass || "input-validation-error",
errorElement: defaultOptions.errorElement || "span",
errorPlacement: function () {
onError.apply(form, arguments);
execInContext("errorPlacement", arguments);
},
invalidHandler: function () {
onErrors.apply(form, arguments);
execInContext("invalidHandler", arguments);
},
messages: {},
rules: {},
success: function () {
onSuccess.apply(form, arguments);
execInContext("success", arguments);
}
},
attachValidation: function () {
$form
.off("reset." + data_validation, onResetProxy)
.on("reset." + data_validation, onResetProxy)
.validate(this.options);
},
validate: function () { // a validation function that is called by unobtrusive Ajax
$form.validate();
return $form.valid();
}
};
$form.data(data_validation, result);
}
return result;
}
$jQval.unobtrusive = {
adapters: [],
parseElement: function (element, skipAttach) {
/// <summary>
/// Parses a single HTML element for unobtrusive validation attributes.
/// </summary>
/// <param name="element" domElement="true">The HTML element to be parsed.</param>
/// <param name="skipAttach" type="Boolean">[Optional] true to skip attaching the
/// validation to the form. If parsing just this single element, you should specify true.
/// If parsing several elements, you should specify false, and manually attach the validation
/// to the form when you are finished. The default is false.</param>
var $element = $(element),
form = $element.parents("form")[0],
valInfo, rules, messages;
if (!form) { // Cannot do client-side validation without a form
return;
}
valInfo = validationInfo(form);
valInfo.options.rules[element.name] = rules = {};
valInfo.options.messages[element.name] = messages = {};
$.each(this.adapters, function () {
var prefix = "data-val-" + this.name,
message = $element.attr(prefix),
paramValues = {};
if (message !== undefined) { // Compare against undefined, because an empty message is legal (and falsy)
prefix += "-";
$.each(this.params, function () {
paramValues[this] = $element.attr(prefix + this);
});
this.adapt({
element: element,
form: form,
message: message,
params: paramValues,
rules: rules,
messages: messages
});
}
});
$.extend(rules, { "__dummy__": true });
if (!skipAttach) {
valInfo.attachValidation();
}
},
parse: function (selector) {
/// <summary>
/// Parses all the HTML elements in the specified selector. It looks for input elements decorated
/// with the [data-val=true] attribute value and enables validation according to the data-val-*
/// attribute values.
/// </summary>
/// <param name="selector" type="String">Any valid jQuery selector.</param>
// $forms includes all forms in selector's DOM hierarchy (parent, children and self) that have at least one
// element with data-val=true
var $selector = $(selector),
$forms = $selector.parents()
.addBack()
.filter("form")
.add($selector.find("form"))
.has("[data-val=true]");
$selector.find("[data-val=true]").each(function () {
$jQval.unobtrusive.parseElement(this, true);
});
$forms.each(function () {
var info = validationInfo(this);
if (info) {
info.attachValidation();
}
});
}
};
adapters = $jQval.unobtrusive.adapters;
adapters.add = function (adapterName, params, fn) {
/// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation.</summary>
/// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
/// in the data-val-nnnn HTML attribute (where nnnn is the adapter name).</param>
/// <param name="params" type="Array" optional="true">[Optional] An array of parameter names (strings) that will
/// be extracted from the data-val-nnnn-mmmm HTML attributes (where nnnn is the adapter name, and
/// mmmm is the parameter name).</param>
/// <param name="fn" type="Function">The function to call, which adapts the values from the HTML
/// attributes into jQuery Validate rules and/or messages.</param>
/// <returns type="jQuery.validator.unobtrusive.adapters" />
if (!fn) { // Called with no params, just a function
fn = params;
params = [];
}
this.push({ name: adapterName, params: params, adapt: fn });
return this;
};
adapters.addBool = function (adapterName, ruleName) {
/// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation, where
/// the jQuery Validate validation rule has no parameter values.</summary>
/// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
/// in the data-val-nnnn HTML attribute (where nnnn is the adapter name).</param>
/// <param name="ruleName" type="String" optional="true">[Optional] The name of the jQuery Validate rule. If not provided, the value
/// of adapterName will be used instead.</param>
/// <returns type="jQuery.validator.unobtrusive.adapters" />
return this.add(adapterName, function (options) {
setValidationValues(options, ruleName || adapterName, true);
});
};
adapters.addMinMax = function (adapterName, minRuleName, maxRuleName, minMaxRuleName, minAttribute, maxAttribute) {
/// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation, where
/// the jQuery Validate validation has three potential rules (one for min-only, one for max-only, and
/// one for min-and-max). The HTML parameters are expected to be named -min and -max.</summary>
/// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
/// in the data-val-nnnn HTML attribute (where nnnn is the adapter name).</param>
/// <param name="minRuleName" type="String">The name of the jQuery Validate rule to be used when you only
/// have a minimum value.</param>
/// <param name="maxRuleName" type="String">The name of the jQuery Validate rule to be used when you only
/// have a maximum value.</param>
/// <param name="minMaxRuleName" type="String">The name of the jQuery Validate rule to be used when you
/// have both a minimum and maximum value.</param>
/// <param name="minAttribute" type="String" optional="true">[Optional] The name of the HTML attribute that
/// contains the minimum value. The default is "min".</param>
/// <param name="maxAttribute" type="String" optional="true">[Optional] The name of the HTML attribute that
/// contains the maximum value. The default is "max".</param>
/// <returns type="jQuery.validator.unobtrusive.adapters" />
return this.add(adapterName, [minAttribute || "min", maxAttribute || "max"], function (options) {
var min = options.params.min,
max = options.params.max;
if (min && max) {
setValidationValues(options, minMaxRuleName, [min, max]);
}
else if (min) {
setValidationValues(options, minRuleName, min);
}
else if (max) {
setValidationValues(options, maxRuleName, max);
}
});
};
adapters.addSingleVal = function (adapterName, attribute, ruleName) {
/// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation, where
/// the jQuery Validate validation rule has a single value.</summary>
/// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
/// in the data-val-nnnn HTML attribute(where nnnn is the adapter name).</param>
/// <param name="attribute" type="String">[Optional] The name of the HTML attribute that contains the value.
/// The default is "val".</param>
/// <param name="ruleName" type="String" optional="true">[Optional] The name of the jQuery Validate rule. If not provided, the value
/// of adapterName will be used instead.</param>
/// <returns type="jQuery.validator.unobtrusive.adapters" />
return this.add(adapterName, [attribute || "val"], function (options) {
setValidationValues(options, ruleName || adapterName, options.params[attribute]);
});
};
$jQval.addMethod("__dummy__", function (value, element, params) {
return true;
});
$jQval.addMethod("regex", function (value, element, params) {
var match;
if (this.optional(element)) {
return true;
}
match = new RegExp(params).exec(value);
return (match && (match.index === 0) && (match[0].length === value.length));
});
$jQval.addMethod("nonalphamin", function (value, element, nonalphamin) {
var match;
if (nonalphamin) {
match = value.match(/\W/g);
match = match && match.length >= nonalphamin;
}
return match;
});
if ($jQval.methods.extension) {
adapters.addSingleVal("accept", "mimtype");
adapters.addSingleVal("extension", "extension");
} else {
// for backward compatibility, when the 'extension' validation method does not exist, such as with versions
// of JQuery Validation plugin prior to 1.10, we should use the 'accept' method for
// validating the extension, and ignore mime-type validations as they are not supported.
adapters.addSingleVal("extension", "extension", "accept");
}
adapters.addSingleVal("regex", "pattern");
adapters.addBool("creditcard").addBool("date").addBool("digits").addBool("email").addBool("number").addBool("url");
adapters.addMinMax("length", "minlength", "maxlength", "rangelength").addMinMax("range", "min", "max", "range");
adapters.addMinMax("minlength", "minlength").addMinMax("maxlength", "minlength", "maxlength");
adapters.add("equalto", ["other"], function (options) {
var prefix = getModelPrefix(options.element.name),
other = options.params.other,
fullOtherName = appendModelPrefix(other, prefix),
element = $(options.form).find(":input").filter("[name='" + escapeAttributeValue(fullOtherName) + "']")[0];
setValidationValues(options, "equalTo", element);
});
adapters.add("required", function (options) {
// jQuery Validate equates "required" with "mandatory" for checkbox elements
if (options.element.tagName.toUpperCase() !== "INPUT" || options.element.type.toUpperCase() !== "CHECKBOX") {
setValidationValues(options, "required", true);
}
});
adapters.add("remote", ["url", "type", "additionalfields"], function (options) {
var value = {
url: options.params.url,
type: options.params.type || "GET",
data: {}
},
prefix = getModelPrefix(options.element.name);
$.each(splitAndTrim(options.params.additionalfields || options.element.name), function (i, fieldName) {
var paramName = appendModelPrefix(fieldName, prefix);
value.data[paramName] = function () {
var field = $(options.form).find(":input").filter("[name='" + escapeAttributeValue(paramName) + "']");
// For checkboxes and radio buttons, only pick up values from checked fields.
if (field.is(":checkbox")) {
return field.filter(":checked").val() || field.filter(":hidden").val() || '';
}
else if (field.is(":radio")) {
return field.filter(":checked").val() || '';
}
return field.val();
};
});
setValidationValues(options, "remote", value);
});
adapters.add("password", ["min", "nonalphamin", "regex"], function (options) {
if (options.params.min) {
setValidationValues(options, "minlength", options.params.min);
}
if (options.params.nonalphamin) {
setValidationValues(options, "nonalphamin", options.params.nonalphamin);
}
if (options.params.regex) {
setValidationValues(options, "regex", options.params.regex);
}
});
adapters.add("fileextensions", ["extensions"], function (options) {
setValidationValues(options, "extension", options.params.extensions);
});
$(function () {
$jQval.unobtrusive.parse(document);
});
return $jQval.unobtrusive;
}));

File diff suppressed because one or more lines are too long

View File

@ -1,22 +0,0 @@
The MIT License (MIT)
=====================
Copyright Jörn Zaefferer
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@ -1,21 +0,0 @@
Copyright OpenJS Foundation and other contributors, https://openjsf.org/
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@ -1,40 +0,0 @@
using Asp.Versioning.ApiExplorer;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace OliverBooth.Api;
internal sealed class ConfigureSwaggerOptions(IApiVersionDescriptionProvider provider)
: IConfigureNamedOptions<SwaggerGenOptions>
{
public void Configure(SwaggerGenOptions options)
{
foreach (var description in provider.ApiVersionDescriptions)
{
options.SwaggerDoc(description.GroupName, CreateVersionInfo(description));
}
}
public void Configure(string? name, SwaggerGenOptions options)
{
Configure(options);
}
private OpenApiInfo CreateVersionInfo(
ApiVersionDescription description)
{
var info = new OpenApiInfo
{
Title = "api.oliverbooth.dev",
Version = description.ApiVersion.ToString()
};
if (description.IsDeprecated)
{
info.Description += " This API version has been deprecated.";
}
return info;
}
}

View File

@ -1,112 +0,0 @@
using System.Net.Http.Headers;
using System.Reflection;
using System.Text.Json.Serialization;
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
namespace OliverBooth.Api.Controllers.v1;
[ApiController]
[Route("v{version:apiVersion}/badge")]
[Produces("application/json")]
[ApiVersion(1)]
public sealed class BadgeController : ControllerBase
{
private readonly IConfiguration _configuration;
private readonly IHttpClientFactory _httpClientFactory;
private readonly string _version;
/// <summary>
/// Initializes a new instance of the <see cref="BadgeController" /> class.
/// </summary>
/// <param name="configuration">The configuration.</param>
/// <param name="httpClientFactory">The HTTP client factory.</param>
public BadgeController(IConfiguration configuration, IHttpClientFactory httpClientFactory)
{
_configuration = configuration;
_httpClientFactory = httpClientFactory;
var attribute = typeof(Program).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
_version = attribute?.InformationalVersion ?? "1.0.0";
}
/// <summary>
/// Returns a JSON object that is compatible with a shields.io custom endpoint.
/// </summary>
/// <param name="repo">The repository name.</param>
/// <param name="workflow">The workflow.</param>
/// <param name="owner">The owner. Defaults to <c>oliverbooth</c>.</param>
/// <returns>A JSON object.</returns>
[HttpGet("status/{repo}/{workflow}")]
[HttpGet("status/{owner}/{repo}/{workflow}")]
[EndpointDescription("Returns a JSON object that is compatible with a shields.io custom endpoint.")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> StatusAsync(string repo, string workflow, string owner = "oliverbooth")
{
string? githubToken = _configuration.GetSection("GitHub:Token").Value;
var url = $"https://api.github.com/repos/{owner}/{repo}/actions/workflows/{workflow}/runs";
Console.WriteLine(url);
using HttpClient client = _httpClientFactory.CreateClient();
using var request = new HttpRequestMessage();
request.RequestUri = new Uri(url);
request.Headers.Add("Accept", "application/json");
request.Headers.Add("Authorization", $"Bearer {githubToken}");
request.Headers.Add("X-GitHub-Api-Version", "2022-11-28");
request.Headers.UserAgent.Add(new ProductInfoHeaderValue("oliverbooth.dev", _version));
using HttpResponseMessage response = await client.SendAsync(request);
Console.WriteLine(await response.Content.ReadAsStringAsync());
var body = await response.Content.ReadFromJsonAsync<WorkflowRunSchema>();
WorkflowRun? run = body?.WorkflowRuns.FirstOrDefault(r => r.Status == WorkflowRunStatus.Completed);
if (run is not null)
{
var color = run.Conclusion switch
{
WorkflowRunConclusion.Failure => "e05d44",
WorkflowRunConclusion.Success => "44cc11",
_ => "lightgray"
};
var message = run.Conclusion switch
{
WorkflowRunConclusion.Failure => "failing",
WorkflowRunConclusion.Success => "passing",
_ => "unknown"
};
return Ok(new { schemaVersion = 1, label = "build", color, message });
}
return Ok(new { schemaVersion = 1, label = "build", color = "lightgray", message = "unknown" });
}
private class WorkflowRunSchema
{
[JsonPropertyName("workflow_runs"), JsonInclude]
public WorkflowRun[] WorkflowRuns { get; set; } = Array.Empty<WorkflowRun>();
}
private class WorkflowRun
{
[JsonPropertyName("conclusion"), JsonInclude]
[JsonConverter(typeof(JsonStringEnumConverter<WorkflowRunConclusion>))]
public WorkflowRunConclusion Conclusion { get; set; } = WorkflowRunConclusion.Unknown;
[JsonPropertyName("status"), JsonInclude]
[JsonConverter(typeof(JsonStringEnumConverter<WorkflowRunStatus>))]
public WorkflowRunStatus Status { get; set; } = WorkflowRunStatus.Unknown;
}
private enum WorkflowRunStatus
{
Unknown = -1,
Completed
}
private enum WorkflowRunConclusion
{
Unknown = -1,
Success,
Failure
}
}

View File

@ -1,141 +0,0 @@
using Asp.Versioning;
using Humanizer;
using Microsoft.AspNetCore.Mvc;
using OliverBooth.Api.Data;
using OliverBooth.Common.Data.Blog;
using OliverBooth.Common.Data.Web.Users;
using OliverBooth.Common.Services;
namespace OliverBooth.Api.Controllers.v1.Blog;
[ApiController]
[Route("blog")]
[Produces("application/json")]
[ApiVersion(1)]
[Obsolete("API v1 is deprecated and will be removed in future. Use /v2")]
public sealed class BlogController : ControllerBase
{
private readonly IBlogPostService _blogPostService;
private readonly IUserService _userService;
/// <summary>
/// Initializes a new instance of the <see cref="BlogController" /> class.
/// </summary>
/// <param name="blogPostService">The <see cref="IBlogPostService" />.</param>
/// <param name="userService">The <see cref="IUserService" />.</param>
public BlogController(IBlogPostService blogPostService, IUserService userService)
{
_blogPostService = blogPostService;
_userService = userService;
}
/// <summary>
/// Returns the number of publicly published blog posts.
/// </summary>
/// <returns>The number of publicly published blog posts.</returns>
[HttpGet("count")]
[EndpointDescription("Returns the number of publicly published blog posts.")]
[ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult Count()
{
return Ok(new { count = _blogPostService.GetBlogPostCount() });
}
/// <summary>
/// Returns a collection of all blog posts on the specified page.
/// </summary>
/// <param name="page">The page number.</param>
/// <returns>An array of <see cref="IBlogPost" /> objects.</returns>
[HttpGet("posts/{page:int?}")]
[EndpointDescription("Returns a collection of all blog posts on the specified page.")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(BlogPost[]))]
public IActionResult GetAllBlogPosts(int page = 0)
{
const int itemsPerPage = 10;
IReadOnlyList<IBlogPost> allPosts = _blogPostService.GetBlogPosts(page, itemsPerPage);
return Ok(allPosts.Select(post => CreatePostObject(post)));
}
/// <summary>
/// Returns a collection of all blog posts which contain the specified tag on the specified page.
/// </summary>
/// <param name="tag">The tag for which to search.</param>
/// <param name="page">The page number.</param>
/// <returns>An array of <see cref="IBlogPost" /> objects.</returns>
[HttpGet("posts/tagged/{tag}/{page:int?}")]
[EndpointDescription("Returns a collection of all blog posts which contain the specified tag on the specified page.")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(BlogPost[]))]
public IActionResult GetTaggedBlogPosts(string tag, int page = 0)
{
const int itemsPerPage = 10;
tag = tag.Replace('-', ' ').ToLowerInvariant();
IReadOnlyList<IBlogPost> allPosts = _blogPostService.GetBlogPosts(page, itemsPerPage);
allPosts = allPosts.Where(post => post.Tags.Contains(tag)).ToList();
return Ok(allPosts.Select(post => CreatePostObject(post)));
}
/// <summary>
/// Returns an object representing the author with the specified ID.
/// </summary>
/// <param name="id">The ID of the author.</param>
/// <returns>An object representing the author.</returns>
[HttpGet("author/{id:guid}")]
[EndpointDescription("Returns an object representing the author with the specified ID.")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Author))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
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,
});
}
/// <summary>
/// Returns an object representing the blog post with the specified ID.
/// </summary>
/// <param name="id">The ID of the blog post.</param>
/// <returns>An object representing the blog post.</returns>
[HttpGet("post/{id:guid?}")]
[EndpointDescription("Returns an object representing the blog post with the specified ID.")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(BlogPost))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
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(),
updated = post.Updated?.ToUnixTimeSeconds(),
formattedPublishDate = post.Published.ToString("dddd, d MMMM yyyy HH:mm"),
formattedUpdateDate = post.Updated?.ToString("dddd, d MMMM yyyy HH:mm"),
humanizedTimestamp = post.Updated?.Humanize() ?? post.Published.Humanize(),
excerpt = _blogPostService.RenderExcerpt(post, out bool trimmed),
content = includeContent ? _blogPostService.RenderPost(post) : null,
trimmed,
tags = post.Tags.Select(t => t.Replace(' ', '-')),
url = new
{
year = post.Published.ToString("yyyy"),
month = post.Published.ToString("MM"),
day = post.Published.ToString("dd"),
slug = post.Slug
}
};
}
}

View File

@ -1,47 +0,0 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
using OliverBooth.Api.Data;
using OliverBooth.Common.Data.Web.Users;
using OliverBooth.Common.Services;
namespace OliverBooth.Api.Controllers.v2.Blog;
/// <summary>
/// Represents an API controller which allows reading authors of blog posts.
/// </summary>
[ApiController]
[Route("v{version:apiVersion}/blog/author")]
[Produces("application/json")]
[ApiVersion(2)]
public sealed class AuthorController : ControllerBase
{
private readonly IUserService _userService;
/// <summary>
/// Initializes a new instance of the <see cref="AuthorController" /> class.
/// </summary>
/// <param name="userService">The <see cref="IUserService" />.</param>
public AuthorController(IUserService userService)
{
_userService = userService;
}
/// <summary>
/// Returns an object representing the author with the specified ID.
/// </summary>
/// <param name="id">The ID of the author.</param>
/// <returns>An object representing the author.</returns>
[HttpGet("{id:guid}")]
[EndpointDescription("Returns an object representing the author with the specified ID.")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Author))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public IActionResult GetAuthor(Guid id)
{
if (!_userService.TryGetUser(id, out IUser? author))
{
return NotFound();
}
return Ok(Author.FromUser(author));
}
}

View File

@ -1,92 +0,0 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
using OliverBooth.Api.Data;
using OliverBooth.Common.Data.Blog;
using OliverBooth.Common.Services;
namespace OliverBooth.Api.Controllers.v2.Blog;
/// <summary>
/// Represents an API controller which allows reading and writing of blog posts.
/// </summary>
[ApiController]
[Route("v{version:apiVersion}/blog/post")]
[Produces("application/json")]
[ApiVersion(2)]
public sealed class PostController : ControllerBase
{
private const int ItemsPerPage = 10;
private readonly IBlogPostService _blogPostService;
/// <summary>
/// Initializes a new instance of the <see cref="PostController" /> class.
/// </summary>
/// <param name="blogPostService">The <see cref="IBlogPostService" />.</param>
public PostController(IBlogPostService blogPostService)
{
_blogPostService = blogPostService;
}
/// <summary>
/// Returns a collection of all blog posts on the specified page.
/// </summary>
/// <param name="page">The page number.</param>
/// <returns>An array of <see cref="IBlogPost" /> objects.</returns>
[HttpGet("all/{page:int?}")]
[EndpointDescription("Returns a collection of all blog posts on the specified page.")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(BlogPost[]))]
public IActionResult All(int page = 0)
{
IReadOnlyList<IBlogPost> allPosts = _blogPostService.GetBlogPosts(page, ItemsPerPage);
return Ok(allPosts.Select(post => BlogPost.FromBlogPost(post, _blogPostService)));
}
/// <summary>
/// Returns the number of publicly published blog posts.
/// </summary>
/// <returns>The number of publicly published blog posts.</returns>
[HttpGet("count")]
[EndpointDescription("Returns the number of publicly published blog posts.")]
[ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult Count()
{
return Ok(new { count = _blogPostService.GetBlogPostCount() });
}
/// <summary>
/// Returns an object representing the blog post with the specified ID.
/// </summary>
/// <param name="id">The ID of the blog post.</param>
/// <returns>An object representing the blog post.</returns>
[HttpGet("{id:guid}")]
[EndpointDescription("Returns an object representing the blog post with the specified ID.")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(BlogPost))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public IActionResult GetPost(Guid id)
{
if (!_blogPostService.TryGetPost(id, out IBlogPost? post))
{
return NotFound();
}
return Ok(BlogPost.FromBlogPost(post, _blogPostService, true));
}
/// <summary>
/// Returns a collection of all blog posts which contain the specified tag on the specified page.
/// </summary>
/// <param name="tag">The tag for which to search.</param>
/// <param name="page">The page number.</param>
/// <returns>An array of <see cref="IBlogPost" /> objects.</returns>
[HttpGet("tagged/{tag}/{page:int?}")]
[EndpointDescription("Returns a collection of all blog posts which contain the specified tag on the specified page.")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(BlogPost[]))]
public IActionResult Tagged(string tag, int page = 0)
{
tag = tag.Replace('-', ' ').ToLowerInvariant();
IReadOnlyList<IBlogPost> allPosts = _blogPostService.GetBlogPosts(page, ItemsPerPage);
allPosts = allPosts.Where(post => post.Tags.Contains(tag)).ToList();
return Ok(allPosts.Select(post => BlogPost.FromBlogPost(post, _blogPostService)));
}
}

View File

@ -1,26 +0,0 @@
using System.Text.Json.Serialization;
using OliverBooth.Common.Data.Web.Users;
namespace OliverBooth.Api.Data;
internal sealed class Author
{
[JsonPropertyName("avatarUrl"), JsonInclude, JsonPropertyOrder(2)]
public Uri AvatarUrl { get; private set; } = null!;
[JsonPropertyName("id"), JsonInclude, JsonPropertyOrder(0)]
public Guid Id { get; private set; }
[JsonPropertyName("name"), JsonInclude, JsonPropertyOrder(1)]
public string Name { get; private set; } = string.Empty;
public static Author FromUser(IUser author)
{
return new Author
{
Id = author.Id,
Name = author.DisplayName,
AvatarUrl = author.AvatarUrl
};
}
}

View File

@ -1,83 +0,0 @@
using System.Text.Json.Serialization;
using Humanizer;
using OliverBooth.Common.Data.Blog;
using OliverBooth.Common.Services;
namespace OliverBooth.Api.Data;
internal sealed class BlogPost
{
[JsonPropertyName("author"), JsonInclude, JsonPropertyOrder(3)]
public Guid Author { get; private set; }
[JsonPropertyName("commentsEnabled"), JsonInclude, JsonPropertyOrder(1)]
public bool CommentsEnabled { get; private set; }
[JsonPropertyName("content"), JsonInclude, JsonPropertyOrder(11)]
public string? Content { get; private set; }
[JsonPropertyName("excerpt"), JsonInclude, JsonPropertyOrder(10)]
public string Excerpt { get; private set; } = string.Empty;
[JsonPropertyName("formattedPublishDate"), JsonInclude, JsonPropertyOrder(7)]
public string FormattedPublishDate { get; private set; } = string.Empty;
[JsonPropertyName("formattedUpdateDate"), JsonInclude, JsonPropertyOrder(8)]
public string? FormattedUpdateDate { get; private set; }
[JsonPropertyName("humanizedTimestamp"), JsonInclude, JsonPropertyOrder(9)]
public string HumanizedTimestamp { get; private set; } = string.Empty;
[JsonPropertyName("id"), JsonInclude, JsonPropertyOrder(0)]
public Guid Id { get; private set; }
[JsonPropertyName("identifier"), JsonInclude, JsonPropertyOrder(2)]
public string Identifier { get; private set; } = string.Empty;
[JsonPropertyName("trimmed"), JsonInclude, JsonPropertyOrder(12)]
public bool IsTrimmed { get; private set; }
[JsonPropertyName("published"), JsonInclude, JsonPropertyOrder(5)]
public long Published { get; private set; }
[JsonPropertyName("tags"), JsonInclude, JsonPropertyOrder(13)]
public IEnumerable<string> Tags { get; private set; } = ArraySegment<string>.Empty;
[JsonPropertyName("title"), JsonInclude, JsonPropertyOrder(4)]
public string Title { get; private set; } = string.Empty;
[JsonPropertyName("updated"), JsonInclude, JsonPropertyOrder(6)]
public long? Updated { get; private set; }
[JsonPropertyName("url"), JsonInclude, JsonPropertyOrder(14)]
public object Url { get; private set; } = null!;
public static BlogPost FromBlogPost(IBlogPost post, IBlogPostService blogPostService,
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(),
Updated = post.Updated?.ToUnixTimeSeconds(),
FormattedPublishDate = post.Published.ToString("dddd, d MMMM yyyy HH:mm"),
FormattedUpdateDate = post.Updated?.ToString("dddd, d MMMM yyyy HH:mm"),
HumanizedTimestamp = post.Updated?.Humanize() ?? post.Published.Humanize(),
Excerpt = blogPostService.RenderExcerpt(post, out bool trimmed),
Content = includeContent ? blogPostService.RenderPost(post) : null,
IsTrimmed = trimmed,
Tags = post.Tags.Select(t => t.Replace(' ', '-')),
Url = new
{
year = post.Published.ToString("yyyy"),
month = post.Published.ToString("MM"),
day = post.Published.ToString("dd"),
slug = post.Slug
}
};
}
}

View File

@ -1,22 +0,0 @@
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 80
EXPOSE 443
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["OliverBooth.Api/OliverBooth.Api.csproj", "OliverBooth.Api/"]
COPY . .
WORKDIR "/src/OliverBooth.Api"
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "OliverBooth.Api.csproj" -c Release -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "OliverBooth.Api.dll"]

View File

@ -1,38 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<VersionPrefix>2.0.0</VersionPrefix>
</PropertyGroup>
<PropertyGroup Condition="'$(VersionSuffix)' != '' And '$(BuildNumber)' == ''">
<Version>$(VersionPrefix)-$(VersionSuffix)</Version>
<AssemblyVersion>$(VersionPrefix).0</AssemblyVersion>
<FileVersion>$(VersionPrefix).0</FileVersion>
</PropertyGroup>
<PropertyGroup Condition="'$(VersionSuffix)' != '' And '$(BuildNumber)' != ''">
<Version>$(VersionPrefix)-$(VersionSuffix).$(BuildNumber)</Version>
<AssemblyVersion>$(VersionPrefix).$(BuildNumber)</AssemblyVersion>
<FileVersion>$(VersionPrefix).$(BuildNumber)</FileVersion>
</PropertyGroup>
<PropertyGroup Condition="'$(VersionSuffix)' == ''">
<Version>$(VersionPrefix)</Version>
<AssemblyVersion>$(VersionPrefix).0</AssemblyVersion>
<FileVersion>$(VersionPrefix).0</FileVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.0.0"/>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.2"/>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OliverBooth.Common\OliverBooth.Common.csproj"/>
</ItemGroup>
</Project>

View File

@ -1,7 +0,0 @@
@OliverBooth.Api_HostAddress = https://localhost:2844
@TestPostId = bdcd9789-f0a6-4721-a66d-b6f7e2acd0f7
GET {{OliverBooth.Api_HostAddress}}/v1/blog/post/{{TestPostId}}
Accept: application/json
###

View File

@ -1,84 +0,0 @@
using Asp.Versioning;
using Asp.Versioning.ApiExplorer;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using OliverBooth.Api;
using OliverBooth.Common.Extensions;
using Serilog;
using Swashbuckle.AspNetCore.SwaggerGen;
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.WriteTo.File("logs/latest.log", rollingInterval: RollingInterval.Day)
#if DEBUG
.MinimumLevel.Debug()
#endif
.CreateLogger();
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddTomlFile("data/config.toml", true, true);
builder.Logging.ClearProviders();
builder.Logging.AddSerilog();
builder.Services.AddCors(options =>
{
options.AddPolicy("localhost", policy => policy.WithOrigins("https://localhost:2843", "https://localhost:2845"));
options.AddPolicy("site", policy => policy.WithOrigins("https://admin.oliverbooth.dev", "https://oliverbooth.dev"));
});
builder.Services.AddHttpClient();
builder.Services.AddCommonServices();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options => options.ResolveConflictingActions(resolver =>
{
foreach (ApiDescription description in resolver)
{
if (description.GetApiVersion()?.MajorVersion == 2)
{
return description;
}
}
return null;
}));
builder.Services.AddControllers();
builder.Services.AddRouting(options => options.LowercaseUrls = true);
builder.Services.AddApiVersioning(options =>
{
options.AssumeDefaultVersionWhenUnspecified = true;
options.DefaultApiVersion = new ApiVersion(2);
options.ReportApiVersions = true;
options.ApiVersionReader = new UrlSegmentApiVersionReader();
}).AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
});
builder.Services.ConfigureOptions<ConfigureSwaggerOptions>();
if (builder.Environment.IsProduction())
{
builder.WebHost.AddCertificateFromEnvironment(2844, 5048);
}
WebApplication app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI(options =>
{
var provider = app.Services.GetRequiredService<IApiVersionDescriptionProvider>();
foreach (ApiVersionDescription description in provider.ApiVersionDescriptions)
{
var url = $"/swagger/{description.GroupName}/swagger.json";
options.SwaggerEndpoint(url, description.GroupName.ToUpperInvariant());
}
});
}
app.UseHttpsRedirection();
app.MapControllers();
app.UseCors(app.Environment.IsDevelopment() ? "localhost" : "site");
app.Run();

View File

@ -1,126 +0,0 @@
using System.ComponentModel.DataAnnotations.Schema;
using SmartFormat;
namespace OliverBooth.Common.Data.Blog;
/// <inheritdoc />
internal sealed class BlogPostDraft : IBlogPostDraft
{
/// <inheritdoc />
[NotMapped]
public IAuthor Author { get; internal set; } = null!;
/// <inheritdoc />
public string Body { get; 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 string? Password { get; internal set; }
/// <inheritdoc />
public Uri? RedirectUrl { get; internal set; }
/// <inheritdoc />
public string Slug { get; internal set; } = string.Empty;
/// <inheritdoc />
public IReadOnlyList<string> Tags { get; internal set; } = ArraySegment<string>.Empty;
/// <inheritdoc />
public string Title { get; set; } = string.Empty;
/// <inheritdoc />
public DateTimeOffset Updated { get; internal set; }
/// <inheritdoc />
public BlogPostVisibility Visibility { get; internal set; }
/// <inheritdoc />
public int? WordPressId { get; 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>
/// Constructs a <see cref="BlogPostDraft" /> by copying values from an existing <see cref="BlogPost" />.
/// </summary>
/// <param name="post">The existing <see cref="BlogPost" />.</param>
/// <returns>The newly-constructed <see cref="BlogPostDraft" />.</returns>
/// <exception cref="ArgumentNullException"><paramref name="post" /> is <see langword="null" />.</exception>
public static BlogPostDraft CreateFromBlogPost(BlogPost post)
{
if (post is null)
{
throw new ArgumentNullException(nameof(post));
}
return new BlogPostDraft
{
AuthorId = post.AuthorId,
Body = post.Body,
DisqusDomain = post.DisqusDomain,
DisqusIdentifier = post.DisqusIdentifier,
DisqusPath = post.DisqusPath,
EnableComments = post.EnableComments,
IsRedirect = post.IsRedirect,
Password = post.Password,
RedirectUrl = post.RedirectUrl,
Tags = post.Tags,
Title = post.Title,
Visibility = post.Visibility,
WordPressId = post.WordPressId
};
}
/// <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 GetDisqusPostId()
{
return WordPressId?.ToString() ?? Id.ToString();
}
}

View File

@ -1,36 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace OliverBooth.Common.Data.Blog.Configuration;
internal sealed class BlogPostDraftConfiguration : IEntityTypeConfiguration<BlogPostDraft>
{
/// <inheritdoc />
public void Configure(EntityTypeBuilder<BlogPostDraft> builder)
{
builder.ToTable("BlogPostDraft");
builder.HasKey(e => new { e.Id, e.Updated });
builder.Property(e => e.Id);
builder.Property(e => e.Updated).IsRequired();
builder.Property(e => e.WordPressId).IsRequired(false);
builder.Property(e => e.Slug).HasMaxLength(100).IsRequired();
builder.Property(e => e.AuthorId).IsRequired();
builder.Property(e => e.Title).HasMaxLength(255).IsRequired();
builder.Property(e => e.Body).IsRequired();
builder.Property(e => e.IsRedirect).IsRequired();
builder.Property(e => e.RedirectUrl).HasConversion<UriToStringConverter>().HasMaxLength(255).IsRequired(false);
builder.Property(e => e.EnableComments).IsRequired();
builder.Property(e => e.DisqusDomain).IsRequired(false);
builder.Property(e => e.DisqusIdentifier).IsRequired(false);
builder.Property(e => e.DisqusPath).IsRequired(false);
builder.Property(e => e.Visibility).HasConversion(new EnumToStringConverter<BlogPostVisibility>()).IsRequired();
builder.Property(e => e.Password).HasMaxLength(255).IsRequired(false);
builder.Property(e => e.Tags).IsRequired()
.HasConversion(
tags => string.Join(' ', tags.Select(t => t.Replace(' ', '-'))),
tags => tags.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Select(t => t.Replace('-', ' ')).ToArray());
}
}

View File

@ -3,7 +3,7 @@ namespace OliverBooth.Common.Data.Blog;
/// <summary>
/// Represents the author of a blog post.
/// </summary>
public interface IAuthor
public interface IBlogAuthor
{
/// <summary>
/// Gets the URL of the author's avatar.

View File

@ -9,13 +9,13 @@ public interface IBlogPost
/// Gets the author of the post.
/// </summary>
/// <value>The author of the post.</value>
IAuthor Author { get; }
IBlogAuthor Author { get; }
/// <summary>
/// Gets or sets the body of the post.
/// Gets the body of the post.
/// </summary>
/// <value>The body of the post.</value>
string Body { get; set; }
string Body { get; }
/// <summary>
/// Gets a value indicating whether comments are enabled for the post.
@ -25,6 +25,12 @@ public interface IBlogPost
/// </value>
bool EnableComments { get; }
/// <summary>
/// Gets the excerpt of this post, if it has one.
/// </summary>
/// <value>The excerpt, or <see langword="null" /> if this post has no excerpt.</value>
string? Excerpt { get; }
/// <summary>
/// Gets the ID of the post.
/// </summary>
@ -70,10 +76,10 @@ public interface IBlogPost
IReadOnlyList<string> Tags { get; }
/// <summary>
/// Gets or sets the title of the post.
/// Gets the title of the post.
/// </summary>
/// <value>The title of the post.</value>
string Title { get; set; }
string Title { get; }
/// <summary>
/// Gets the date and time the post was last updated.
@ -85,7 +91,7 @@ public interface IBlogPost
/// Gets the visibility of the post.
/// </summary>
/// <value>The visibility of the post.</value>
BlogPostVisibility Visibility { get; }
Visibility Visibility { get; }
/// <summary>
/// Gets the WordPress ID of the post.

View File

@ -1,103 +0,0 @@
namespace OliverBooth.Common.Data.Blog;
/// <summary>
/// Represents a draft of a blog post.
/// </summary>
public interface IBlogPostDraft
{
/// <summary>
/// Gets the author of the post.
/// </summary>
/// <value>The author of the post.</value>
IAuthor Author { get; }
/// <summary>
/// Gets or sets the body of the post.
/// </summary>
/// <value>The body of the post.</value>
string Body { get; set; }
/// <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 password of the post.
/// </summary>
/// <value>The password of the post.</value>
string? Password { 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 tags of the post.
/// </summary>
/// <value>The tags of the post.</value>
IReadOnlyList<string> Tags { get; }
/// <summary>
/// Gets or sets the title of the post.
/// </summary>
/// <value>The title of the post.</value>
string Title { get; set; }
/// <summary>
/// Gets the date and time the post was last updated.
/// </summary>
/// <value>The update date and time.</value>
DateTimeOffset Updated { get; }
/// <summary>
/// Gets the visibility of the post.
/// </summary>
/// <value>The visibility of the post.</value>
BlogPostVisibility Visibility { get; }
/// <summary>
/// Gets the WordPress ID of the post.
/// </summary>
/// <value>
/// The WordPress ID of the post, or <see langword="null" /> if the post was not imported from WordPress.
/// </value>
int? WordPressId { get; }
/// <summary>
/// Gets the Disqus identifier for the post.
/// </summary>
/// <returns>The Disqus identifier for the post.</returns>
string GetDisqusIdentifier();
/// <summary>
/// Gets the Disqus post ID for the post.
/// </summary>
/// <returns>The Disqus post ID for the post.</returns>
string GetDisqusPostId();
}

View File

@ -0,0 +1,54 @@
namespace OliverBooth.Common.Data.Blog;
/// <summary>
/// Represents a comment that was posted on a legacy comment framework.
/// </summary>
public interface ILegacyComment
{
/// <summary>
/// Gets the PNG-encoded avatar of the author.
/// </summary>
/// <value>The author's avatar.</value>
string? Avatar { get; }
/// <summary>
/// Gets the name of the comment's author.
/// </summary>
/// <value>The author's name.</value>
string Author { get; }
/// <summary>
/// Gets the body of the comment.
/// </summary>
/// <value>The comment body.</value>
string Body { get; }
/// <summary>
/// Gets the date and time at which this comment was posted.
/// </summary>
/// <value>The creation timestamp.</value>
DateTimeOffset CreatedAt { get; }
/// <summary>
/// Gets the ID of this comment.
/// </summary>
Guid Id { get; }
/// <summary>
/// Gets the ID of the comment this comment is replying to.
/// </summary>
/// <value>The parent comment ID, or <see langword="null" /> if this comment is not a reply.</value>
Guid? ParentComment { get; }
/// <summary>
/// Gets the ID of the post to which this comment was posted.
/// </summary>
/// <value>The post ID.</value>
Guid PostId { get; }
/// <summary>
/// Gets the avatar URL of the comment's author.
/// </summary>
/// <returns>The avatar URL.</returns>
string GetAvatarUrl();
}

View File

@ -0,0 +1,54 @@
namespace OliverBooth.Common.Data.Blog;
/// <summary>
/// Represents a user which can log in to the blog.
/// </summary>
public interface IUser
{
/// <summary>
/// Gets the URL of the user's avatar.
/// </summary>
/// <value>The URL of the user's avatar.</value>
Uri AvatarUrl { get; }
/// <summary>
/// Gets the email address of the user.
/// </summary>
/// <value>The email address of the user.</value>
string EmailAddress { get; }
/// <summary>
/// Gets the display name of the author.
/// </summary>
/// <value>The display name of the author.</value>
string DisplayName { get; }
/// <summary>
/// Gets the unique identifier of the user.
/// </summary>
/// <value>The unique identifier of the user.</value>
Guid Id { get; }
/// <summary>
/// Gets the date and time the user registered.
/// </summary>
/// <value>The registration date and time.</value>
DateTimeOffset Registered { get; }
/// <summary>
/// Gets the URL of the user's avatar.
/// </summary>
/// <param name="size">The size of the avatar.</param>
/// <returns>The URL of the user's avatar.</returns>
Uri GetAvatarUrl(int size = 28);
/// <summary>
/// Returns a value indicating whether the specified password is valid for the user.
/// </summary>
/// <param name="password">The password to test.</param>
/// <returns>
/// <see langword="true" /> if the specified password is valid for the user; otherwise,
/// <see langword="false" />.
/// </returns>
bool TestCredentials(string password);
}

View File

@ -1,4 +1,4 @@
namespace OliverBooth.Data.Mastodon;
namespace OliverBooth.Common.Data.Mastodon;
public enum AttachmentType
{

View File

@ -0,0 +1,31 @@
namespace OliverBooth.Common.Data.Mastodon;
/// <summary>
/// Represents a status on Mastodon.
/// </summary>
public interface IMastodonStatus
{
/// <summary>
/// Gets the content of the status.
/// </summary>
/// <value>The content.</value>
string Content { get; }
/// <summary>
/// Gets the date and time at which this status was posted.
/// </summary>
/// <value>The post timestamp.</value>
DateTimeOffset CreatedAt { get; }
/// <summary>
/// Gets the media attachments for this status.
/// </summary>
/// <value>The media attachments.</value>
IReadOnlyList<MediaAttachment> MediaAttachments { get; }
/// <summary>
/// Gets the original URI of the status.
/// </summary>
/// <value>The original URI.</value>
Uri OriginalUri { get; }
}

View File

@ -1,4 +1,4 @@
namespace OliverBooth.Data.Mastodon;
namespace OliverBooth.Common.Data.Mastodon;
public sealed class MediaAttachment
{

View File

@ -1,37 +0,0 @@
namespace OliverBooth.Common.Data;
/// <summary>
/// Represents a permission.
/// </summary>
public struct Permission
{
/// <summary>
/// Represents a permission that grants all scopes.
/// </summary>
public static readonly Permission Administrator = new("*");
/// <summary>
/// Initializes a new instance of the <see cref="Permission" /> struct.
/// </summary>
/// <param name="name">The name of the permission.</param>
/// <param name="isAllowed">
/// <see langword="true" /> if the permission is allowed; otherwise, <see langword="false" />.
/// </param>
public Permission(string name, bool isAllowed = true)
{
Name = name;
IsAllowed = isAllowed;
}
/// <summary>
/// Gets the name of this permission.
/// </summary>
/// <value>The name.</value>
public string Name { get; }
/// <summary>
/// Gets a value indicating whether this permission is allowed.
/// </summary>
/// <value><see langword="true" /> if the permission is allowed; otherwise, <see langword="false" />.</value>
public bool IsAllowed { get; }
}

View File

@ -1,42 +0,0 @@
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace OliverBooth.Common.Data.ValueConverters;
internal sealed class PermissionListConverter : ValueConverter<IReadOnlyList<Permission>, string>
{
public PermissionListConverter() : this(';')
{
}
public PermissionListConverter(char separator) :
base(v => ToProvider(v, separator),
s => FromProvider(s, separator))
{
}
private static IReadOnlyList<Permission> FromProvider(string source, char separator = ';')
{
var permissions = new List<Permission>();
foreach (string permission in source.Split(separator))
{
string name = permission;
var allowed = true;
if (name.Length > 1 && name[0] == '-')
{
name = name[1..];
allowed = false;
}
permissions.Add(new Permission(name, allowed));
}
return permissions.AsReadOnly();
}
private static string ToProvider(IEnumerable<Permission> permissions, char separator = ';')
{
return string.Join(separator, permissions.Select(p => $"{(p.IsAllowed ? "-" : "")}{p.Name}"));
}
}

View File

@ -1,10 +1,15 @@
namespace OliverBooth.Common.Data.Blog;
namespace OliverBooth.Common.Data;
/// <summary>
/// An enumeration of the possible visibilities of a blog post.
/// </summary>
public enum BlogPostVisibility
public enum Visibility
{
/// <summary>
/// Used for filtering results. Represents all visibilities.
/// </summary>
None = -1,
/// <summary>
/// The post is private and only visible to the author, or those with the password.
/// </summary>

View File

@ -1,4 +1,4 @@
namespace OliverBooth.Common.Data.Web.Books;
namespace OliverBooth.Common.Data.Web;
/// <summary>
/// Represents the state of a book.

View File

@ -1,26 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using OliverBooth.Common.Data.Web.Users;
namespace OliverBooth.Common.Data.Web.Configuration;
internal sealed class SessionConfiguration : IEntityTypeConfiguration<Session>
{
/// <inheritdoc />
public void Configure(EntityTypeBuilder<Session> builder)
{
builder.ToTable("Session");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).IsRequired();
builder.Property(e => e.Created).IsRequired();
builder.Property(e => e.Updated).IsRequired();
builder.Property(e => e.LastAccessed).IsRequired();
builder.Property(e => e.Expires).IsRequired();
builder.Property(e => e.UserAgent).HasMaxLength(255).IsRequired();
builder.Property(e => e.UserId);
builder.Property(e => e.IpAddress).HasConversion<IPAddressToBytesConverter>().IsRequired();
builder.Property(e => e.RequiresTotp).IsRequired();
}
}

View File

@ -1,4 +1,4 @@
namespace OliverBooth.Common.Data.Web.Contact;
namespace OliverBooth.Common.Data.Web;
/// <summary>
/// Represents an entry in the blacklist.

View File

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

View File

@ -0,0 +1,25 @@
namespace OliverBooth.Common.Data.Web;
/// <summary>
/// Represents a code snippet.
/// </summary>
public interface ICodeSnippet
{
/// <summary>
/// Gets the content for this snippet.
/// </summary>
/// <value>The content for this snippet</value>
string Content { get; }
/// <summary>
/// Gets the ID for this snippet.
/// </summary>
/// <value>The ID for this snippet</value>
int Id { get; }
/// <summary>
/// Gets the language for this snippet.
/// </summary>
/// <value>The language for this snippet</value>
string Language { get; }
}

View File

@ -1,4 +1,4 @@
namespace OliverBooth.Common.Data.Web.Projects;
namespace OliverBooth.Common.Data.Web;
/// <summary>
/// Represents a programming language.

View File

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

View File

@ -0,0 +1,99 @@
namespace OliverBooth.Common.Data.Web;
/// <summary>
/// Represents a tutorial article.
/// </summary>
public interface ITutorialArticle
{
/// <summary>
/// Gets the body of this article.
/// </summary>
/// <value>The body.</value>
string Body { get; }
/// <summary>
/// Gets a value indicating whether comments are enabled for the article.
/// </summary>
/// <value>
/// <see langword="true" /> if comments are enabled for the article; otherwise, <see langword="false" />.
/// </value>
bool EnableComments { get; }
/// <summary>
/// Gets the excerpt of this article, if it has one.
/// </summary>
/// <value>The excerpt, or <see langword="null" /> if this article has no excerpt.</value>
string? Excerpt { get; }
/// <summary>
/// Gets the ID of the folder this article is contained within.
/// </summary>
/// <value>The ID of the folder.</value>
int Folder { get; }
/// <summary>
/// Gets a value indicating whether this article is part of a multi-part series.
/// </summary>
/// <value><see langword="true" /> if this article has additional parts; otherwise, <see langword="false" />.</value>
bool HasOtherParts { get; }
/// <summary>
/// Gets the ID of this article.
/// </summary>
/// <value>The ID.</value>
int Id { get; }
/// <summary>
/// Gets the ID of the next article to this one.
/// </summary>
/// <value>The next part ID.</value>
int? NextPart { get; }
/// <summary>
/// Gets the URL of the article's preview image.
/// </summary>
/// <value>The preview image URL.</value>
Uri? PreviewImageUrl { get; }
/// <summary>
/// Gets the ID of the previous article to this one.
/// </summary>
/// <value>The previous part ID.</value>
int? PreviousPart { get; }
/// <summary>
/// Gets the date and time at which this article was published.
/// </summary>
/// <value>The publish timestamp.</value>
DateTimeOffset Published { get; }
/// <summary>
/// Gets the ID of the post that was redirected to this article.
/// </summary>
/// <value>The source redirect post ID.</value>
Guid? RedirectFrom { get; }
/// <summary>
/// Gets the slug of this article.
/// </summary>
/// <value>The slug.</value>
string Slug { get; }
/// <summary>
/// Gets the title of this article.
/// </summary>
/// <value>The title.</value>
string Title { get; }
/// <summary>
/// Gets the date and time at which this article was updated.
/// </summary>
/// <value>The update timestamp, or <see langword="null" /> if this article has not been updated.</value>
DateTimeOffset? Updated { get; }
/// <summary>
/// Gets the visibility of this article.
/// </summary>
/// <value>The visibility of the article.</value>
Visibility Visibility { get; }
}

View File

@ -0,0 +1,43 @@
namespace OliverBooth.Common.Data.Web;
/// <summary>
/// Represents a folder for tutorial articles.
/// </summary>
public interface ITutorialFolder
{
/// <summary>
/// Gets the ID of this folder.
/// </summary>
/// <value>The ID of the folder.</value>
int Id { get; }
/// <summary>
/// Gets the ID of this folder's parent.
/// </summary>
/// <value>The ID of the parent, or <see langword="null" /> if this folder is at the root.</value>
int? Parent { get; }
/// <summary>
/// Gets the URL of the folder's preview image.
/// </summary>
/// <value>The preview image URL.</value>
Uri? PreviewImageUrl { get; }
/// <summary>
/// Gets the slug of this folder.
/// </summary>
/// <value>The slug.</value>
string Slug { get; }
/// <summary>
/// Gets the title of this folder.
/// </summary>
/// <value>The title.</value>
string Title { get; }
/// <summary>
/// Gets the visibility of this article.
/// </summary>
/// <value>The visibility of the article.</value>
Visibility Visibility { get; }
}

View File

@ -1,6 +1,6 @@
using System.ComponentModel;
namespace OliverBooth.Common.Data.Web.Projects;
namespace OliverBooth.Common.Data.Web;
/// <summary>
/// Represents the status of a project.

View File

@ -1,37 +0,0 @@
namespace OliverBooth.Common.Data.Web.Users;
/// <summary>
/// Represents a temporary token used to correlate MFA attempts with the user.
/// </summary>
public interface IMfaToken
{
/// <summary>
/// Gets a value indicating the number of attempts made with this token.
/// </summary>
/// <value>The number of attempts.</value>
int Attempts { get; }
/// <summary>
/// Gets the date and time at which this token was created.
/// </summary>
/// <value>The creation timestamp.</value>
DateTimeOffset Created { get; }
/// <summary>
/// Gets the date and time at which this token expires.
/// </summary>
/// <value>The expiration timestamp.</value>
DateTimeOffset Expires { get; }
/// <summary>
/// Gets the 512-bit token for MFA.
/// </summary>
/// <value>The temporary MFA token.</value>
string Token { get; }
/// <summary>
/// Gets the user to whom this token is associated.
/// </summary>
/// <value>The user.</value>
IUser User { get; }
}

View File

@ -1,63 +0,0 @@
using System.Net;
namespace OliverBooth.Common.Data.Web.Users;
/// <summary>
/// Represents a login session.
/// </summary>
public interface ISession
{
/// <summary>
/// Gets the date and time at which this session was created.
/// </summary>
/// <value>The creation timestamp.</value>
DateTimeOffset Created { get; }
/// <summary>
/// Gets the date and time at which this session expires.
/// </summary>
/// <value>The expiration timestamp.</value>
DateTimeOffset Expires { get; }
/// <summary>
/// Gets the ID of the session.
/// </summary>
/// <value>The ID of the session.</value>
Guid Id { get; }
/// <summary>
/// Gets the IP address of the session.
/// </summary>
/// <value>The IP address.</value>
IPAddress IpAddress { get; }
/// <summary>
/// Gets the date and time at which this session was last accessed.
/// </summary>
/// <value>The last access timestamp.</value>
DateTimeOffset LastAccessed { get; }
/// <summary>
/// Gets a value indicating whether this session is valid.
/// </summary>
/// <value><see langword="true" /> if the session is valid; otherwise, <see langword="false" />.</value>
bool RequiresTotp { get; }
/// <summary>
/// Gets the date and time at which this session was updated.
/// </summary>
/// <value>The update timestamp.</value>
DateTimeOffset Updated { get; }
/// <summary>
/// Gets the user agent string associated with this session.
/// </summary>
/// <value>The user agent string.</value>
string UserAgent { get; }
/// <summary>
/// Gets the user ID associated with the session.
/// </summary>
/// <value>The user ID.</value>
Guid UserId { get; }
}

View File

@ -1,95 +0,0 @@
namespace OliverBooth.Common.Data.Web.Users;
/// <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 permissions this user is granted.
/// </summary>
/// <value>A read-only view of the permissions this user is granted.</value>
IReadOnlyList<Permission> Permissions { get; }
/// <summary>
/// Gets the user's TOTP token.
/// </summary>
/// <value>The TOTP token.</value>
string? Totp { 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>
/// Determines whether the user has the specified permission.
/// </summary>
/// <param name="permission">The permission to test.</param>
/// <returns>
/// <see langword="true" /> if the user has the specified permission; otherwise, <see langword="false" />.
/// </returns>
bool HasPermission(Permission permission);
/// <summary>
/// Determines whether the user has the specified permission.
/// </summary>
/// <param name="permission">The permission to test.</param>
/// <returns>
/// <see langword="true" /> if the user has the specified permission; otherwise, <see langword="false" />.
/// </returns>
bool HasPermission(string permission);
/// <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);
/// <summary>
/// Tests the specified TOTP with the user's current TOTP.
/// </summary>
/// <param name="value">The TOTP to test.</param>
/// <returns>
/// <see langword="true" /> if the specified time-based one-time password matches that of the user; otherwise,
/// <see langword="false" />.
/// </returns>
/// <exception cref="ArgumentNullException"><paramref name="value" /> is <see langword="null" />.</exception>
bool TestTotp(string value);
}

View File

@ -1,29 +0,0 @@
using OliverBooth.Common.Services;
namespace OliverBooth.Common.Data.Web.Users;
/// <summary>
/// An enumeration of possible results for <see cref="IUserService.VerifyMfaRequest" />.
/// </summary>
public enum MfaRequestResult
{
/// <summary>
/// The request was successful.
/// </summary>
Success,
/// <summary>
/// The wrong code was entered.
/// </summary>
InvalidTotp,
/// <summary>
/// The MFA token has expired.
/// </summary>
TokenExpired,
/// <summary>
/// Too many attempts were made by the user.
/// </summary>
TooManyAttempts,
}

View File

@ -1,19 +0,0 @@
namespace OliverBooth.Common.Data.Web.Users;
internal sealed class MfaToken : IMfaToken
{
/// <inheritdoc />
public int Attempts { get; set; }
/// <inheritdoc />
public DateTimeOffset Created { get; set; }
/// <inheritdoc />
public DateTimeOffset Expires { get; set; }
/// <inheritdoc />
public string Token { get; set; } = string.Empty;
/// <inheritdoc />
public IUser User { get; set; } = null!;
}

View File

@ -1,33 +0,0 @@
using System.Net;
namespace OliverBooth.Common.Data.Web.Users;
internal sealed class Session : ISession
{
/// <inheritdoc />
public DateTimeOffset Created { get; set; }
/// <inheritdoc />
public DateTimeOffset Expires { get; set; }
/// <inheritdoc />
public Guid Id { get; private set; } = Guid.NewGuid();
/// <inheritdoc />
public IPAddress IpAddress { get; set; } = IPAddress.None;
/// <inheritdoc />
public DateTimeOffset LastAccessed { get; set; }
/// <inheritdoc />
public bool RequiresTotp { get; set; }
/// <inheritdoc />
public DateTimeOffset Updated { get; set; }
/// <inheritdoc />
public string UserAgent { get; set; } = string.Empty;
/// <inheritdoc />
public Guid UserId { get; set; }
}

View File

@ -1,43 +0,0 @@
using Markdig;
using Microsoft.Extensions.DependencyInjection;
using OliverBooth.Common.Data.Blog;
using OliverBooth.Common.Data.Web;
using OliverBooth.Common.Markdown.Template;
using OliverBooth.Common.Services;
using X10D.Hosting.DependencyInjection;
namespace OliverBooth.Common.Extensions;
/// <summary>
/// Extension methods for dependency injection.
/// </summary>
public static class DependencyInjectionExtensions
{
/// <summary>
/// Adds all required services provided by the assembly to the current <see cref="IServiceCollection" />.
/// </summary>
/// <param name="collection">The <see cref="IServiceCollection" /> to add the service to.</param>
public static void AddCommonServices(this IServiceCollection collection)
{
collection.AddSingleton(provider => new MarkdownPipelineBuilder()
// .Use<TimestampExtension>()
.Use(new TemplateExtension(provider.GetRequiredService<ITemplateService>()))
.UseAdvancedExtensions()
.UseBootstrap()
.UseEmojiAndSmiley()
.UseSmartyPants()
.Build());
collection.AddDbContextFactory<BlogContext>();
collection.AddDbContextFactory<WebContext>();
collection.AddSingleton<IBlogPostService, BlogPostService>();
collection.AddSingleton<IContactService, ContactService>();
collection.AddSingleton<IProjectService, ProjectService>();
collection.AddSingleton<IReadingListService, ReadingListService>();
collection.AddSingleton<ITemplateService, TemplateService>();
collection.AddHostedSingleton<ISessionService, SessionService>();
collection.AddHostedSingleton<IUserService, UserService>();
}
}

View File

@ -7,28 +7,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Alexinea.Extensions.Configuration.Toml" Version="7.0.0"/>
<PackageReference Include="Asp.Versioning.Mvc" Version="8.0.0"/>
<PackageReference Include="BCrypt.Net-Core" Version="1.6.0"/>
<PackageReference Include="HtmlAgilityPack" Version="1.11.59"/>
<PackageReference Include="Humanizer.Core" Version="2.14.1"/>
<PackageReference Include="Markdig" Version="0.36.2"/>
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0"/>
<PackageReference Include="NetBarcode" Version="1.7.0"/>
<PackageReference Include="Otp.NET" Version="1.3.0"/>
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2"/>
<PackageReference Include="Serilog" Version="3.1.1"/>
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1"/>
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0"/>
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1"/>
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0"/>
<PackageReference Include="SmartFormat.NET" Version="3.3.2"/>
<PackageReference Include="X10D" Version="3.3.1"/>
<PackageReference Include="X10D.Hosting" Version="3.3.1"/>
<PackageReference Include="ZString" Version="2.5.1"/>
</ItemGroup>
<ItemGroup>
<Folder Include="Markdown\"/>
</ItemGroup>
</Project>

View File

@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using OliverBooth.Common.Data;
using OliverBooth.Common.Data.Blog;
namespace OliverBooth.Common.Services;
@ -12,28 +13,20 @@ public interface IBlogPostService
/// 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>
/// <param name="visibility">The visibility of the posts to retrieve.</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,
BlogPostVisibility visibility = BlogPostVisibility.Published);
IReadOnlyList<IBlogPost> GetAllBlogPosts(int limit = -1);
/// <summary>
/// Returns the total number of blog posts.
/// </summary>
/// <param name="visibility">The post visibility filter.</param>
/// <param name="tags">The tags of the posts to return.</param>
/// <returns>The total number of blog posts.</returns>
int GetBlogPostCount();
/// <summary>
/// Returns a JSON object representing the blog post block data.
/// </summary>
/// <param name="post">The blog post whose block data object should be returned.</param>
/// <returns>The JSON data of the blog post block data.</returns>
/// <exception cref="ArgumentNullException"><paramref name="post" /> is <see langword="null" />.</exception>
string GetBlogPostEditorObject(IBlogPost post);
int GetBlogPostCount(Visibility visibility = Visibility.None, string[]? tags = null);
/// <summary>
/// Returns a collection of blog posts from the specified page, optionally limiting the number of posts
@ -41,16 +34,30 @@ public interface IBlogPostService
/// </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>
/// <param name="tags">The tags of the posts to return.</param>
/// <returns>A collection of blog posts.</returns>
IReadOnlyList<IBlogPost> GetBlogPosts(int page, int pageSize);
IReadOnlyList<IBlogPost> GetBlogPosts(int page, int pageSize = 10, string[]? tags = null);
/// <summary>
/// Returns the drafts of this post, sorted by their update timestamp.
/// Returns the number of legacy comments for the specified post.
/// </summary>
/// <param name="post">The post whose drafts to return.</param>
/// <returns>The drafts of the <paramref name="post" />.</returns>
/// <exception cref="ArgumentNullException"><paramref name="post" /> is <see langword="null" />.</exception>
IReadOnlyList<IBlogPostDraft> GetDrafts(IBlogPost post);
/// <param name="post">The post whose legacy comments to count.</param>
/// <returns>The total number of legacy comments.</returns>
int GetLegacyCommentCount(IBlogPost post);
/// <summary>
/// Returns the collection of legacy comments for the specified post.
/// </summary>
/// <param name="post">The post whose legacy comments to retrieve.</param>
/// <returns>A read-only view of the legacy comments.</returns>
IReadOnlyList<ILegacyComment> GetLegacyComments(IBlogPost post);
/// <summary>
/// Returns the collection of replies to the specified legacy comment.
/// </summary>
/// <param name="comment">The comment whose replies to retrieve.</param>
/// <returns>A read-only view of the replies.</returns>
IReadOnlyList<ILegacyComment> GetLegacyReplies(ILegacyComment comment);
/// <summary>
/// Returns the next blog post from the specified blog post.
@ -59,6 +66,16 @@ public interface IBlogPostService
/// <returns>The next blog post from the specified blog post.</returns>
IBlogPost? GetNextPost(IBlogPost blogPost);
/// <summary>
/// Returns the number of pages needed to render all blog posts, using the specified <paramref name="pageSize" /> as an
/// indicator of how many posts are allowed per page.
/// </summary>
/// <param name="pageSize">The page size. Defaults to 10.</param>
/// <param name="visibility">The post visibility filter.</param>
/// <param name="tags">The tags of the posts to return.</param>
/// <returns>The page count.</returns>
int GetPageCount(int pageSize = 10, Visibility visibility = Visibility.None, string[]? tags = null);
/// <summary>
/// Returns the previous blog post from the specified blog post.
/// </summary>
@ -126,11 +143,4 @@ public interface IBlogPostService
/// </returns>
/// <exception cref="ArgumentNullException"><paramref name="slug" /> is <see langword="null" />.</exception>
bool TryGetPost(DateOnly publishDate, string slug, [NotNullWhen(true)] out IBlogPost? post);
/// <summary>
/// Updates the specified post.
/// </summary>
/// <param name="post">The post to edit.</param>
/// <exception cref="ArgumentNullException"><paramref name="post" /> is <see langword="null" />.</exception>
void UpdatePost(IBlogPost post);
}

View File

@ -0,0 +1,23 @@
using System.Diagnostics.CodeAnalysis;
using OliverBooth.Common.Data.Blog;
namespace OliverBooth.Common.Services;
/// <summary>
/// Represents a service for managing users.
/// </summary>
public interface IBlogUserService
{
/// <summary>
/// Attempts to find a user with the specified ID.
/// </summary>
/// <param name="id">The ID of the user to find.</param>
/// <param name="user">
/// When this method returns, contains the user with the specified ID, if the user is found; otherwise,
/// <see langword="null" />.
/// </param>
/// <returns>
/// <see langword="true" /> if a user with the specified ID is found; otherwise, <see langword="false" />.
/// </returns>
bool TryGetUser(Guid id, [NotNullWhen(true)] out IUser? user);
}

View File

@ -0,0 +1,32 @@
using System.Diagnostics.CodeAnalysis;
using OliverBooth.Common.Data.Web;
namespace OliverBooth.Common.Services;
/// <summary>
/// Represents a service which can fetch multi-language code snippets.
/// </summary>
public interface ICodeSnippetService
{
/// <summary>
/// Returns all the languages which apply to the specified snippet.
/// </summary>
/// <param name="id">The ID of the snippet whose languages should be returned.</param>
/// <returns>
/// A read-only view of the languages that apply to the snippet. This list may be empty if the snippet ID is invalid.
/// </returns>
IReadOnlyList<string> GetLanguagesForSnippet(int id);
/// <summary>
/// Attempts to find a code snippet by the specified ID, in the specified language.
/// </summary>
/// <param name="id">The ID of the snippet to search for.</param>
/// <param name="language">The language to search for.</param>
/// <param name="snippet">
/// When this method returns, contains the code snippet matching the specified criteria, if such a snippet was found;
/// otherwise, <see langword="null" />.
/// </param>
/// <returns><see langword="true" /> if the snippet was found; otherwise, <see langword="false" />.</returns>
/// <exception cref="ArgumentNullException"><paramref name="language" /> is <see langword="null" />.</exception>
bool TryGetCodeSnippetForLanguage(int id, string language, [NotNullWhen(true)] out ICodeSnippet? snippet);
}

View File

@ -1,4 +1,4 @@
using OliverBooth.Common.Data.Web.Contact;
using OliverBooth.Common.Data.Web;
namespace OliverBooth.Common.Services;

View File

@ -1,6 +1,6 @@
using OliverBooth.Data.Mastodon;
using OliverBooth.Common.Data.Mastodon;
namespace OliverBooth.Services;
namespace OliverBooth.Common.Services;
public interface IMastodonService
{
@ -8,5 +8,5 @@ public interface IMastodonService
/// Gets the latest status posted to Mastodon.
/// </summary>
/// <returns>The latest status.</returns>
MastodonStatus GetLatestStatus();
IMastodonStatus GetLatestStatus();
}

View File

@ -0,0 +1,14 @@
namespace OliverBooth.Common.Services;
/// <summary>
/// Represents a service which can perform programming language lookup.
/// </summary>
public interface IProgrammingLanguageService
{
/// <summary>
/// Returns the human-readable name of a language.
/// </summary>
/// <param name="alias">The alias of the language.</param>
/// <returns>The human-readable name, or <paramref name="alias" /> if the name could not be found.</returns>
string GetLanguageName(string alias);
}

View File

@ -1,5 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using OliverBooth.Common.Data.Web.Projects;
using OliverBooth.Common.Data.Web;
namespace OliverBooth.Common.Services;

View File

@ -1,4 +1,4 @@
using OliverBooth.Common.Data.Web.Books;
using OliverBooth.Common.Data.Web;
namespace OliverBooth.Common.Services;

View File

@ -1,106 +0,0 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using OliverBooth.Common.Data.Web.Users;
using ISession = OliverBooth.Common.Data.Web.Users.ISession;
namespace OliverBooth.Common.Services;
public interface ISessionService
{
/// <summary>
/// Creates a new session for the specified user.
/// </summary>
/// <param name="request">The HTTP request.</param>
/// <param name="user">The user.</param>
/// <returns>The newly-created session.</returns>
/// <exception cref="ArgumentNullException">
/// <para><paramref name="request" /> is <see langword="null" />.</para>
/// -or-
/// <para><paramref name="user" /> is <see langword="null" />.</para>
/// </exception>
ISession CreateSession(HttpRequest request, IUser user);
/// <summary>
/// Deletes the specified session.
/// </summary>
/// <param name="session">The session to delete.</param>
/// <exception cref="ArgumentNullException"><paramref name="session" /> is <see langword="null" />.</exception>
void DeleteSession(ISession session);
/// <summary>
/// Deletes the client's session cookie.
/// </summary>
/// <param name="response">The response to edit.</param>
/// <exception cref="ArgumentNullException"><paramref name="response" /> is <see langword="null" />.</exception>
IActionResult DeleteSessionCookie(HttpResponse response);
/// <summary>
/// Saves a session cookie to the client's browser.
/// </summary>
/// <param name="response">The response to edit.</param>
/// <param name="session">The session to write.</param>
/// <exception cref="ArgumentNullException">
/// <para><paramref name="request" /> is <see langword="null" />.</para>
/// -or-
/// <para><paramref name="session" /> is <see langword="null" />.</para>
/// </exception>
void SaveSessionCookie(HttpResponse response, ISession session);
/// <summary>
/// Attempts to find the user associated with the client's current request.
/// </summary>
/// <param name="request">The HTTP request.</param>
/// <param name="response">The response to edit.</param>
/// <param name="user">
/// When this method returns, contains the user with the specified request, if the user is found; otherwise,
/// <see langword="null" />.
/// </param>
/// <returns>
/// <see langword="true" /> if a user is found; otherwise, <see langword="false" />.
/// </returns>
/// <exception cref="ArgumentNullException">
/// <para><paramref name="request" /> is <see langword="null" />.</para>
/// -or-
/// <para><paramref name="response" /> is <see langword="null" />.</para>
/// </exception>
bool TryGetCurrentUser(HttpRequest request, HttpResponse response, [NotNullWhen(true)] out IUser? user);
/// <summary>
/// Attempts to find a session with the specified ID.
/// </summary>
/// <param name="sessionId">The session ID.</param>
/// <param name="session">
/// When this method returns, contains the session with the specified ID, if the session is found; otherwise,
/// <see langword="null" />.
/// </param>
/// <returns>
/// <see langword="true" /> if a session with the specified ID is found; otherwise, <see langword="false" />.
/// </returns>
bool TryGetSession(Guid sessionId, [NotNullWhen(true)] out ISession? session);
/// <summary>
/// Attempts to find the session associated with the HTTP request.
/// </summary>
/// <param name="request">The HTTP request.</param>
/// <param name="session">
/// When this method returns, contains the session with the specified request, if the user is found; otherwise,
/// <see langword="null" />.
/// </param>
/// <returns><see langword="true" /> if the session was found; otherwise, <see langword="false" />.</returns>
/// <exception cref="ArgumentNullException"><paramref name="request" /> is <see langword="null" />.</exception>
bool TryGetSession(HttpRequest request, [NotNullWhen(true)] out ISession? session);
/// <summary>
/// Validates the session with the incoming HTTP request.
/// </summary>
/// <param name="request">The HTTP request.</param>
/// <param name="session">The session.</param>
/// <returns><see langword="true" /> if the session is valid; otherwise, <see langword="false" />.</returns>
/// <exception cref="ArgumentNullException">
/// <para><paramref name="request" /> is <see langword="null" />.</para>
/// -or-
/// <para><paramref name="session" /> is <see langword="null" />.</para>
/// </exception>
bool ValidateSession(HttpRequest request, ISession session);
}

View File

@ -0,0 +1,124 @@
using System.Diagnostics.CodeAnalysis;
using OliverBooth.Common.Data;
using OliverBooth.Common.Data.Blog;
using OliverBooth.Common.Data.Web;
namespace OliverBooth.Common.Services;
/// <summary>
/// Represents a service which can retrieve tutorial articles.
/// </summary>
public interface ITutorialService
{
/// <summary>
/// Gets the articles within a tutorial folder.
/// </summary>
/// <param name="folder">The folder whose articles to retrieve.</param>
/// <param name="visibility">The visibility to filter by. -1 does not filter.</param>
/// <returns>A read-only view of the articles in the folder.</returns>
IReadOnlyCollection<ITutorialArticle> GetArticles(ITutorialFolder folder, Visibility visibility = Visibility.None);
/// <summary>
/// Gets the tutorial folders within a specified folder.
/// </summary>
/// <param name="parent">The parent folder.</param>
/// <param name="visibility">The visibility to filter by. -1 does not filter.</param>
/// <returns>A read-only view of the subfolders in the folder.</returns>
IReadOnlyCollection<ITutorialFolder> GetFolders(ITutorialFolder? parent = null, Visibility visibility = Visibility.None);
/// <summary>
/// Gets a folder by its ID.
/// </summary>
/// <param name="id">The ID of the folder to get</param>
/// <param name="folder">
/// When this method returns, contains the folder whose ID is equal to the ID specified, or
/// <see langword="null" /> if no such folder was found.
/// </param>
/// <returns><see langword="true" /></returns>
ITutorialFolder? GetFolder(int id);
/// <summary>
/// Gets a folder by its slug.
/// </summary>
/// <param name="slug">The slug of the folder.</param>
/// <param name="parent">The parent folder.</param>
/// <returns>The folder.</returns>
ITutorialFolder? GetFolder(string? slug, ITutorialFolder? parent = null);
/// <summary>
/// Gets the full slug of the specified folder.
/// </summary>
/// <param name="folder">The folder whose slug to return.</param>
/// <returns>The full slug of the folder.</returns>
/// <exception cref="ArgumentNullException"><paramref name="folder" /> is <see langword="null" />.</exception>
string GetFullSlug(ITutorialFolder folder);
/// <summary>
/// Gets the full slug of the specified article.
/// </summary>
/// <param name="article">The article whose slug to return.</param>
/// <returns>The full slug of the article.</returns>
/// <exception cref="ArgumentNullException"><paramref name="article" /> is <see langword="null" />.</exception>
string GetFullSlug(ITutorialArticle article);
/// <summary>
/// Returns the number of legacy comments for the specified article.
/// </summary>
/// <param name="article">The article whose legacy comments to count.</param>
/// <returns>The total number of legacy comments.</returns>
int GetLegacyCommentCount(ITutorialArticle article);
/// <summary>
/// Returns the collection of legacy comments for the specified article.
/// </summary>
/// <param name="article">The article whose legacy comments to retrieve.</param>
/// <returns>A read-only view of the legacy comments.</returns>
IReadOnlyList<ILegacyComment> GetLegacyComments(ITutorialArticle article);
/// <summary>
/// Returns the collection of replies to the specified legacy comment.
/// </summary>
/// <param name="comment">The comment whose replies to retrieve.</param>
/// <returns>A read-only view of the replies.</returns>
IReadOnlyList<ILegacyComment> GetLegacyReplies(ILegacyComment comment);
/// <summary>
/// Renders the body of the specified article.
/// </summary>
/// <param name="article">The article to render.</param>
/// <returns>The rendered HTML of the article.</returns>
string RenderArticle(ITutorialArticle article);
/// <summary>
/// Renders the excerpt of the specified article.
/// </summary>
/// <param name="article">The article 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 article's excerpt.</returns>
string RenderExcerpt(ITutorialArticle article, out bool wasTrimmed);
/// <summary>
/// Attempts to find an article by its ID.
/// </summary>
/// <param name="id">The ID of the article.</param>
/// <param name="article">
/// When this method returns, contains the article whose ID matches the specified <paramref name="id" />, or
/// <see langword="null" /> if no such article was found.
/// </param>
/// <returns><see langword="true" /> if a matching article was found; otherwise, <see langword="false" />.</returns>
bool TryGetArticle(int id, [NotNullWhen(true)] out ITutorialArticle? article);
/// <summary>
/// Attempts to find an article by its slug.
/// </summary>
/// <param name="slug">The slug of the article.</param>
/// <param name="article">
/// When this method returns, contains the article whose slug matches the specified <paramref name="slug" />, or
/// <see langword="null" /> if no such article was found.
/// </param>
/// <returns><see langword="true" /> if a matching article was found; otherwise, <see langword="false" />.</returns>
bool TryGetArticle(string slug, [NotNullWhen(true)] out ITutorialArticle? article);
}

View File

@ -1,82 +0,0 @@
using System.Diagnostics.CodeAnalysis;
using OliverBooth.Common.Data.Web.Users;
namespace OliverBooth.Common.Services;
/// <summary>
/// Represents a service which manages users.
/// </summary>
public interface IUserService
{
/// <summary>
/// Clears all expired tokens.
/// </summary>
void ClearExpiredTokens();
/// <summary>
/// Clears all tokens.
/// </summary>
void ClearTokens();
/// <summary>
/// Creates a temporary MFA token for the specified user.
/// </summary>
/// <param name="user">The user for whom to create the token.</param>
/// <returns>The newly-created token.</returns>
/// <exception cref="ArgumentNullException"><paramref name="user" /> is <see langword="null" />.</exception>
IMfaToken CreateMfaToken(IUser user);
/// <summary>
/// Deletes the specified token.
/// </summary>
/// <param name="token">The token to delete.</param>
/// <exception cref="ArgumentNullException"><paramref name="token" /> is <see langword="null" />.</exception>
void DeleteToken(string token);
/// <summary>
/// Attempts to find a user by their unique ID.
/// </summary>
/// <param name="id">The ID of the user to return.</param>
/// <param name="user">
/// When this method returns, contains the user whose ID is equal to the specified <paramref name="id" />, if
/// such a user exists; otherwise, <see langword="null" />.
/// </param>
/// <returns>
/// <see langword="true" /> if a user was found with the specified <paramref name="id" />; otherwise,
/// <see langword="false" />.
/// </returns>
bool TryGetUser(Guid id, [NotNullWhen(true)] out IUser? user);
/// <summary>
/// Verifies the login information of the specified user.
/// </summary>
/// <param name="email">The email address.</param>
/// <param name="password">The password.</param>
/// <param name="user">
/// When this method returns, contains the user associated with the login credentials, or
/// <see langword="null" /> if the credentials are invalid.
/// </param>
/// <returns>
/// <see langword="true" /> if the login credentials are valid; otherwise, <see langword="false" />.
/// </returns>
bool VerifyLogin(string email, string password, [NotNullWhen(true)] out IUser? user);
/// <summary>
/// Verifies the MFA request for the specified user.
/// </summary>
/// <param name="token">The MFA token.</param>
/// <param name="totp">The user-provided TOTP.</param>
/// <param name="user">
/// When this method returns, contains the user associated with the specified token, if the verification was
/// successful; otherwise, <see langword="null" />.
/// </param>
/// <returns>
/// An <see cref="MfaRequestResult" /> representing the result of the request.
/// </returns>
/// <exception cref="ArgumentNullException">
/// <para><paramref name="token" /> is <see langword="null" />.</para>
/// -or-
/// <para><paramref name="totp" /> is <see langword="null" />.</para>
/// </exception>
MfaRequestResult VerifyMfaRequest(string token, string totp, out IUser? user);
}

View File

@ -1,244 +0,0 @@
using System.Diagnostics.CodeAnalysis;
using System.Net;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using OliverBooth.Common.Data.Web;
using OliverBooth.Common.Data.Web.Users;
using ISession = OliverBooth.Common.Data.Web.Users.ISession;
namespace OliverBooth.Common.Services;
internal sealed class SessionService : BackgroundService, ISessionService
{
private readonly ILogger<SessionService> _logger;
private readonly IUserService _userService;
private readonly IDbContextFactory<WebContext> _webContextFactory;
/// <summary>
/// Initializes a new instance of the <see cref="SessionService" /> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="userService">The user service.</param>
/// <param name="webContextFactory">The <see cref="WebContext" /> factory.</param>
public SessionService(ILogger<SessionService> logger,
IUserService userService,
IDbContextFactory<WebContext> webContextFactory)
{
_logger = logger;
_userService = userService;
_webContextFactory = webContextFactory;
}
/// <inheritdoc />
public ISession CreateSession(HttpRequest request, IUser user)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
if (user is null)
{
throw new ArgumentNullException(nameof(user));
}
using WebContext context = _webContextFactory.CreateDbContext();
var now = DateTimeOffset.UtcNow;
var session = new Session
{
UserId = user.Id,
IpAddress = request.HttpContext.Connection.RemoteIpAddress!,
Created = now,
Updated = now,
LastAccessed = now,
Expires = now + TimeSpan.FromDays(1),
RequiresTotp = !string.IsNullOrWhiteSpace(user.Totp),
UserAgent = request.Headers.UserAgent.ToString()
};
EntityEntry<Session> entry = context.Sessions.Add(session);
context.SaveChanges();
return entry.Entity;
}
/// <inheritdoc />
public void DeleteSession(ISession session)
{
using WebContext context = _webContextFactory.CreateDbContext();
context.Sessions.Remove((Session)session);
context.SaveChanges();
}
/// <inheritdoc />
public IActionResult DeleteSessionCookie(HttpResponse response)
{
response.Cookies.Delete("sid");
return new RedirectToPageResult("/Admin/Login");
}
/// <inheritdoc />
public void SaveSessionCookie(HttpResponse response, ISession session)
{
if (response is null)
{
throw new ArgumentNullException(nameof(response));
}
if (session is null)
{
throw new ArgumentNullException(nameof(session));
}
Span<byte> buffer = stackalloc byte[16];
if (!session.Id.TryWriteBytes(buffer))
{
return;
}
IPAddress? remoteIpAddress = response.HttpContext.Connection.RemoteIpAddress;
_logger.LogDebug("Writing cookie 'sid' to HTTP response for {RemoteAddr}", remoteIpAddress);
response.Cookies.Append("sid", Convert.ToBase64String(buffer), new CookieOptions
{
Expires = DateTimeOffset.UtcNow + TimeSpan.FromDays(30),
Secure = true,
SameSite = SameSiteMode.Strict
});
}
/// <inheritdoc />
public bool TryGetCurrentUser(HttpRequest request, HttpResponse response, [NotNullWhen(true)] out IUser? user)
{
user = null;
if (!TryGetSession(request, out ISession? session))
{
_logger.LogDebug("Session not found; redirecting");
DeleteSessionCookie(response);
return false;
}
if (!ValidateSession(request, session))
{
_logger.LogDebug("Session invalid; redirecting");
DeleteSessionCookie(response);
return false;
}
if (!_userService.TryGetUser(session.UserId, out user))
{
_logger.LogDebug("User not found; redirecting");
DeleteSessionCookie(response);
return false;
}
return true;
}
/// <inheritdoc />
public bool TryGetSession(Guid sessionId, [NotNullWhen(true)] out ISession? session)
{
using WebContext context = _webContextFactory.CreateDbContext();
session = context.Sessions.FirstOrDefault(s => s.Id == sessionId);
return session is not null;
}
/// <inheritdoc />
public bool TryGetSession(HttpRequest request, [NotNullWhen(true)] out ISession? session)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
session = null;
IPAddress? remoteIpAddress = request.HttpContext.Connection.RemoteIpAddress;
if (remoteIpAddress is null)
{
return false;
}
if (!request.Cookies.TryGetValue("sid", out string? sessionIdCookie))
{
return false;
}
Span<byte> bytes = stackalloc byte[16];
if (!Convert.TryFromBase64Chars(sessionIdCookie, bytes, out int bytesWritten) || bytesWritten < 16)
{
return false;
}
var sessionId = new Guid(bytes);
return TryGetSession(sessionId, out session);
}
/// <inheritdoc />
public bool ValidateSession(HttpRequest request, ISession session)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
if (session is null)
{
throw new ArgumentNullException(nameof(session));
}
IPAddress? remoteIpAddress = request.HttpContext.Connection.RemoteIpAddress;
if (remoteIpAddress is null)
{
return false;
}
if (session.Expires <= DateTimeOffset.UtcNow)
{
_logger.LogInformation("Session {Id} has expired (client {Ip})", session.Id, remoteIpAddress);
return false;
}
Span<byte> remoteAddressBytes = stackalloc byte[16];
Span<byte> sessionAddressBytes = stackalloc byte[16];
if (!remoteIpAddress.TryWriteBytes(remoteAddressBytes, out _) ||
!session.IpAddress.TryWriteBytes(sessionAddressBytes, out _))
{
_logger.LogWarning("Failed to write bytes for session {Id}", session.Id);
return false;
}
if (!remoteAddressBytes.SequenceEqual(sessionAddressBytes))
{
_logger.LogInformation("Session {Id} has IP mismatch (wanted {Expected}, got {Actual})", session.Id,
session.IpAddress, remoteIpAddress);
return false;
}
var userAgent = request.Headers.UserAgent.ToString();
if (session.UserAgent != userAgent)
{
_logger.LogInformation("Session {Id} has user agent mismatch (wanted {Expected}, got {Actual})", session.Id,
session.UserAgent, userAgent);
return false;
}
if (!_userService.TryGetUser(session.UserId, out _))
{
_logger.LogWarning("User {Id} not found for session {Session} (client {Ip})", session.UserId, session.Id,
remoteIpAddress);
return false;
}
return true;
}
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await using WebContext context = await _webContextFactory.CreateDbContextAsync(stoppingToken);
context.Sessions.RemoveRange(context.Sessions.Where(s => s.Expires <= DateTimeOffset.UtcNow));
await context.SaveChangesAsync(stoppingToken);
}
}

View File

@ -1,186 +0,0 @@
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Security.Cryptography;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
using OliverBooth.Common.Data.Web;
using OliverBooth.Common.Data.Web.Users;
using BC = BCrypt.Net.BCrypt;
using Timer = System.Timers.Timer;
namespace OliverBooth.Common.Services;
internal sealed class UserService : BackgroundService, IUserService
{
private static readonly RandomNumberGenerator RandomNumberGenerator = RandomNumberGenerator.Create();
private readonly IDbContextFactory<WebContext> _dbContextFactory;
private readonly ConcurrentDictionary<Guid, IUser> _userCache = new();
private readonly ConcurrentDictionary<string, MfaToken> _tokenCache = new();
private readonly Timer _tokenClearTimer = new();
/// <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="WebContext" />.
/// </param>
public UserService(IDbContextFactory<WebContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
_tokenClearTimer.Interval = TimeSpan.FromMinutes(5).TotalMilliseconds;
_tokenClearTimer.Elapsed += (_, _) => ClearExpiredTokens();
}
/// <inheritdoc />
public void ClearExpiredTokens()
{
DateTimeOffset now = DateTimeOffset.UtcNow;
var keysToRemove = new string[_tokenCache.Count];
var insertionIndex = 0;
foreach (var (key, token) in _tokenCache)
{
if (token.Expires <= now)
{
keysToRemove[insertionIndex++] = key;
}
}
for (var index = 0; index < insertionIndex; index++)
{
_tokenCache.TryRemove(keysToRemove[index], out _);
}
}
/// <inheritdoc />
public void ClearTokens()
{
_tokenCache.Clear();
}
/// <inheritdoc />
public IMfaToken CreateMfaToken(IUser user)
{
if (user is null)
{
throw new ArgumentNullException(nameof(user));
}
DateTimeOffset now = DateTimeOffset.UtcNow;
var token = new MfaToken
{
Token = CreateToken(),
User = user,
Attempts = 0,
Created = now,
Expires = now + TimeSpan.FromMinutes(5)
};
_tokenCache[token.Token] = token;
return token;
// while we do want a string, BitConvert.ToString requires a heap byte array
// which is just very not pog. so this method behaves the same but uses a Span<byte>
// while still returning a string necessary for the IMfaToken model
static string CreateToken()
{
ReadOnlySpan<char> hexChars = "0123456789ABCDEF";
Span<char> chars = stackalloc char[128];
Span<byte> buffer = stackalloc byte[64];
RandomNumberGenerator.GetBytes(buffer);
for (var index = 0; index < buffer.Length; index++)
{
int byteValue = buffer[index];
chars[index * 2] = hexChars[byteValue >> 4];
chars[index * 2 + 1] = hexChars[byteValue & 0xF];
}
return chars.ToString();
}
}
/// <inheritdoc />
public void DeleteToken(string token)
{
if (token is null)
{
throw new ArgumentNullException(nameof(token));
}
_tokenCache.TryRemove(token, out _);
}
/// <inheritdoc />
public bool TryGetUser(Guid id, [NotNullWhen(true)] out IUser? user)
{
using WebContext context = _dbContextFactory.CreateDbContext();
user = context.Users.FirstOrDefault(u => u.Id == id);
return user is not null;
}
/// <inheritdoc />
public bool VerifyLogin(string email, string password, [NotNullWhen(true)] out IUser? user)
{
using WebContext context = _dbContextFactory.CreateDbContext();
user = context.Users.FirstOrDefault(u => u.EmailAddress == email);
if (user is not null && !BC.Verify(password, ((User)user).Password))
{
user = null;
}
return user is not null;
}
/// <inheritdoc />
public MfaRequestResult VerifyMfaRequest(string token, string totp, out IUser? user)
{
if (token is null)
{
throw new ArgumentNullException(nameof(token));
}
if (totp is null)
{
throw new ArgumentNullException(nameof(totp));
}
user = null;
if (!_tokenCache.TryGetValue(token, out MfaToken? mfaToken))
{
return MfaRequestResult.TokenExpired;
}
if (!mfaToken.User.TestTotp(totp))
{
mfaToken.Attempts++;
if (mfaToken.Attempts == 4)
{
return MfaRequestResult.TooManyAttempts;
}
return MfaRequestResult.InvalidTotp;
}
user = mfaToken.User;
return MfaRequestResult.Success;
}
/// <inheritdoc />
public override Task StopAsync(CancellationToken cancellationToken)
{
_tokenClearTimer.Stop();
return base.StopAsync(cancellationToken);
}
/// <inheritdoc />
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
ClearTokens();
_tokenClearTimer.Start();
return Task.CompletedTask;
}
}

View File

@ -0,0 +1,43 @@
using Markdig.Helpers;
using Markdig.Syntax;
namespace OliverBooth.Extensions.Markdig.Markdown.Callout;
/// <summary>
/// Represents a callout block.
/// </summary>
internal sealed class CalloutBlock : QuoteBlock
{
/// <summary>
/// Initializes a new instance of the <see cref="CalloutBlock" /> class.
/// </summary>
/// <param name="type">The type of the callout.</param>
public CalloutBlock(StringSlice type) : base(null)
{
Type = type;
}
/// <summary>
/// Gets or sets a value indicating whether this callout is foldable.
/// </summary>
/// <value><see langword="true" /> if this callout is foldable; otherwise, <see langword="false" />.</value>
public bool Foldable { get; set; }
/// <summary>
/// Gets or sets the title of the callout.
/// </summary>
/// <value>The title of the callout.</value>
public StringSlice Title { get; set; }
/// <summary>
/// Gets or sets the trailing whitespace trivia.
/// </summary>
/// <value>The trailing whitespace trivia.</value>
public StringSlice TrailingWhitespaceTrivia { get; set; }
/// <summary>
/// Gets or sets the type of the callout.
/// </summary>
/// <value>The type of the callout.</value>
public StringSlice Type { get; set; }
}

View File

@ -0,0 +1,32 @@
using Markdig;
using Markdig.Parsers.Inlines;
using Markdig.Renderers;
using Markdig.Renderers.Html;
namespace OliverBooth.Extensions.Markdig.Markdown.Callout;
/// <summary>
/// Extension for adding Obsidian-style callouts to a Markdown pipeline.
/// </summary>
internal sealed class CalloutExtension : IMarkdownExtension
{
/// <inheritdoc />
public void Setup(MarkdownPipelineBuilder pipeline)
{
var parser = pipeline.InlineParsers.Find<CalloutInlineParser>();
if (parser is null)
{
pipeline.InlineParsers.InsertBefore<LinkInlineParser>(new CalloutInlineParser());
}
}
/// <inheritdoc />
public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer)
{
var blockRenderer = renderer.ObjectRenderers.FindExact<CalloutRenderer>();
if (blockRenderer is null)
{
renderer.ObjectRenderers.InsertBefore<QuoteBlockRenderer>(new CalloutRenderer(pipeline));
}
}
}

View File

@ -0,0 +1,176 @@
using System.Reflection;
using Cysharp.Text;
using Markdig.Helpers;
using Markdig.Parsers;
using Markdig.Renderers.Html;
using Markdig.Syntax;
namespace OliverBooth.Extensions.Markdig.Markdown.Callout;
/// <summary>
/// An inline parser for Obsidian-style callouts (<c>[!NOTE]</c> etc.)
/// </summary>
internal sealed class CalloutInlineParser : InlineParser
{
// ugly hack to access internal method
private static readonly MethodInfo ReplaceParentContainerMethod =
typeof(InlineProcessor).GetMethod("ReplaceParentContainer", BindingFlags.Instance | BindingFlags.NonPublic)!;
/// <summary>
/// Initializes a new instance of the <see cref="CalloutInlineParser" /> class.
/// </summary>
public CalloutInlineParser()
{
OpeningCharacters = ['['];
}
/// <inheritdoc />
public override bool Match(InlineProcessor processor, ref StringSlice slice)
{
// We expect the alert to be the first child of a quote block. Example:
// > [!NOTE]
// > This is a note
if (processor.Block is not ParagraphBlock { Parent: QuoteBlock quoteBlock } paragraphBlock ||
paragraphBlock.Inline?.FirstChild != null)
{
return false;
}
StringSlice cache = slice;
char current = slice.NextChar();
if (current != '!')
{
slice = cache;
return false;
}
current = slice.NextChar(); // skip !
int start = slice.Start;
int end = start;
while (current.IsAlphaUpper())
{
end = slice.Start;
current = slice.NextChar();
}
if (current != ']' || start == end)
{
slice = cache;
return false;
}
var type = new StringSlice(slice.Text, start, end);
current = slice.NextChar(); // skip ]
start = slice.Start;
bool fold = false;
if (current == '-')
{
fold = true;
current = slice.NextChar(); // skip -
start = slice.Start;
}
ReadTitle(current, ref slice, out StringSlice title, out end);
var callout = new CalloutBlock(type)
{
Foldable = fold,
Span = quoteBlock.Span,
TrailingWhitespaceTrivia = new StringSlice(slice.Text, start, end),
Line = quoteBlock.Line,
Column = quoteBlock.Column,
Title = title
};
AddAttributes(callout, type);
ReplaceQuoteBlock(processor, quoteBlock, callout);
return true;
}
private static void ReadTitle(char startChar, ref StringSlice slice, out StringSlice title, out int end)
{
using Utf16ValueStringBuilder builder = ZString.CreateStringBuilder();
char current = startChar;
while (true)
{
if (current is not ('\0' or '\r' or '\n'))
{
builder.Append(current);
current = slice.NextChar();
continue;
}
end = slice.Start;
if (HandleCharacter(ref slice, ref end, ref current))
{
continue;
}
break;
}
title = new StringSlice(builder.ToString(), 0, builder.Length);
}
private static bool HandleCharacter(ref StringSlice slice, ref int end, ref char current)
{
switch (current)
{
case '\r':
current = slice.NextChar(); // skip \r
if (current is not ('\0' or '\n'))
{
return true;
}
end = slice.Start;
if (current == '\n')
{
slice.NextChar(); // skip \n
}
break;
case '\n':
slice.NextChar(); // skip \n
break;
}
return false;
}
private static void AddAttributes(IMarkdownObject callout, StringSlice type)
{
HtmlAttributes attributes = callout.GetAttributes();
attributes.AddClass("callout");
attributes.AddProperty("data-callout", type.AsSpan().ToString().ToLowerInvariant());
}
private static void ReplaceQuoteBlock(InlineProcessor processor, QuoteBlock quoteBlock, CalloutBlock callout)
{
ContainerBlock? parentQuoteBlock = quoteBlock.Parent;
if (parentQuoteBlock is null)
{
return;
}
int indexOfQuoteBlock = parentQuoteBlock.IndexOf(quoteBlock);
parentQuoteBlock[indexOfQuoteBlock] = callout;
while (quoteBlock.Count > 0)
{
var block = quoteBlock[0];
quoteBlock.RemoveAt(0);
callout.Add(block);
}
ReplaceParentContainerMethod.Invoke(processor, [quoteBlock, callout]);
// ReplaceParentContainer(processor, quoteBlock, callout);
}
}

View File

@ -0,0 +1,119 @@
using HtmlAgilityPack;
using Humanizer;
using Markdig;
using Markdig.Renderers;
using Markdig.Renderers.Html;
namespace OliverBooth.Extensions.Markdig.Markdown.Callout;
/// <summary>
/// Represents an HTML renderer which renders a <see cref="CalloutBlock" />.
/// </summary>
internal sealed class CalloutRenderer : HtmlObjectRenderer<CalloutBlock>
{
private readonly MarkdownPipeline _pipeline;
private static readonly Dictionary<string, string> CalloutTypes = new()
{
["NOTE"] = "pencil",
["ABSTRACT"] = "clipboard-list",
["INFO"] = "info",
["TODO"] = "circle-check",
["TIP"] = "flame",
["IMPORTANT"] = "flame",
["SUCCESS"] = "check",
["QUESTION"] = "circle-help",
["WARNING"] = "triangle-alert",
["FAILURE"] = "x",
["DANGER"] = "zap",
["BUG"] = "bug",
["EXAMPLE"] = "list",
["CITE"] = "quote",
["UPDATE"] = "calendar-check",
};
public CalloutRenderer(MarkdownPipeline pipeline)
{
_pipeline = pipeline;
}
/// <inheritdoc />
protected override void Write(HtmlRenderer renderer, CalloutBlock block)
{
renderer.EnsureLine();
if (renderer.EnableHtmlForBlock)
{
RenderAsHtml(renderer, block, _pipeline);
}
else
{
RenderAsText(renderer, block);
}
renderer.EnsureLine();
}
private static void RenderAsHtml(HtmlRenderer renderer, CalloutBlock block, MarkdownPipeline pipeline)
{
string title = block.Title.Text;
ReadOnlySpan<char> type = block.Type.AsSpan();
Span<char> upperType = stackalloc char[type.Length];
type.ToUpperInvariant(upperType);
if (!CalloutTypes.TryGetValue(upperType.ToString(), out string? lucideClass))
{
lucideClass = "pencil";
}
var typeString = type.ToString().ToLowerInvariant();
renderer.Write($"<div class=\"callout\" data-callout=\"{typeString}\"");
if (block.Foldable)
{
renderer.Write(" data-callout-fold=\"true\"");
}
renderer.Write('>');
renderer.Write("<div class=\"callout-title\"><i data-lucide=\"");
renderer.Write(lucideClass);
renderer.Write("\"></i> ");
string calloutTitle = title.Length == 0 ? typeString.Humanize(LetterCasing.Sentence) : title;
WriteTitle(renderer, pipeline, calloutTitle);
if (block.Foldable)
{
renderer.Write("<span class=\"callout-fold\"><i data-lucide=\"chevron-down\"></i></span>");
}
renderer.WriteLine("</div>");
renderer.Write("<div class=\"callout-content\">");
renderer.WriteChildren(block);
renderer.WriteLine("</div>");
renderer.WriteLine("</div>");
renderer.EnsureLine();
}
private static void WriteTitle(TextRendererBase renderer, MarkdownPipeline pipeline, string calloutTitle)
{
string html = global::Markdig.Markdown.ToHtml(calloutTitle, pipeline);
var document = new HtmlDocument();
document.LoadHtml(html);
if (document.DocumentNode.FirstChild is { Name: "p" } child)
{
// ugly hack to remove <p> tag generated by Markdig
document.DocumentNode.InnerHtml = child.InnerHtml;
}
document.Save(renderer.Writer);
}
private static void RenderAsText(HtmlRenderer renderer, CalloutBlock block)
{
string title = block.Title.Text;
ReadOnlySpan<char> type = block.Type.AsSpan();
renderer.WriteLine(title.Length == 0 ? type.ToString().ToUpperInvariant() : title.ToUpperInvariant());
renderer.WriteChildren(block);
renderer.EnsureLine();
}
}

View File

@ -1,8 +1,8 @@
using Markdig;
using Markdig.Renderers;
using OliverBooth.Common.Services;
using OliverBooth.Extensions.Markdig.Services;
namespace OliverBooth.Common.Markdown.Template;
namespace OliverBooth.Extensions.Markdig.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.Template;
namespace OliverBooth.Extensions.Markdig.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.Template;
namespace OliverBooth.Extensions.Markdig.Markdown.Template;
/// <summary>
/// Represents a Markdown inline parser that handles MediaWiki-style templates.
@ -17,7 +17,7 @@ public sealed class TemplateInlineParser : InlineParser
/// </summary>
public TemplateInlineParser()
{
OpeningCharacters = ['{'];
OpeningCharacters = new[] { '{' };
}
/// <inheritdoc />

View File

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

View File

@ -1,7 +1,7 @@
using Markdig;
using Markdig.Renderers;
namespace OliverBooth.Markdown.Timestamp;
namespace OliverBooth.Extensions.Markdig.Markdown.Timestamp;
/// <summary>
/// Represents a Markdig extension that supports Discord-style timestamps.

View File

@ -1,4 +1,4 @@
namespace OliverBooth.Markdown.Timestamp;
namespace OliverBooth.Extensions.Markdig.Markdown.Timestamp;
/// <summary>
/// An enumeration of timestamp formats.

View File

@ -1,6 +1,6 @@
using Markdig.Syntax.Inlines;
namespace OliverBooth.Markdown.Timestamp;
namespace OliverBooth.Extensions.Markdig.Markdown.Timestamp;
/// <summary>
/// Represents a Markdown inline element that contains a timestamp.

View File

@ -1,7 +1,7 @@
using Markdig.Helpers;
using Markdig.Parsers;
namespace OliverBooth.Markdown.Timestamp;
namespace OliverBooth.Extensions.Markdig.Markdown.Timestamp;
/// <summary>
/// Represents a Markdown inline parser that matches Discord-style timestamps.
@ -13,7 +13,7 @@ public sealed class TimestampInlineParser : InlineParser
/// </summary>
public TimestampInlineParser()
{
OpeningCharacters = ['<'];
OpeningCharacters = new[] { '<' };
}
/// <inheritdoc />
@ -56,11 +56,7 @@ public sealed class TimestampInlineParser : InlineParser
timestamp = default;
format = default;
if (!source.StartsWith("<t:"))
{
return false;
}
if (!source.StartsWith("<t:")) return false;
timestamp = source[3..];
if (timestamp.IndexOf('>') == -1)

View File

@ -3,7 +3,7 @@ using Humanizer;
using Markdig.Renderers;
using Markdig.Renderers.Html;
namespace OliverBooth.Markdown.Timestamp;
namespace OliverBooth.Extensions.Markdig.Markdown.Timestamp;
/// <summary>
/// Represents a Markdown object renderer that renders <see cref="TimestampInline" /> elements.

View File

@ -0,0 +1,56 @@
using Markdig;
using OliverBooth.Extensions.Markdig.Markdown.Callout;
using OliverBooth.Extensions.Markdig.Markdown.Template;
using OliverBooth.Extensions.Markdig.Services;
namespace OliverBooth.Extensions.Markdig;
/// <summary>
/// Extension methods for <see cref="MarkdownPipelineBuilder" />.
/// </summary>
public static class MarkdownPipelineExtensions
{
/// <summary>
/// Enables the use of Obsidian-style callouts in this pipeline.
/// </summary>
/// <param name="builder">The Markdig markdown pipeline builder.</param>
/// <returns>The modified Markdig markdown pipeline builder.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder" /> is <see langword="null" />.</exception>
public static MarkdownPipelineBuilder UseCallouts(this MarkdownPipelineBuilder builder)
{
if (builder is null)
{
throw new ArgumentNullException(nameof(builder));
}
builder.Extensions.AddIfNotAlready<CalloutExtension>();
return builder;
}
/// <summary>
/// Enables the use of Wiki-style templates in this pipeline.
/// </summary>
/// <param name="builder">The Markdig markdown pipeline builder.</param>
/// <param name="templateService">The template service responsible for fetching and rendering templates.</param>
/// <returns>The modified Markdig markdown pipeline builder.</returns>
/// <exception cref="ArgumentNullException">
/// <para><paramref name="builder" /> is <see langword="null" />.</para>
/// -or-
/// <para><paramref name="templateService" /> is <see langword="null" />.</para>
/// </exception>
public static MarkdownPipelineBuilder UseTemplates(this MarkdownPipelineBuilder builder, ITemplateService templateService)
{
if (builder is null)
{
throw new ArgumentNullException(nameof(builder));
}
if (templateService is null)
{
throw new ArgumentNullException(nameof(templateService));
}
builder.Use(new TemplateExtension(templateService));
return builder;
}
}

View File

@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Markdig" Version="0.36.2"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OliverBooth.Common\OliverBooth.Common.csproj"/>
</ItemGroup>
</Project>

View File

@ -1,8 +1,8 @@
using System.Diagnostics.CodeAnalysis;
using OliverBooth.Common.Data.Web;
using OliverBooth.Common.Markdown.Template;
using OliverBooth.Extensions.Markdig.Markdown.Template;
namespace OliverBooth.Common.Services;
namespace OliverBooth.Extensions.Markdig.Services;
/// <summary>
/// Represents a service that renders MediaWiki-style templates.

View File

@ -1,7 +1,7 @@
using System.Globalization;
using SmartFormat.Core.Extensions;
namespace OliverBooth.Common.Formatting;
namespace OliverBooth.Extensions.SmartFormat;
/// <summary>
/// Represents a SmartFormat formatter that formats a date.
@ -18,17 +18,13 @@ public sealed class DateFormatter : IFormatter
public bool TryEvaluateFormat(IFormattingInfo formattingInfo)
{
if (formattingInfo.CurrentValue is not string value)
{
return false;
}
if (!DateTime.TryParseExact(value, "yyyy-MM-dd",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out DateTime date))
{
return false;
}
formattingInfo.Write(date.ToString(formattingInfo.Format?.ToString(), CultureInfo.InvariantCulture));

Some files were not shown because too many files have changed in this diff Show More