Compare commits
312 Commits
e35a6e5c15
...
1200318326
Author | SHA1 | Date | |
---|---|---|---|
1200318326 | |||
40d8052116 | |||
c3706213f7 | |||
e65e4aeeb6 | |||
33c3b434d7 | |||
00aed04181 | |||
5644dfd2e3 | |||
f912fa580d | |||
bcb2e9292a | |||
4f8ab1db4f | |||
3a7d807ac4 | |||
fd2ecf0b5c | |||
e0b236831b | |||
b04e63a8a3 | |||
a0fd48e6ca | |||
f48713c470 | |||
856c33a74f | |||
dd2a0c027b | |||
4b3e345a1b | |||
6b1a75bfcc | |||
dede552729 | |||
dd111cb0de | |||
3076f58485 | |||
5283985026 | |||
a9c4b3a144 | |||
2ea52759b8 | |||
7cad3204df | |||
eb152aaa09 | |||
a75536d08b | |||
08eed3c71e | |||
1a20749809 | |||
eed1e3ad8d | |||
6ef492016c | |||
bb088e3107 | |||
5558aecb5a | |||
39b455caf0 | |||
9885bfaed9 | |||
73f5e4e4a2 | |||
d114870f87 | |||
2fd4b704cd | |||
7ae8a749d2 | |||
ffaa2b2fa4 | |||
0e583de316 | |||
06fd256ec8 | |||
9295c4a848 | |||
70bf8aca19 | |||
bcbf963cd8 | |||
7dc9c4c6f7 | |||
e5fd4b106e | |||
1c044a9c96 | |||
b36a3207ca | |||
f18ae5eba4 | |||
9e2fa951f1 | |||
8202bb7440 | |||
1e1f67b9b4 | |||
09f3535d77 | |||
20eabeeb1e | |||
fe4701c1bf | |||
d9c6034aa0 | |||
7ee9d3637c | |||
1cdad4c17c | |||
b9e2597bc0 | |||
e7cbe0330b | |||
9ce4b844fe | |||
c3e64a6cde | |||
adf9e63008 | |||
fdea721f4f | |||
eb2a63e136 | |||
193b2486a1 | |||
dc83309db7 | |||
bbc76bc305 | |||
369436ccce | |||
67d89c1831 | |||
0a9c2e82d5 | |||
be44fb4b4b | |||
9475205196 | |||
f878bff8f3 | |||
a84f537dc1 | |||
7495da56cb | |||
6bbdd0a74d | |||
287af40501 | |||
bd5fd6114a | |||
f60b9c754a | |||
692d688dc3 | |||
58799594ae | |||
ad59c3190a | |||
43f0b38fd2 | |||
aca79b0e69 | |||
617f58afad | |||
1432c8e0f1 | |||
419aae741d | |||
4c86a43a84 | |||
43c3670a40 | |||
904ea689a6 | |||
5ecd915d72 | |||
a6a0adc419 | |||
f49b8aee9c | |||
a55c657d91 | |||
86bbf803b5 | |||
0b7218b11a | |||
e8bc50bbdf | |||
b3fd6e9420 | |||
67231c86af | |||
9b9143632a | |||
641313f97a | |||
47b648f327 | |||
6f7fa67135 | |||
034bd66b29 | |||
9d0e16abc1 | |||
415726cdcd | |||
0aee2aafbc | |||
944fc5ced3 | |||
597d7c8b4c | |||
cd0f38764d | |||
7bd1c5a45a | |||
e9d9836238 | |||
37a35d5aab | |||
049601a6fb | |||
54f3706ba0 | |||
e060ab4dea | |||
1fe65aed3b | |||
143131b413 | |||
1a726b4962 | |||
becd70c865 | |||
1bdd2a04f0 | |||
fb8848270f | |||
2844904723 | |||
19a398d694 | |||
eb2edcb3f6 | |||
2d4d6d3823 | |||
0ecef1a547 | |||
c2deccafae | |||
3c6a2209c2 | |||
31794a1238 | |||
69e1279a8b | |||
bb0483d4ae | |||
682b7c2a87 | |||
69963a5b81 | |||
bea35a2015 | |||
ca31a63bb7 | |||
cbb7d07844 | |||
1c73ada81c | |||
7219c948e6 | |||
95b7ed0ae7 | |||
06fab21cd5 | |||
3e3f074e63 | |||
f048c619f3 | |||
b2a7bf3536 | |||
e3702878cd | |||
4cc9efce42 | |||
d1eeea3c85 | |||
3e20e41565 | |||
d3958fc22c | |||
159e1ad65d | |||
9d46d6495e | |||
d6c24d80c1 | |||
74e7187cba | |||
8dd4468c1a | |||
dcbc402bfb | |||
cf615e1e81 | |||
086a8a665c | |||
cf2a5c2ffb | |||
e939174040 | |||
b4c991e44f | |||
ab5277bacb | |||
5bb6463a4b | |||
4244f9f014 | |||
8a3061f23a | |||
506347ce9c | |||
d67955f28a | |||
94a1ee00e1 | |||
28f310e315 | |||
f3ad04ff1f | |||
42af5ebcdd | |||
522caa6add | |||
350247806d | |||
cdc6b1df84 | |||
221a6f0007 | |||
87c54fa5a4 | |||
217c1d660e | |||
3868fcbaa8 | |||
1de869c6f0 | |||
fa17f63b82 | |||
fb6eabf55f | |||
9ef9e6ca2c | |||
085bdafda2 | |||
f989b4c02f | |||
aa69713e49 | |||
cb331ff54f | |||
20656e74e8 | |||
2036970fa2 | |||
95dd7e51e5 | |||
d11e3f616b | |||
e64d8b47b8 | |||
7279c448da | |||
fa51e0a189 | |||
434c61d7fa | |||
4e032c3aa5 | |||
738bf1f3ba | |||
5c55318577 | |||
0b9841a724 | |||
190e247067 | |||
e3b40a94c0 | |||
290d261771 | |||
42d1115df4 | |||
a138c38009 | |||
26b022f7ba | |||
2e17daea52 | |||
07071dc7a5 | |||
8a1cd689ea | |||
6743918f44 | |||
030e5fdd3d | |||
a6afe46891 | |||
6d8a1ac5b9 | |||
dbbc18b8a6 | |||
2d64bccc50 | |||
0120ac6dee | |||
68bff36fa6 | |||
aee4052954 | |||
6af41cba5a | |||
da5fe30c7a | |||
0fa43704ea | |||
77288a0ab5 | |||
4d10fd4ea1 | |||
50a25dad6f | |||
2ef1e47ece | |||
aaafcaf760 | |||
983e636635 | |||
56a8ba7368 | |||
9de1a84446 | |||
95a5a9e93b | |||
96ec83e525 | |||
299e315ddd | |||
f14239bcfb | |||
7f4ef10960 | |||
6b18b36b96 | |||
3c62a42d32 | |||
ea656f6513 | |||
6583a8eba6 | |||
204269396e | |||
2df24a99e7 | |||
a8158611b8 | |||
7abc44a58c | |||
6d40452fb3 | |||
84814da85b | |||
69a6f4a3af | |||
4a94a404b3 | |||
b584bb84a2 | |||
83e5757429 | |||
79a45643cb | |||
80e0f04e93 | |||
852df0acf2 | |||
6b864d5ab3 | |||
8f345e493a | |||
3ec44a59b4 | |||
5b524337ca | |||
81cd1dcf1d | |||
8768a90d48 | |||
951e44743f | |||
c62a939f2e | |||
1377ed012c | |||
b9e7e938ba | |||
6524a4f618 | |||
d69ef231eb | |||
e2ecc6dc57 | |||
3db59d6ca2 | |||
12309c0bf1 | |||
e5f01f66a9 | |||
cebaac553c | |||
6f3961901e | |||
8b2f0fb454 | |||
0c594ac306 | |||
2aa218d105 | |||
480363dd5a | |||
bd999f0ed8 | |||
ba8e186cb5 | |||
518ea1b933 | |||
d24f9d3996 | |||
8ad324227a | |||
d7d3cb6986 | |||
060514aca2 | |||
47eeb276c5 | |||
494a85a447 | |||
bcee08347a | |||
b2ada7d720 | |||
75eed18bc8 | |||
e93db2c7a0 | |||
6205648e53 | |||
a495973c44 | |||
bb4a0238be | |||
057c22d9a7 | |||
aeeb7dcfa5 | |||
6ed7097c0c | |||
55ee3ba5a9 | |||
b4d8b4636c | |||
485fe2bc0e | |||
33faa4365d | |||
cd44b9cf2a | |||
185d035388 | |||
f077c8f6fc | |||
ddb7a3c1f2 | |||
b2765b002a | |||
f2da5f9363 | |||
46008c2acb | |||
d24b492b91 | |||
c87223dcdb | |||
1279bfb82e | |||
a48fd2d625 | |||
567a47c62e | |||
6c04fd1bf1 | |||
2065832f58 | |||
5d561c99db |
@ -1,4 +1,4 @@
|
||||
**/.dockerignore
|
||||
**/.dockerignore
|
||||
**/.env
|
||||
**/.git
|
||||
**/.gitignore
|
||||
|
132
.gitignore
vendored
132
.gitignore
vendored
@ -4,6 +4,7 @@ project.lock.json
|
||||
.DS_Store
|
||||
*.pyc
|
||||
nupkg/
|
||||
tmp/
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode
|
||||
@ -35,3 +36,134 @@ msbuild.wrn
|
||||
|
||||
# Visual Studio 2015
|
||||
.vs/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
20
Dockerfile
20
Dockerfile
@ -1,20 +0,0 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base
|
||||
WORKDIR /app
|
||||
EXPOSE 80
|
||||
EXPOSE 443
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build
|
||||
WORKDIR /src
|
||||
COPY ["oliverbooth.dev/oliverbooth.dev.csproj", "oliverbooth.dev/"]
|
||||
RUN dotnet restore "oliverbooth.dev/oliverbooth.dev.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/oliverbooth.dev"
|
||||
RUN dotnet build "oliverbooth.dev.csproj" -c Release -o /app/build
|
||||
|
||||
FROM build AS publish
|
||||
RUN dotnet publish "oliverbooth.dev.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=publish /app/publish .
|
||||
ENTRYPOINT ["dotnet", "oliverbooth.dev.dll"]
|
51
Gulpfile.js
Normal file
51
Gulpfile.js
Normal file
@ -0,0 +1,51 @@
|
||||
const gulp = require('gulp');
|
||||
const sass = require('gulp-sass')(require('node-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.default = compileSCSS;
|
||||
exports.default = gulp.parallel(compileSCSS, gulp.series(compileTS, bundleJS), copyCSS, copyJS, copyImages);
|
53
OliverBooth.sln
Normal file
53
OliverBooth.sln
Normal file
@ -0,0 +1,53 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OliverBooth", "OliverBooth\OliverBooth.csproj", "{A58A6FA3-480C-400B-822A-3786741BF39C}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{06B0C27F-3432-41D7-B103-47B8D0EE28CC}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
README.md = README.md
|
||||
package.json = package.json
|
||||
Gulpfile.js = Gulpfile.js
|
||||
tsconfig.json = tsconfig.json
|
||||
.dockerignore = .dockerignore
|
||||
docker-compose.yml = docker-compose.yml
|
||||
global.json = global.json
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{8A323E64-E41E-4780-99FD-17BF58961FB5}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "scss", "scss", "{822F528E-3CA7-4B7D-9250-BD248ADA7BAE}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
src\scss\app.scss = src\scss\app.scss
|
||||
src\scss\prism.vs.scss = src\scss\prism.vs.scss
|
||||
src\scss\prism.css = src\scss\prism.css
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ts", "ts", "{BB9F76AC-292A-4F47-809D-8BBBA6E0A048}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
src\ts\app.ts = src\ts\app.ts
|
||||
src\ts\prism.js = src\ts\prism.js
|
||||
src\ts\API.ts = src\ts\API.ts
|
||||
src\ts\BlogPost.ts = src\ts\BlogPost.ts
|
||||
src\ts\Author.ts = src\ts\Author.ts
|
||||
src\ts\TimeUtility.ts = src\ts\TimeUtility.ts
|
||||
src\ts\UI.ts = src\ts\UI.ts
|
||||
src\ts\Input.ts = src\ts\Input.ts
|
||||
src\ts\BlogUrl.ts = src\ts\BlogUrl.ts
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{A58A6FA3-480C-400B-822A-3786741BF39C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A58A6FA3-480C-400B-822A-3786741BF39C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A58A6FA3-480C-400B-822A-3786741BF39C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A58A6FA3-480C-400B-822A-3786741BF39C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{822F528E-3CA7-4B7D-9250-BD248ADA7BAE} = {8A323E64-E41E-4780-99FD-17BF58961FB5}
|
||||
{BB9F76AC-292A-4F47-809D-8BBBA6E0A048} = {8A323E64-E41E-4780-99FD-17BF58961FB5}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
2
OliverBooth.sln.DotSettings
Normal file
2
OliverBooth.sln.DotSettings
Normal file
@ -0,0 +1,2 @@
|
||||
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=localizer/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
1
OliverBooth/.gitignore
vendored
Normal file
1
OliverBooth/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
wwwroot
|
101
OliverBooth/Controllers/Blog/BlogApiController.cs
Normal file
101
OliverBooth/Controllers/Blog/BlogApiController.cs
Normal file
@ -0,0 +1,101 @@
|
||||
using Humanizer;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using OliverBooth.Data.Blog;
|
||||
using OliverBooth.Services;
|
||||
|
||||
namespace OliverBooth.Controllers.Blog;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a controller for the blog API.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/blog")]
|
||||
[Produces("application/json")]
|
||||
public sealed class BlogApiController : ControllerBase
|
||||
{
|
||||
private readonly IBlogPostService _blogPostService;
|
||||
private readonly IBlogUserService _userService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="BlogApiController" /> class.
|
||||
/// </summary>
|
||||
/// <param name="blogPostService">The <see cref="IBlogPostService" />.</param>
|
||||
/// <param name="userService">The <see cref="IBlogUserService" />.</param>
|
||||
public BlogApiController(IBlogPostService blogPostService, IBlogUserService userService)
|
||||
{
|
||||
_blogPostService = blogPostService;
|
||||
_userService = userService;
|
||||
}
|
||||
|
||||
[Route("count")]
|
||||
public IActionResult Count()
|
||||
{
|
||||
return Ok(new { count = _blogPostService.GetBlogPostCount() });
|
||||
}
|
||||
|
||||
[HttpGet("posts/{page:int?}")]
|
||||
public IActionResult GetAllBlogPosts(int page = 0)
|
||||
{
|
||||
const int itemsPerPage = 10;
|
||||
IReadOnlyList<IBlogPost> allPosts = _blogPostService.GetBlogPosts(page, itemsPerPage);
|
||||
return Ok(allPosts.Select(post => CreatePostObject(post)));
|
||||
}
|
||||
|
||||
[HttpGet("posts/tagged/{tag}/{page:int?}")]
|
||||
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)));
|
||||
}
|
||||
|
||||
[HttpGet("author/{id:guid}")]
|
||||
public IActionResult GetAuthor(Guid id)
|
||||
{
|
||||
if (!_userService.TryGetUser(id, out IUser? author)) return NotFound();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
id = author.Id,
|
||||
name = author.DisplayName,
|
||||
avatarUrl = author.AvatarUrl,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("post/{id:guid?}")]
|
||||
public IActionResult GetPost(Guid id)
|
||||
{
|
||||
if (!_blogPostService.TryGetPost(id, out IBlogPost? post)) return NotFound();
|
||||
return Ok(CreatePostObject(post, true));
|
||||
}
|
||||
|
||||
private object CreatePostObject(IBlogPost post, bool includeContent = false)
|
||||
{
|
||||
return new
|
||||
{
|
||||
id = post.Id,
|
||||
commentsEnabled = post.EnableComments,
|
||||
identifier = post.GetDisqusIdentifier(),
|
||||
author = post.Author.Id,
|
||||
title = post.Title,
|
||||
published = post.Published.ToUnixTimeSeconds(),
|
||||
formattedDate = post.Published.ToString("dddd, d MMMM yyyy HH:mm"),
|
||||
updated = post.Updated?.ToUnixTimeSeconds(),
|
||||
humanizedTimestamp = post.Updated?.Humanize() ?? post.Published.Humanize(),
|
||||
excerpt = _blogPostService.RenderExcerpt(post, out bool trimmed),
|
||||
content = includeContent ? _blogPostService.RenderPost(post) : null,
|
||||
trimmed,
|
||||
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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
83
OliverBooth/Controllers/Blog/RssController.cs
Normal file
83
OliverBooth/Controllers/Blog/RssController.cs
Normal file
@ -0,0 +1,83 @@
|
||||
using System.Xml.Serialization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using OliverBooth.Data.Blog;
|
||||
using OliverBooth.Data.Blog.Rss;
|
||||
using OliverBooth.Services;
|
||||
|
||||
namespace OliverBooth.Controllers.Blog;
|
||||
|
||||
[ApiController]
|
||||
[Route("blog/feed")]
|
||||
public class RssController : Controller
|
||||
{
|
||||
private readonly IBlogPostService _blogPostService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RssController" /> class.
|
||||
/// </summary>
|
||||
/// <param name="blogPostService">The <see cref="IBlogPostService" />.</param>
|
||||
public RssController(IBlogPostService blogPostService)
|
||||
{
|
||||
_blogPostService = blogPostService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Produces("application/rss+xml")]
|
||||
public IActionResult OnGet()
|
||||
{
|
||||
Response.ContentType = "application/rss+xml";
|
||||
|
||||
var baseUrl = $"https://{Request.Host}/blog";
|
||||
var blogItems = new List<BlogItem>();
|
||||
|
||||
foreach (IBlogPost post in _blogPostService.GetAllBlogPosts())
|
||||
{
|
||||
var url = $"{baseUrl}/{post.Published:yyyy/MM/dd}/{post.Slug}";
|
||||
string excerpt = _blogPostService.RenderPost(post);
|
||||
var description = $"{excerpt}<p><a href=\"{url}\">Read more...</a></p>";
|
||||
|
||||
var item = new BlogItem
|
||||
{
|
||||
Title = post.Title,
|
||||
Link = url,
|
||||
Comments = $"{url}#disqus_thread",
|
||||
Creator = post.Author.DisplayName,
|
||||
PubDate = post.Published.ToString("R"),
|
||||
Guid = post.WordPressId.HasValue ? $"{baseUrl}?p={post.WordPressId.Value}" : $"{baseUrl}?pid={post.Id}",
|
||||
Description = description
|
||||
};
|
||||
blogItems.Add(item);
|
||||
}
|
||||
|
||||
var rss = new BlogRoot
|
||||
{
|
||||
Channel = new BlogChannel
|
||||
{
|
||||
AtomLink = new AtomLink
|
||||
{
|
||||
Href = $"{baseUrl}/feed/",
|
||||
},
|
||||
Description = $"{baseUrl}/",
|
||||
LastBuildDate = DateTimeOffset.UtcNow.ToString("R"),
|
||||
Link = $"{baseUrl}/",
|
||||
Title = "Oliver Booth",
|
||||
Generator = $"{baseUrl}/",
|
||||
Items = blogItems
|
||||
}
|
||||
};
|
||||
|
||||
var serializer = new XmlSerializer(typeof(BlogRoot));
|
||||
var xmlNamespaces = new XmlSerializerNamespaces();
|
||||
xmlNamespaces.Add("content", "http://purl.org/rss/1.0/modules/content/");
|
||||
xmlNamespaces.Add("wfw", "http://wellformedweb.org/CommentAPI/");
|
||||
xmlNamespaces.Add("dc", "http://purl.org/dc/elements/1.1/");
|
||||
xmlNamespaces.Add("atom", "http://www.w3.org/2005/Atom");
|
||||
xmlNamespaces.Add("sy", "http://purl.org/rss/1.0/modules/syndication/");
|
||||
xmlNamespaces.Add("slash", "http://purl.org/rss/1.0/modules/slash/");
|
||||
|
||||
using var writer = new StreamWriter(Response.BodyWriter.AsStream());
|
||||
serializer.Serialize(writer, rss, xmlNamespaces);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
}
|
80
OliverBooth/Controllers/ContactController.cs
Normal file
80
OliverBooth/Controllers/ContactController.cs
Normal file
@ -0,0 +1,80 @@
|
||||
using MailKitSimplified.Sender.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
|
||||
namespace OliverBooth.Controllers;
|
||||
|
||||
[Controller]
|
||||
[Route("contact/submit")]
|
||||
public class ContactController : Controller
|
||||
{
|
||||
private readonly ILogger<ContactController> _logger;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IConfigurationSection _destination;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ContactController" /> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="configuration">The configuration.</param>
|
||||
public ContactController(ILogger<ContactController> logger, IConfiguration configuration)
|
||||
{
|
||||
_logger = logger;
|
||||
_configuration = configuration;
|
||||
_destination = configuration.GetSection("Mail").GetSection("Destination");
|
||||
}
|
||||
|
||||
[HttpGet("{_?}")]
|
||||
public IActionResult OnGet(string _)
|
||||
{
|
||||
_logger.LogWarning("Method GET for endpoint {Path} is not supported!", Request.Path);
|
||||
return RedirectToPage("/Contact/Index");
|
||||
}
|
||||
|
||||
[HttpPost("other")]
|
||||
public async Task<IActionResult> HandleForm()
|
||||
{
|
||||
if (!Request.HasFormContentType)
|
||||
{
|
||||
return RedirectToPage("/Contact/Index");
|
||||
}
|
||||
|
||||
IFormCollection form = Request.Form;
|
||||
StringValues name = form["name"];
|
||||
StringValues email = form["email"];
|
||||
StringValues subject = form["subject"];
|
||||
StringValues message = form["message"];
|
||||
|
||||
await using SmtpSender sender = CreateSender();
|
||||
try
|
||||
{
|
||||
await sender.WriteEmail
|
||||
.To("Oliver Booth", _destination.Get<string>())
|
||||
.From(name, email)
|
||||
.Subject($"[Contact via Website] {subject}")
|
||||
.BodyText(message)
|
||||
.SendAsync();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "Failed to send email");
|
||||
TempData["Success"] = false;
|
||||
return RedirectToPage("/Contact/Result");
|
||||
}
|
||||
|
||||
TempData["Success"] = true;
|
||||
return RedirectToPage("/Contact/Result");
|
||||
}
|
||||
|
||||
private SmtpSender CreateSender()
|
||||
{
|
||||
IConfigurationSection mailSection = _configuration.GetSection("Mail");
|
||||
string? mailServer = mailSection.GetSection("Server").Value;
|
||||
string? mailUsername = mailSection.GetSection("Username").Value;
|
||||
string? mailPassword = mailSection.GetSection("Password").Value;
|
||||
|
||||
var sender = SmtpSender.Create(mailServer);
|
||||
sender.SetCredential(mailUsername, mailPassword);
|
||||
return sender;
|
||||
}
|
||||
}
|
48
OliverBooth/Data/Blog/BlogContext.cs
Normal file
48
OliverBooth/Data/Blog/BlogContext.cs
Normal file
@ -0,0 +1,48 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OliverBooth.Data.Blog.Configuration;
|
||||
|
||||
namespace OliverBooth.Data.Blog;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a session with the blog database.
|
||||
/// </summary>
|
||||
internal sealed class BlogContext : DbContext
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="BlogContext" /> class.
|
||||
/// </summary>
|
||||
/// <param name="configuration">The configuration.</param>
|
||||
public BlogContext(IConfiguration configuration)
|
||||
{
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the collection of blog posts in the database.
|
||||
/// </summary>
|
||||
/// <value>The collection of blog posts.</value>
|
||||
public DbSet<BlogPost> BlogPosts { get; private set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the collection of users in the database.
|
||||
/// </summary>
|
||||
/// <value>The collection of users.</value>
|
||||
public DbSet<User> Users { get; private set; } = null!;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
string connectionString = _configuration.GetConnectionString("Blog") ?? string.Empty;
|
||||
ServerVersion serverVersion = ServerVersion.AutoDetect(connectionString);
|
||||
optionsBuilder.UseMySql(connectionString, serverVersion);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.ApplyConfiguration(new BlogPostConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new UserConfiguration());
|
||||
}
|
||||
}
|
108
OliverBooth/Data/Blog/BlogPost.cs
Normal file
108
OliverBooth/Data/Blog/BlogPost.cs
Normal file
@ -0,0 +1,108 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using SmartFormat;
|
||||
|
||||
namespace OliverBooth.Data.Blog;
|
||||
|
||||
/// <inheritdoc />
|
||||
internal sealed class BlogPost : IBlogPost
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[NotMapped]
|
||||
public IBlogAuthor Author { get; internal set; } = null!;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Body { get; internal set; } = string.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool EnableComments { get; internal set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Guid Id { get; private set; } = Guid.NewGuid();
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsRedirect { get; internal set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? Password { get; internal set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset Published { get; internal set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Uri? RedirectUrl { get; internal set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Slug { get; internal set; } = string.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags { get; internal set; } = ArraySegment<string>.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Title { get; internal 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>
|
||||
/// Gets the Disqus domain for the blog post.
|
||||
/// </summary>
|
||||
/// <returns>The Disqus domain.</returns>
|
||||
public string GetDisqusDomain()
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(DisqusDomain)
|
||||
? "https://oliverbooth.dev/blog"
|
||||
: Smart.Format(DisqusDomain, this);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetDisqusIdentifier()
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(DisqusIdentifier) ? $"post-{Id}" : Smart.Format(DisqusIdentifier, this);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetDisqusUrl()
|
||||
{
|
||||
string path = string.IsNullOrWhiteSpace(DisqusPath)
|
||||
? $"{Published:yyyy/MM/dd}/{Slug}/"
|
||||
: Smart.Format(DisqusPath, this);
|
||||
|
||||
return $"{GetDisqusDomain()}/{path}";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetDisqusPostId()
|
||||
{
|
||||
return WordPressId?.ToString() ?? Id.ToString();
|
||||
}
|
||||
}
|
22
OliverBooth/Data/Blog/BlogPostVisibility.cs
Normal file
22
OliverBooth/Data/Blog/BlogPostVisibility.cs
Normal file
@ -0,0 +1,22 @@
|
||||
namespace OliverBooth.Data.Blog;
|
||||
|
||||
/// <summary>
|
||||
/// An enumeration of the possible visibilities of a blog post.
|
||||
/// </summary>
|
||||
public enum BlogPostVisibility
|
||||
{
|
||||
/// <summary>
|
||||
/// The post is private and only visible to the author, or those with the password.
|
||||
/// </summary>
|
||||
Private,
|
||||
|
||||
/// <summary>
|
||||
/// The post is unlisted and only visible to those with the link.
|
||||
/// </summary>
|
||||
Unlisted,
|
||||
|
||||
/// <summary>
|
||||
/// The post is published and visible to everyone.
|
||||
/// </summary>
|
||||
Published
|
||||
}
|
37
OliverBooth/Data/Blog/Configuration/BlogPostConfiguration.cs
Normal file
37
OliverBooth/Data/Blog/Configuration/BlogPostConfiguration.cs
Normal file
@ -0,0 +1,37 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace OliverBooth.Data.Blog.Configuration;
|
||||
|
||||
internal sealed class BlogPostConfiguration : IEntityTypeConfiguration<BlogPost>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void Configure(EntityTypeBuilder<BlogPost> builder)
|
||||
{
|
||||
builder.ToTable("BlogPost");
|
||||
builder.HasKey(e => e.Id);
|
||||
|
||||
builder.Property(e => e.Id);
|
||||
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.Published).IsRequired();
|
||||
builder.Property(e => e.Updated).IsRequired(false);
|
||||
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());
|
||||
}
|
||||
}
|
21
OliverBooth/Data/Blog/Configuration/UserConfiguration.cs
Normal file
21
OliverBooth/Data/Blog/Configuration/UserConfiguration.cs
Normal file
@ -0,0 +1,21 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace OliverBooth.Data.Blog.Configuration;
|
||||
|
||||
internal sealed class UserConfiguration : IEntityTypeConfiguration<User>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void Configure(EntityTypeBuilder<User> builder)
|
||||
{
|
||||
builder.ToTable("User");
|
||||
builder.HasKey(e => e.Id);
|
||||
|
||||
builder.Property(e => e.Id).IsRequired();
|
||||
builder.Property(e => e.DisplayName).HasMaxLength(50).IsRequired();
|
||||
builder.Property(e => e.EmailAddress).HasMaxLength(255).IsRequired();
|
||||
builder.Property(e => e.Password).HasMaxLength(255).IsRequired();
|
||||
builder.Property(e => e.Salt).HasMaxLength(255).IsRequired();
|
||||
builder.Property(e => e.Registered).IsRequired();
|
||||
}
|
||||
}
|
32
OliverBooth/Data/Blog/IBlogAuthor.cs
Normal file
32
OliverBooth/Data/Blog/IBlogAuthor.cs
Normal file
@ -0,0 +1,32 @@
|
||||
namespace OliverBooth.Data.Blog;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the author of a blog post.
|
||||
/// </summary>
|
||||
public interface IBlogAuthor
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the URL of the author's avatar.
|
||||
/// </summary>
|
||||
/// <value>The URL of the author's avatar.</value>
|
||||
Uri AvatarUrl { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the display name of the author.
|
||||
/// </summary>
|
||||
/// <value>The display name of the author.</value>
|
||||
string DisplayName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the unique identifier of the author.
|
||||
/// </summary>
|
||||
/// <value>The unique identifier of the author.</value>
|
||||
Guid Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the URL of the author's avatar.
|
||||
/// </summary>
|
||||
/// <param name="size">The size of the avatar.</param>
|
||||
/// <returns>The URL of the author's avatar.</returns>
|
||||
Uri GetAvatarUrl(int size = 28);
|
||||
}
|
115
OliverBooth/Data/Blog/IBlogPost.cs
Normal file
115
OliverBooth/Data/Blog/IBlogPost.cs
Normal file
@ -0,0 +1,115 @@
|
||||
namespace OliverBooth.Data.Blog;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a blog post.
|
||||
/// </summary>
|
||||
public interface IBlogPost
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the author of the post.
|
||||
/// </summary>
|
||||
/// <value>The author of the post.</value>
|
||||
IBlogAuthor Author { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the body of the post.
|
||||
/// </summary>
|
||||
/// <value>The body of the post.</value>
|
||||
string Body { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether comments are enabled for the post.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// <see langword="true" /> if comments are enabled for the post; otherwise, <see langword="false" />.
|
||||
/// </value>
|
||||
bool EnableComments { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ID of the post.
|
||||
/// </summary>
|
||||
/// <value>The ID of the post.</value>
|
||||
Guid Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the post redirects to another URL.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// <see langword="true" /> if the post redirects to another URL; otherwise, <see langword="false" />.
|
||||
/// </value>
|
||||
bool IsRedirect { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the password of the post.
|
||||
/// </summary>
|
||||
/// <value>The password of the post.</value>
|
||||
string? Password { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the date and time the post was published.
|
||||
/// </summary>
|
||||
/// <value>The publication date and time.</value>
|
||||
DateTimeOffset Published { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the URL to which the post redirects.
|
||||
/// </summary>
|
||||
/// <value>The URL to which the post redirects, or <see langword="null" /> if the post does not redirect.</value>
|
||||
Uri? RedirectUrl { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the slug of the post.
|
||||
/// </summary>
|
||||
/// <value>The slug of the post.</value>
|
||||
string Slug { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tags of the post.
|
||||
/// </summary>
|
||||
/// <value>The tags of the post.</value>
|
||||
IReadOnlyList<string> Tags { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the title of the post.
|
||||
/// </summary>
|
||||
/// <value>The title of the post.</value>
|
||||
string Title { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the date and time the post was last updated.
|
||||
/// </summary>
|
||||
/// <value>The update date and time, or <see langword="null" /> if the post has not been updated.</value>
|
||||
DateTimeOffset? Updated { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the 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 URL for the post.
|
||||
/// </summary>
|
||||
/// <returns>The Disqus URL for the post.</returns>
|
||||
string GetDisqusUrl();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Disqus post ID for the post.
|
||||
/// </summary>
|
||||
/// <returns>The Disqus post ID for the post.</returns>
|
||||
string GetDisqusPostId();
|
||||
}
|
54
OliverBooth/Data/Blog/IUser.cs
Normal file
54
OliverBooth/Data/Blog/IUser.cs
Normal file
@ -0,0 +1,54 @@
|
||||
namespace OliverBooth.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);
|
||||
}
|
15
OliverBooth/Data/Blog/Rss/AtomLink.cs
Normal file
15
OliverBooth/Data/Blog/Rss/AtomLink.cs
Normal file
@ -0,0 +1,15 @@
|
||||
using System.Xml.Serialization;
|
||||
|
||||
namespace OliverBooth.Data.Blog.Rss;
|
||||
|
||||
public sealed class AtomLink
|
||||
{
|
||||
[XmlAttribute("href")]
|
||||
public string Href { get; set; } = default!;
|
||||
|
||||
[XmlAttribute("rel")]
|
||||
public string Rel { get; set; } = "self";
|
||||
|
||||
[XmlAttribute("type")]
|
||||
public string Type { get; set; } = "application/rss+xml";
|
||||
}
|
33
OliverBooth/Data/Blog/Rss/BlogChannel.cs
Normal file
33
OliverBooth/Data/Blog/Rss/BlogChannel.cs
Normal file
@ -0,0 +1,33 @@
|
||||
using System.Xml.Serialization;
|
||||
|
||||
namespace OliverBooth.Data.Blog.Rss;
|
||||
|
||||
public sealed class BlogChannel
|
||||
{
|
||||
[XmlElement("title")]
|
||||
public string Title { get; set; } = default!;
|
||||
|
||||
[XmlElement("link", Namespace = "http://www.w3.org/2005/Atom")]
|
||||
public AtomLink AtomLink { get; set; } = default!;
|
||||
|
||||
[XmlElement("link")]
|
||||
public string Link { get; set; } = default!;
|
||||
|
||||
[XmlElement("description")]
|
||||
public string Description { get; set; } = default!;
|
||||
|
||||
[XmlElement("lastBuildDate")]
|
||||
public string LastBuildDate { get; set; } = default!;
|
||||
|
||||
[XmlElement("updatePeriod", Namespace = "http://purl.org/rss/1.0/modules/syndication/")]
|
||||
public string UpdatePeriod { get; set; } = "hourly";
|
||||
|
||||
[XmlElement("updateFrequency", Namespace = "http://purl.org/rss/1.0/modules/syndication/")]
|
||||
public string UpdateFrequency { get; set; } = "1";
|
||||
|
||||
[XmlElement("generator")]
|
||||
public string Generator { get; set; } = default!;
|
||||
|
||||
[XmlElement("item")]
|
||||
public List<BlogItem> Items { get; set; } = new();
|
||||
}
|
27
OliverBooth/Data/Blog/Rss/BlogItem.cs
Normal file
27
OliverBooth/Data/Blog/Rss/BlogItem.cs
Normal file
@ -0,0 +1,27 @@
|
||||
using System.Xml.Serialization;
|
||||
|
||||
namespace OliverBooth.Data.Blog.Rss;
|
||||
|
||||
public sealed class BlogItem
|
||||
{
|
||||
[XmlElement("title")]
|
||||
public string Title { get; set; } = default!;
|
||||
|
||||
[XmlElement("link")]
|
||||
public string Link { get; set; } = default!;
|
||||
|
||||
[XmlElement("comments")]
|
||||
public string Comments { get; set; } = default!;
|
||||
|
||||
[XmlElement("creator", Namespace = "http://purl.org/dc/elements/1.1/")]
|
||||
public string Creator { get; set; } = default!;
|
||||
|
||||
[XmlElement("pubDate")]
|
||||
public string PubDate { get; set; } = default!;
|
||||
|
||||
[XmlElement("guid")]
|
||||
public BlogItemGuid Guid { get; set; } = default!;
|
||||
|
||||
[XmlElement("description")]
|
||||
public string Description { get; set; } = default!;
|
||||
}
|
18
OliverBooth/Data/Blog/Rss/BlogItemGuid.cs
Normal file
18
OliverBooth/Data/Blog/Rss/BlogItemGuid.cs
Normal file
@ -0,0 +1,18 @@
|
||||
using System.Xml.Serialization;
|
||||
|
||||
namespace OliverBooth.Data.Blog.Rss;
|
||||
|
||||
public struct BlogItemGuid
|
||||
{
|
||||
public BlogItemGuid()
|
||||
{
|
||||
}
|
||||
|
||||
[XmlAttribute("isPermaLink")]
|
||||
public bool IsPermaLink { get; set; } = false;
|
||||
|
||||
[XmlText]
|
||||
public string Value { get; set; } = default!;
|
||||
|
||||
public static implicit operator BlogItemGuid(string value) => new() { Value = value };
|
||||
}
|
13
OliverBooth/Data/Blog/Rss/BlogRoot.cs
Normal file
13
OliverBooth/Data/Blog/Rss/BlogRoot.cs
Normal file
@ -0,0 +1,13 @@
|
||||
using System.Xml.Serialization;
|
||||
|
||||
namespace OliverBooth.Data.Blog.Rss;
|
||||
|
||||
[XmlRoot("rss")]
|
||||
public sealed class BlogRoot
|
||||
{
|
||||
[XmlAttribute("version")]
|
||||
public string Version { get; set; } = default!;
|
||||
|
||||
[XmlElement("channel")]
|
||||
public BlogChannel Channel { get; set; } = default!;
|
||||
}
|
73
OliverBooth/Data/Blog/User.cs
Normal file
73
OliverBooth/Data/Blog/User.cs
Normal file
@ -0,0 +1,73 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Cysharp.Text;
|
||||
|
||||
namespace OliverBooth.Data.Blog;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a user.
|
||||
/// </summary>
|
||||
internal sealed class User : IUser, IBlogAuthor
|
||||
{
|
||||
/// <inheritdoc cref="IUser.AvatarUrl" />
|
||||
[NotMapped]
|
||||
public Uri AvatarUrl => GetAvatarUrl();
|
||||
|
||||
/// <inheritdoc />
|
||||
public string EmailAddress { get; set; } = string.Empty;
|
||||
|
||||
/// <inheritdoc cref="IUser.DisplayName" />
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
/// <inheritdoc cref="IUser.Id" />
|
||||
public Guid Id { get; private set; } = Guid.NewGuid();
|
||||
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset Registered { get; private set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the password hash.
|
||||
/// </summary>
|
||||
/// <value>The password hash.</value>
|
||||
internal string Password { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the salt used to hash the password.
|
||||
/// </summary>
|
||||
/// <value>The salt used to hash the password.</value>
|
||||
internal string Salt { get; set; } = string.Empty;
|
||||
|
||||
/// <inheritdoc cref="IUser.GetAvatarUrl" />
|
||||
public Uri GetAvatarUrl(int size = 28)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(EmailAddress))
|
||||
{
|
||||
return new Uri($"https://www.gravatar.com/avatar/0?size={size}");
|
||||
}
|
||||
|
||||
ReadOnlySpan<char> span = EmailAddress.AsSpan();
|
||||
int byteCount = Encoding.UTF8.GetByteCount(span);
|
||||
Span<byte> bytes = stackalloc byte[byteCount];
|
||||
Encoding.UTF8.GetBytes(span, bytes);
|
||||
|
||||
Span<byte> hash = stackalloc byte[16];
|
||||
MD5.TryHashData(bytes, hash, out _);
|
||||
|
||||
using Utf8ValueStringBuilder builder = ZString.CreateUtf8StringBuilder();
|
||||
Span<char> hex = stackalloc char[2];
|
||||
for (var index = 0; index < hash.Length; index++)
|
||||
{
|
||||
if (hash[index].TryFormat(hex, out _, "x2")) builder.Append(hex);
|
||||
else builder.Append("00");
|
||||
}
|
||||
|
||||
return new Uri($"https://www.gravatar.com/avatar/{builder}?size={size}");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TestCredentials(string password)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
28
OliverBooth/Data/Web/Configuration/ProjectConfiguration.cs
Normal file
28
OliverBooth/Data/Web/Configuration/ProjectConfiguration.cs
Normal file
@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace OliverBooth.Data.Web.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the configuration for the <see cref="Project" /> entity.
|
||||
/// </summary>
|
||||
internal sealed class ProjectConfiguration : IEntityTypeConfiguration<Project>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void Configure(EntityTypeBuilder<Project> builder)
|
||||
{
|
||||
builder.ToTable("Project");
|
||||
builder.HasKey(e => e.Id);
|
||||
|
||||
builder.Property(e => e.Id).IsRequired();
|
||||
builder.Property(e => e.Rank).IsRequired();
|
||||
builder.Property(e => e.Slug).IsRequired();
|
||||
builder.Property(e => e.Name).IsRequired();
|
||||
builder.Property(e => e.HeroUrl).IsRequired();
|
||||
builder.Property(e => e.Description).IsRequired();
|
||||
builder.Property(e => e.Status).HasConversion<EnumToStringConverter<ProjectStatus>>().IsRequired();
|
||||
builder.Property(e => e.RemoteUrl);
|
||||
builder.Property(e => e.RemoteTarget);
|
||||
}
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace OliverBooth.Data.Web.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the configuration for the <see cref="SiteConfiguration" /> entity.
|
||||
/// </summary>
|
||||
internal sealed class SiteConfigurationConfiguration : IEntityTypeConfiguration<SiteConfiguration>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void Configure(EntityTypeBuilder<SiteConfiguration> builder)
|
||||
{
|
||||
builder.ToTable("SiteConfig");
|
||||
builder.HasKey(x => x.Key);
|
||||
|
||||
builder.Property(x => x.Key).HasMaxLength(50).IsRequired();
|
||||
builder.Property(x => x.Value).HasMaxLength(1000);
|
||||
}
|
||||
}
|
20
OliverBooth/Data/Web/Configuration/TemplateConfiguration.cs
Normal file
20
OliverBooth/Data/Web/Configuration/TemplateConfiguration.cs
Normal file
@ -0,0 +1,20 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace OliverBooth.Data.Web.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the configuration for the <see cref="Template" /> entity.
|
||||
/// </summary>
|
||||
internal sealed class TemplateConfiguration : IEntityTypeConfiguration<Template>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Template> builder)
|
||||
{
|
||||
builder.ToTable("Template");
|
||||
builder.HasKey(e => new { e.Name, e.Variant });
|
||||
|
||||
builder.Property(e => e.Name).HasMaxLength(50).IsRequired();
|
||||
builder.Property(e => e.Variant).HasMaxLength(50).IsRequired();
|
||||
builder.Property(e => e.FormatString).IsRequired();
|
||||
}
|
||||
}
|
61
OliverBooth/Data/Web/IProject.cs
Normal file
61
OliverBooth/Data/Web/IProject.cs
Normal file
@ -0,0 +1,61 @@
|
||||
namespace OliverBooth.Data.Web;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a project.
|
||||
/// </summary>
|
||||
public interface IProject
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the description of the project.
|
||||
/// </summary>
|
||||
/// <value>The description of the project.</value>
|
||||
string Description { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the URL of the hero image.
|
||||
/// </summary>
|
||||
/// <value>The URL of the hero image.</value>
|
||||
string HeroUrl { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ID of the project.
|
||||
/// </summary>
|
||||
/// <value>The ID of the project.</value>
|
||||
Guid Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the project.
|
||||
/// </summary>
|
||||
/// <value>The name of the project.</value>
|
||||
string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the rank of the project.
|
||||
/// </summary>
|
||||
/// <value>The rank of the project.</value>
|
||||
int Rank { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the host of the project.
|
||||
/// </summary>
|
||||
/// <value>The host of the project.</value>
|
||||
string? RemoteTarget { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the URL of the project.
|
||||
/// </summary>
|
||||
/// <value>The URL of the project.</value>
|
||||
string? RemoteUrl { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the slug of the project.
|
||||
/// </summary>
|
||||
/// <value>The slug of the project.</value>
|
||||
string Slug { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the status of the project.
|
||||
/// </summary>
|
||||
/// <value>The status of the project.</value>
|
||||
ProjectStatus Status { get; }
|
||||
}
|
24
OliverBooth/Data/Web/ITemplate.cs
Normal file
24
OliverBooth/Data/Web/ITemplate.cs
Normal file
@ -0,0 +1,24 @@
|
||||
namespace OliverBooth.Data.Web;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a template.
|
||||
/// </summary>
|
||||
public interface ITemplate
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the format string.
|
||||
/// </summary>
|
||||
/// <value>The format string.</value>
|
||||
string FormatString { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the template.
|
||||
/// </summary>
|
||||
string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the variant of the template.
|
||||
/// </summary>
|
||||
/// <value>The variant of the template.</value>
|
||||
string Variant { get; }
|
||||
}
|
95
OliverBooth/Data/Web/Project.cs
Normal file
95
OliverBooth/Data/Web/Project.cs
Normal file
@ -0,0 +1,95 @@
|
||||
namespace OliverBooth.Data.Web;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a project.
|
||||
/// </summary>
|
||||
internal sealed class Project : IEquatable<Project>, IProject
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string Description { get; private set; } = string.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string HeroUrl { get; private set; } = string.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Guid Id { get; private set; } = Guid.NewGuid();
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name { get; private set; } = string.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
public int Rank { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? RemoteTarget { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? RemoteUrl { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Slug { get; private set; } = string.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ProjectStatus Status { get; private set; } = ProjectStatus.Ongoing;
|
||||
|
||||
/// <summary>
|
||||
/// Returns a value indicating whether two instances of <see cref="Project" /> are equal.
|
||||
/// </summary>
|
||||
/// <param name="left">The first instance of <see cref="Project" /> to compare.</param>
|
||||
/// <param name="right">The second instance of <see cref="Project" /> to compare.</param>
|
||||
/// <returns>
|
||||
/// <see langword="true" /> if <paramref name="left" /> and <paramref name="right" /> are equal; otherwise,
|
||||
/// <see langword="false" />.
|
||||
/// </returns>
|
||||
public static bool operator ==(Project? left, Project? right) => Equals(left, right);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a value indicating whether two instances of <see cref="Project" /> are not equal.
|
||||
/// </summary>
|
||||
/// <param name="left">The first instance of <see cref="Project" /> to compare.</param>
|
||||
/// <param name="right">The second instance of <see cref="Project" /> to compare.</param>
|
||||
/// <returns>
|
||||
/// <see langword="true" /> if <paramref name="left" /> and <paramref name="right" /> are not equal; otherwise,
|
||||
/// <see langword="false" />.
|
||||
/// </returns>
|
||||
public static bool operator !=(Project? left, Project? right) => !(left == right);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a value indicating whether this instance of <see cref="Project" /> is equal to another
|
||||
/// instance.
|
||||
/// </summary>
|
||||
/// <param name="other">An instance to compare with this instance.</param>
|
||||
/// <returns>
|
||||
/// <see langword="true" /> if <paramref name="other" /> is equal to this instance; otherwise,
|
||||
/// <see langword="false" />.
|
||||
/// </returns>
|
||||
public bool Equals(Project? other)
|
||||
{
|
||||
if (ReferenceEquals(null, other)) return false;
|
||||
if (ReferenceEquals(this, other)) return true;
|
||||
return Id.Equals(other.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a value indicating whether this instance is equal to a specified object.
|
||||
/// </summary>
|
||||
/// <param name="obj">An object to compare with this instance.</param>
|
||||
/// <returns>
|
||||
/// <see langword="true" /> if <paramref name="obj" /> is an instance of <see cref="Project" /> and equals the
|
||||
/// value of this instance; otherwise, <see langword="false" />.
|
||||
/// </returns>
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
return ReferenceEquals(this, obj) || obj is Project other && Equals(other);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the hash code for this instance.
|
||||
/// </summary>
|
||||
/// <returns>The hash code.</returns>
|
||||
public override int GetHashCode()
|
||||
{
|
||||
// ReSharper disable once NonReadonlyMemberInGetHashCode
|
||||
return Id.GetHashCode();
|
||||
}
|
||||
}
|
18
OliverBooth/Data/Web/ProjectStatus.cs
Normal file
18
OliverBooth/Data/Web/ProjectStatus.cs
Normal file
@ -0,0 +1,18 @@
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace OliverBooth.Data.Web;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the status of a project.
|
||||
/// </summary>
|
||||
public enum ProjectStatus
|
||||
{
|
||||
[Description("The project is currently being worked on.")]
|
||||
Ongoing,
|
||||
|
||||
[Description("The project is on an indefinite hiatus.")]
|
||||
Hiatus,
|
||||
|
||||
[Description("The project is no longer being worked on.")]
|
||||
Past
|
||||
}
|
80
OliverBooth/Data/Web/SiteConfiguration.cs
Normal file
80
OliverBooth/Data/Web/SiteConfiguration.cs
Normal file
@ -0,0 +1,80 @@
|
||||
namespace OliverBooth.Data.Web;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a site configuration item.
|
||||
/// </summary>
|
||||
public sealed class SiteConfiguration : IEquatable<SiteConfiguration>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the configuration item.
|
||||
/// </summary>
|
||||
/// <value>The name of the configuration item.</value>
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the value of the configuration item.
|
||||
/// </summary>
|
||||
/// <value>The value of the configuration item.</value>
|
||||
public string? Value { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns a value indicating whether two instances of <see cref="SiteConfiguration" /> are equal.
|
||||
/// </summary>
|
||||
/// <param name="left">The first instance of <see cref="SiteConfiguration" /> to compare.</param>
|
||||
/// <param name="right">The second instance of <see cref="SiteConfiguration" /> to compare.</param>
|
||||
/// <returns>
|
||||
/// <see langword="true" /> if <paramref name="left" /> and <paramref name="right" /> are equal; otherwise,
|
||||
/// <see langword="false" />.
|
||||
/// </returns>
|
||||
public static bool operator ==(SiteConfiguration? left, SiteConfiguration? right) => Equals(left, right);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a value indicating whether two instances of <see cref="SiteConfiguration" /> are not equal.
|
||||
/// </summary>
|
||||
/// <param name="left">The first instance of <see cref="SiteConfiguration" /> to compare.</param>
|
||||
/// <param name="right">The second instance of <see cref="SiteConfiguration" /> to compare.</param>
|
||||
/// <returns>
|
||||
/// <see langword="true" /> if <paramref name="left" /> and <paramref name="right" /> are not equal; otherwise,
|
||||
/// <see langword="false" />.
|
||||
/// </returns>
|
||||
public static bool operator !=(SiteConfiguration? left, SiteConfiguration? right) => !(left == right);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a value indicating whether this instance of <see cref="SiteConfiguration" /> is equal to another
|
||||
/// instance.
|
||||
/// </summary>
|
||||
/// <param name="other">An instance to compare with this instance.</param>
|
||||
/// <returns>
|
||||
/// <see langword="true" /> if <paramref name="other" /> is equal to this instance; otherwise,
|
||||
/// <see langword="false" />.
|
||||
/// </returns>
|
||||
public bool Equals(SiteConfiguration? other)
|
||||
{
|
||||
if (ReferenceEquals(null, other)) return false;
|
||||
if (ReferenceEquals(this, other)) return true;
|
||||
return Key == other.Key;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a value indicating whether this instance is equal to a specified object.
|
||||
/// </summary>
|
||||
/// <param name="obj">An object to compare with this instance.</param>
|
||||
/// <returns>
|
||||
/// <see langword="true" /> if <paramref name="obj" /> is an instance of <see cref="SiteConfiguration" /> and
|
||||
/// equals the value of this instance; otherwise, <see langword="false" />.
|
||||
/// </returns>
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
return ReferenceEquals(this, obj) || obj is SiteConfiguration other && Equals(other);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the hash code for this instance.
|
||||
/// </summary>
|
||||
/// <returns>The hash code.</returns>
|
||||
public override int GetHashCode()
|
||||
{
|
||||
// ReSharper disable once NonReadonlyMemberInGetHashCode
|
||||
return Key.GetHashCode();
|
||||
}
|
||||
}
|
77
OliverBooth/Data/Web/Template.cs
Normal file
77
OliverBooth/Data/Web/Template.cs
Normal file
@ -0,0 +1,77 @@
|
||||
namespace OliverBooth.Data.Web;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a MediaWiki-style template.
|
||||
/// </summary>
|
||||
public sealed class Template : ITemplate, IEquatable<Template>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string FormatString { get; internal set; } = string.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name { get; private set; } = string.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Variant { get; private set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Returns a value indicating whether two instances of <see cref="Template" /> are equal.
|
||||
/// </summary>
|
||||
/// <param name="left">The first instance of <see cref="Template" /> to compare.</param>
|
||||
/// <param name="right">The second instance of <see cref="Template" /> to compare.</param>
|
||||
/// <returns>
|
||||
/// <see langword="true" /> if <paramref name="left" /> and <paramref name="right" /> are equal; otherwise,
|
||||
/// <see langword="false" />.
|
||||
/// </returns>
|
||||
public static bool operator ==(Template? left, Template? right) => Equals(left, right);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a value indicating whether two instances of <see cref="Template" /> are not equal.
|
||||
/// </summary>
|
||||
/// <param name="left">The first instance of <see cref="Template" /> to compare.</param>
|
||||
/// <param name="right">The second instance of <see cref="Template" /> to compare.</param>
|
||||
/// <returns>
|
||||
/// <see langword="true" /> if <paramref name="left" /> and <paramref name="right" /> are not equal; otherwise,
|
||||
/// <see langword="false" />.
|
||||
/// </returns>
|
||||
public static bool operator !=(Template? left, Template? right) => !(left == right);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a value indicating whether this instance of <see cref="Template" /> is equal to another
|
||||
/// instance.
|
||||
/// </summary>
|
||||
/// <param name="other">An instance to compare with this instance.</param>
|
||||
/// <returns>
|
||||
/// <see langword="true" /> if <paramref name="other" /> is equal to this instance; otherwise,
|
||||
/// <see langword="false" />.
|
||||
/// </returns>
|
||||
public bool Equals(Template? other)
|
||||
{
|
||||
if (ReferenceEquals(null, other)) return false;
|
||||
if (ReferenceEquals(this, other)) return true;
|
||||
return Name == other.Name && Variant == other.Variant;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a value indicating whether this instance is equal to a specified object.
|
||||
/// </summary>
|
||||
/// <param name="obj">An object to compare with this instance.</param>
|
||||
/// <returns>
|
||||
/// <see langword="true" /> if <paramref name="obj" /> is an instance of <see cref="Template" /> and
|
||||
/// equals the value of this instance; otherwise, <see langword="false" />.
|
||||
/// </returns>
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
return ReferenceEquals(this, obj) || obj is Template other && Equals(other);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the hash code for this instance.
|
||||
/// </summary>
|
||||
/// <returns>The hash code.</returns>
|
||||
public override int GetHashCode()
|
||||
{
|
||||
// ReSharper disable NonReadonlyMemberInGetHashCode
|
||||
return HashCode.Combine(Name, Variant);
|
||||
}
|
||||
}
|
54
OliverBooth/Data/Web/WebContext.cs
Normal file
54
OliverBooth/Data/Web/WebContext.cs
Normal file
@ -0,0 +1,54 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OliverBooth.Data.Web.Configuration;
|
||||
|
||||
namespace OliverBooth.Data.Web;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a session with the web database.
|
||||
/// </summary>
|
||||
internal sealed class WebContext : DbContext
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="WebContext" /> class.
|
||||
/// </summary>
|
||||
/// <param name="configuration">The configuration.</param>
|
||||
public WebContext(IConfiguration configuration)
|
||||
{
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the collection of projects in the database.
|
||||
/// </summary>
|
||||
/// <value>The collection of projects.</value>
|
||||
public DbSet<Project> Projects { get; private set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the set of site configuration items.
|
||||
/// </summary>
|
||||
/// <value>The set of site configuration items.</value>
|
||||
public DbSet<SiteConfiguration> SiteConfiguration { get; private set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the collection of templates in the database.
|
||||
/// </summary>
|
||||
/// <value>The collection of templates.</value>
|
||||
public DbSet<Template> Templates { get; private set; } = null!;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
string connectionString = _configuration.GetConnectionString("Web") ?? string.Empty;
|
||||
ServerVersion serverVersion = ServerVersion.AutoDetect(connectionString);
|
||||
optionsBuilder.UseMySql(connectionString, serverVersion);
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.ApplyConfiguration(new ProjectConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new TemplateConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new SiteConfigurationConfiguration());
|
||||
}
|
||||
}
|
20
OliverBooth/Dockerfile
Normal file
20
OliverBooth/Dockerfile
Normal file
@ -0,0 +1,20 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base
|
||||
WORKDIR /app
|
||||
EXPOSE 80
|
||||
EXPOSE 443
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build
|
||||
WORKDIR /src
|
||||
COPY ["OliverBooth/OliverBooth.csproj", "OliverBooth/"]
|
||||
RUN dotnet restore "OliverBooth/OliverBooth.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/OliverBooth"
|
||||
RUN dotnet build "OliverBooth.csproj" -c Release -o /app/build
|
||||
|
||||
FROM build AS publish
|
||||
RUN dotnet publish "OliverBooth.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=publish /app/publish .
|
||||
ENTRYPOINT ["dotnet", "OliverBooth.dll"]
|
55
OliverBooth/Extensions/WebHostBuilderExtensions.cs
Normal file
55
OliverBooth/Extensions/WebHostBuilderExtensions.cs
Normal file
@ -0,0 +1,55 @@
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace OliverBooth.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for <see cref="IWebHostBuilder" />.
|
||||
/// </summary>
|
||||
public static class WebHostBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds a certificate to the <see cref="IWebHostBuilder" /> by reading the paths from environment variables.
|
||||
/// </summary>
|
||||
/// <param name="builder">The <see cref="IWebHostBuilder" />.</param>
|
||||
/// <param name="httpsPort">The HTTPS port.</param>
|
||||
/// <param name="httpPort">The HTTP port.</param>
|
||||
/// <returns>The <see cref="IWebHostBuilder" />.</returns>
|
||||
public static IWebHostBuilder AddCertificateFromEnvironment(this IWebHostBuilder builder,
|
||||
int httpsPort = 443,
|
||||
int httpPort = 80)
|
||||
{
|
||||
return builder.UseKestrel(options =>
|
||||
{
|
||||
string certPath = Environment.GetEnvironmentVariable("SSL_CERT_PATH")!;
|
||||
if (string.IsNullOrWhiteSpace(certPath))
|
||||
{
|
||||
Console.WriteLine("Certificate path not specified. Using HTTP");
|
||||
options.ListenAnyIP(httpPort);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!File.Exists(certPath))
|
||||
{
|
||||
Console.Error.WriteLine("Certificate not found. Using HTTP");
|
||||
options.ListenAnyIP(httpPort);
|
||||
return;
|
||||
}
|
||||
|
||||
string? keyPath = Environment.GetEnvironmentVariable("SSL_KEY_PATH");
|
||||
if (string.IsNullOrWhiteSpace(keyPath))
|
||||
{
|
||||
Console.WriteLine("Certificate found, but no key provided. Using certificate only");
|
||||
keyPath = null;
|
||||
}
|
||||
else if (!File.Exists(keyPath))
|
||||
{
|
||||
Console.Error.WriteLine("Certificate found, but the provided key was not. Using certificate only");
|
||||
keyPath = null;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Using HTTPS with certificate found at {certPath}:{keyPath}");
|
||||
var certificate = X509Certificate2.CreateFromPemFile(certPath, keyPath);
|
||||
options.ListenAnyIP(httpsPort, configure => configure.UseHttps(certificate));
|
||||
});
|
||||
}
|
||||
}
|
33
OliverBooth/Formatting/DateFormatter.cs
Normal file
33
OliverBooth/Formatting/DateFormatter.cs
Normal file
@ -0,0 +1,33 @@
|
||||
using System.Globalization;
|
||||
using SmartFormat.Core.Extensions;
|
||||
|
||||
namespace OliverBooth.Formatting;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a SmartFormat formatter that formats a date.
|
||||
/// </summary>
|
||||
public sealed class DateFormatter : IFormatter
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public bool CanAutoDetect { get; set; } = true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name { get; set; } = "date";
|
||||
|
||||
/// <inheritdoc />
|
||||
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()));
|
||||
return true;
|
||||
}
|
||||
}
|
38
OliverBooth/Formatting/MarkdownFormatter.cs
Normal file
38
OliverBooth/Formatting/MarkdownFormatter.cs
Normal file
@ -0,0 +1,38 @@
|
||||
using Markdig;
|
||||
using SmartFormat.Core.Extensions;
|
||||
|
||||
namespace OliverBooth.Formatting;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a SmartFormat formatter that formats markdown.
|
||||
/// </summary>
|
||||
public sealed class MarkdownFormatter : IFormatter
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MarkdownFormatter" /> class.
|
||||
/// </summary>
|
||||
/// <param name="serviceProvider">The <see cref="IServiceProvider" />.</param>
|
||||
public MarkdownFormatter(IServiceProvider serviceProvider)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanAutoDetect { get; set; } = true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name { get; set; } = "markdown";
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryEvaluateFormat(IFormattingInfo formattingInfo)
|
||||
{
|
||||
if (formattingInfo.CurrentValue is not string value)
|
||||
return false;
|
||||
|
||||
var pipeline = _serviceProvider.GetService<MarkdownPipeline>();
|
||||
formattingInfo.Write(Markdig.Markdown.ToHtml(value, pipeline));
|
||||
return true;
|
||||
}
|
||||
}
|
37
OliverBooth/Markdown/Template/TemplateExtension.cs
Normal file
37
OliverBooth/Markdown/Template/TemplateExtension.cs
Normal file
@ -0,0 +1,37 @@
|
||||
using Markdig;
|
||||
using Markdig.Renderers;
|
||||
using OliverBooth.Services;
|
||||
|
||||
namespace OliverBooth.Markdown.Template;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a Markdown extension that adds support for MediaWiki-style templates.
|
||||
/// </summary>
|
||||
public sealed class TemplateExtension : IMarkdownExtension
|
||||
{
|
||||
private readonly ITemplateService _templateService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TemplateExtension" /> class.
|
||||
/// </summary>
|
||||
/// <param name="templateService">The template service.</param>
|
||||
public TemplateExtension(ITemplateService templateService)
|
||||
{
|
||||
_templateService = templateService;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Setup(MarkdownPipelineBuilder pipeline)
|
||||
{
|
||||
pipeline.InlineParsers.AddIfNotAlready<TemplateInlineParser>();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer)
|
||||
{
|
||||
if (renderer is HtmlRenderer htmlRenderer)
|
||||
{
|
||||
htmlRenderer.ObjectRenderers.Add(new TemplateRenderer(_templateService));
|
||||
}
|
||||
}
|
||||
}
|
39
OliverBooth/Markdown/Template/TemplateInline.cs
Normal file
39
OliverBooth/Markdown/Template/TemplateInline.cs
Normal file
@ -0,0 +1,39 @@
|
||||
using Markdig.Syntax.Inlines;
|
||||
|
||||
namespace OliverBooth.Markdown.Template;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a Markdown inline element that represents a MediaWiki-style template.
|
||||
/// </summary>
|
||||
public sealed class TemplateInline : Inline
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the raw argument string.
|
||||
/// </summary>
|
||||
/// <value>The raw argument string.</value>
|
||||
public string ArgumentString { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the argument list.
|
||||
/// </summary>
|
||||
/// <value>The argument list.</value>
|
||||
public IReadOnlyList<string> ArgumentList { get; set; } = ArraySegment<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the template.
|
||||
/// </summary>
|
||||
/// <value>The name of the template.</value>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the template parameters.
|
||||
/// </summary>
|
||||
/// <value>The template parameters.</value>
|
||||
public IReadOnlyDictionary<string, string> Params { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the variant of the template.
|
||||
/// </summary>
|
||||
/// <value>The variant of the template.</value>
|
||||
public string Variant { get; set; } = string.Empty;
|
||||
}
|
213
OliverBooth/Markdown/Template/TemplateInlineParser.cs
Normal file
213
OliverBooth/Markdown/Template/TemplateInlineParser.cs
Normal file
@ -0,0 +1,213 @@
|
||||
using Cysharp.Text;
|
||||
using Markdig.Helpers;
|
||||
using Markdig.Parsers;
|
||||
|
||||
namespace OliverBooth.Markdown.Template;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a Markdown inline parser that handles MediaWiki-style templates.
|
||||
/// </summary>
|
||||
public sealed class TemplateInlineParser : InlineParser
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, string> EmptyParams =
|
||||
new Dictionary<string, string>().AsReadOnly();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TemplateInlineParser" /> class.
|
||||
/// </summary>
|
||||
public TemplateInlineParser()
|
||||
{
|
||||
OpeningCharacters = new[] { '{' };
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool Match(InlineProcessor processor, ref StringSlice slice)
|
||||
{
|
||||
ReadOnlySpan<char> span = slice.Text.AsSpan()[slice.Start..];
|
||||
if (!span.StartsWith("{{"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ReadOnlySpan<char> template = ReadUntilClosure(span);
|
||||
if (template.IsEmpty)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
template = template[2..^2]; // trim {{ and }}
|
||||
ReadOnlySpan<char> name = ReadTemplateName(template, out ReadOnlySpan<char> argumentSpan);
|
||||
int variantIndex = name.IndexOf(':');
|
||||
bool hasVariant = variantIndex > -1;
|
||||
var variant = ReadOnlySpan<char>.Empty;
|
||||
|
||||
if (hasVariant)
|
||||
{
|
||||
variant = name[(variantIndex + 1)..];
|
||||
name = name[..variantIndex];
|
||||
}
|
||||
|
||||
if (argumentSpan.IsEmpty)
|
||||
{
|
||||
processor.Inline = new TemplateInline
|
||||
{
|
||||
Name = name.ToString(),
|
||||
Variant = hasVariant ? variant.ToString() : string.Empty,
|
||||
ArgumentString = string.Empty,
|
||||
ArgumentList = ArraySegment<string>.Empty,
|
||||
Params = EmptyParams
|
||||
};
|
||||
|
||||
slice.End = slice.Start;
|
||||
slice.Start += template.Length + 4;
|
||||
return true;
|
||||
}
|
||||
|
||||
var argumentList = new List<string>();
|
||||
var paramsList = new Dictionary<string, string>();
|
||||
|
||||
ParseArguments(argumentSpan, argumentList, paramsList);
|
||||
|
||||
processor.Inline = new TemplateInline
|
||||
{
|
||||
Name = name.ToString(),
|
||||
Variant = hasVariant ? variant.ToString() : string.Empty,
|
||||
ArgumentString = argumentSpan.ToString(),
|
||||
ArgumentList = argumentList.AsReadOnly(),
|
||||
Params = paramsList.AsReadOnly()
|
||||
};
|
||||
|
||||
slice.Start += template.Length + 4;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void ParseArguments(ReadOnlySpan<char> argumentSpan,
|
||||
IList<string> argumentList,
|
||||
IDictionary<string, string> paramsList)
|
||||
{
|
||||
using Utf8ValueStringBuilder buffer = ZString.CreateUtf8StringBuilder();
|
||||
var isKey = true;
|
||||
|
||||
for (var index = 0; index < argumentSpan.Length; index++)
|
||||
{
|
||||
if (isKey)
|
||||
{
|
||||
ReadOnlySpan<char> result = ReadNext(argumentSpan, ref index, false, out bool hasValue);
|
||||
if (!hasValue)
|
||||
{
|
||||
argumentList.Add(result.ToString());
|
||||
continue;
|
||||
}
|
||||
|
||||
buffer.Append(result);
|
||||
isKey = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
ReadOnlySpan<char> result = ReadNext(argumentSpan, ref index, true, out bool _);
|
||||
var key = buffer.ToString();
|
||||
var value = result.ToString();
|
||||
|
||||
buffer.Clear();
|
||||
isKey = true;
|
||||
|
||||
paramsList.Add(key, value);
|
||||
argumentList.Add($"{key}={value}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static ReadOnlySpan<char> ReadNext(ReadOnlySpan<char> argumentSpan,
|
||||
ref int index,
|
||||
bool consumeToken,
|
||||
out bool hasValue)
|
||||
{
|
||||
var isEscaped = false;
|
||||
|
||||
int startIndex = index;
|
||||
for (; index < argumentSpan.Length; index++)
|
||||
{
|
||||
char currentChar = argumentSpan[index];
|
||||
switch (currentChar)
|
||||
{
|
||||
case '\\' when isEscaped:
|
||||
isEscaped = false;
|
||||
break;
|
||||
|
||||
case '\\':
|
||||
isEscaped = true;
|
||||
break;
|
||||
|
||||
case '|' when !isEscaped:
|
||||
hasValue = false;
|
||||
return argumentSpan[startIndex..index];
|
||||
|
||||
case '=' when !isEscaped && !consumeToken:
|
||||
hasValue = true;
|
||||
return argumentSpan[startIndex..index];
|
||||
}
|
||||
}
|
||||
|
||||
hasValue = false;
|
||||
return argumentSpan[startIndex..index];
|
||||
}
|
||||
|
||||
private static ReadOnlySpan<char> ReadUntilClosure(ReadOnlySpan<char> input)
|
||||
{
|
||||
int endIndex = FindClosingBraceIndex(input);
|
||||
return endIndex != -1 ? input[..(endIndex + 1)] : ReadOnlySpan<char>.Empty;
|
||||
}
|
||||
|
||||
private static ReadOnlySpan<char> ReadTemplateName(ReadOnlySpan<char> input, out ReadOnlySpan<char> argumentSpan)
|
||||
{
|
||||
int argumentStartIndex = input.IndexOf('|');
|
||||
if (argumentStartIndex == -1)
|
||||
{
|
||||
argumentSpan = Span<char>.Empty;
|
||||
return input;
|
||||
}
|
||||
|
||||
argumentSpan = input[(argumentStartIndex + 1)..];
|
||||
return input[..argumentStartIndex];
|
||||
}
|
||||
|
||||
private static int FindClosingBraceIndex(ReadOnlySpan<char> input)
|
||||
{
|
||||
var openingBraces = 0;
|
||||
var closingBraces = 0;
|
||||
|
||||
for (var index = 0; index < input.Length - 1; index++)
|
||||
{
|
||||
char currentChar = input[index];
|
||||
char nextChar = index < input.Length - 2 ? input[index + 1] : '\0';
|
||||
|
||||
if (IsOpeningBraceSequence(currentChar, nextChar))
|
||||
{
|
||||
openingBraces++;
|
||||
index++;
|
||||
}
|
||||
else if (IsClosingBraceSequence(currentChar, nextChar))
|
||||
{
|
||||
closingBraces++;
|
||||
index++;
|
||||
}
|
||||
|
||||
if (openingBraces == closingBraces && openingBraces > 0)
|
||||
{
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static bool IsOpeningBraceSequence(char currentChar, char nextChar)
|
||||
{
|
||||
return currentChar == '{' && nextChar == '{';
|
||||
}
|
||||
|
||||
private static bool IsClosingBraceSequence(char currentChar, char nextChar)
|
||||
{
|
||||
return currentChar == '}' && nextChar == '}';
|
||||
}
|
||||
}
|
28
OliverBooth/Markdown/Template/TemplateRenderer.cs
Normal file
28
OliverBooth/Markdown/Template/TemplateRenderer.cs
Normal file
@ -0,0 +1,28 @@
|
||||
using Markdig.Renderers;
|
||||
using Markdig.Renderers.Html;
|
||||
using OliverBooth.Services;
|
||||
|
||||
namespace OliverBooth.Markdown.Template;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a Markdown object renderer that handles <see cref="TemplateInline" /> elements.
|
||||
/// </summary>
|
||||
internal sealed class TemplateRenderer : HtmlObjectRenderer<TemplateInline>
|
||||
{
|
||||
private readonly ITemplateService _templateService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TemplateRenderer" /> class.
|
||||
/// </summary>
|
||||
/// <param name="templateService">The <see cref="TemplateService" />.</param>
|
||||
public TemplateRenderer(ITemplateService templateService)
|
||||
{
|
||||
_templateService = templateService;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Write(HtmlRenderer renderer, TemplateInline template)
|
||||
{
|
||||
renderer.Write(_templateService.RenderGlobalTemplate(template));
|
||||
}
|
||||
}
|
25
OliverBooth/Markdown/Timestamp/TimestampExtension.cs
Normal file
25
OliverBooth/Markdown/Timestamp/TimestampExtension.cs
Normal file
@ -0,0 +1,25 @@
|
||||
using Markdig;
|
||||
using Markdig.Renderers;
|
||||
|
||||
namespace OliverBooth.Markdown.Timestamp;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a Markdig extension that supports Discord-style timestamps.
|
||||
/// </summary>
|
||||
public class TimestampExtension : IMarkdownExtension
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void Setup(MarkdownPipelineBuilder pipeline)
|
||||
{
|
||||
pipeline.InlineParsers.AddIfNotAlready<TimestampInlineParser>();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer)
|
||||
{
|
||||
if (renderer is HtmlRenderer htmlRenderer)
|
||||
{
|
||||
htmlRenderer.ObjectRenderers.AddIfNotAlready<TimestampRenderer>();
|
||||
}
|
||||
}
|
||||
}
|
42
OliverBooth/Markdown/Timestamp/TimestampFormat.cs
Normal file
42
OliverBooth/Markdown/Timestamp/TimestampFormat.cs
Normal file
@ -0,0 +1,42 @@
|
||||
namespace OliverBooth.Markdown.Timestamp;
|
||||
|
||||
/// <summary>
|
||||
/// An enumeration of timestamp formats.
|
||||
/// </summary>
|
||||
public enum TimestampFormat
|
||||
{
|
||||
/// <summary>
|
||||
/// Short time format. Example: 12:00
|
||||
/// </summary>
|
||||
ShortTime = 't',
|
||||
|
||||
/// <summary>
|
||||
/// Long time format. Example: 12:00:00
|
||||
/// </summary>
|
||||
LongTime = 'T',
|
||||
|
||||
/// <summary>
|
||||
/// Short date format. Example: 1/1/2000
|
||||
/// </summary>
|
||||
ShortDate = 'd',
|
||||
|
||||
/// <summary>
|
||||
/// Long date format. Example: 1 January 2000
|
||||
/// </summary>
|
||||
LongDate = 'D',
|
||||
|
||||
/// <summary>
|
||||
/// Short date/time format. Example: 1 January 2000 at 12:00
|
||||
/// </summary>
|
||||
LongDateShortTime = 'f',
|
||||
|
||||
/// <summary>
|
||||
/// Long date/time format. Example: Saturday, 1 January 2000 at 12:00
|
||||
/// </summary>
|
||||
LongDateTime = 'F',
|
||||
|
||||
/// <summary>
|
||||
/// Relative date/time format. Example: 1 second ago
|
||||
/// </summary>
|
||||
Relative = 'R',
|
||||
}
|
21
OliverBooth/Markdown/Timestamp/TimestampInline.cs
Normal file
21
OliverBooth/Markdown/Timestamp/TimestampInline.cs
Normal file
@ -0,0 +1,21 @@
|
||||
using Markdig.Syntax.Inlines;
|
||||
|
||||
namespace OliverBooth.Markdown.Timestamp;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a Markdown inline element that contains a timestamp.
|
||||
/// </summary>
|
||||
public sealed class TimestampInline : Inline
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the format.
|
||||
/// </summary>
|
||||
/// <value>The format.</value>
|
||||
public TimestampFormat Format { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the timestamp.
|
||||
/// </summary>
|
||||
/// <value>The timestamp.</value>
|
||||
public DateTimeOffset Timestamp { get; set; }
|
||||
}
|
91
OliverBooth/Markdown/Timestamp/TimestampInlineParser.cs
Normal file
91
OliverBooth/Markdown/Timestamp/TimestampInlineParser.cs
Normal file
@ -0,0 +1,91 @@
|
||||
using Markdig.Helpers;
|
||||
using Markdig.Parsers;
|
||||
|
||||
namespace OliverBooth.Markdown.Timestamp;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a Markdown inline parser that matches Discord-style timestamps.
|
||||
/// </summary>
|
||||
public sealed class TimestampInlineParser : InlineParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TimestampInlineParser" /> class.
|
||||
/// </summary>
|
||||
public TimestampInlineParser()
|
||||
{
|
||||
OpeningCharacters = new[] { '<' };
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool Match(InlineProcessor processor, ref StringSlice slice)
|
||||
{
|
||||
// Previous char must be a space
|
||||
if (!slice.PeekCharExtra(-1).IsWhiteSpaceOrZero())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ReadOnlySpan<char> span = slice.Text.AsSpan(slice.Start, slice.Length);
|
||||
|
||||
if (!TryConsumeTimestamp(span, out ReadOnlySpan<char> rawTimestamp, out char format))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!long.TryParse(rawTimestamp, out long timestamp))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool hasFormat = format != '\0';
|
||||
processor.Inline = new TimestampInline
|
||||
{
|
||||
Format = (TimestampFormat)format,
|
||||
Timestamp = DateTimeOffset.FromUnixTimeSeconds(timestamp)
|
||||
};
|
||||
|
||||
int paddingCount = hasFormat ? 6 : 4; // <t:*> or optionally <t:*:*>
|
||||
slice.Start += rawTimestamp.Length + paddingCount;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryConsumeTimestamp(ReadOnlySpan<char> source,
|
||||
out ReadOnlySpan<char> timestamp,
|
||||
out char format)
|
||||
{
|
||||
timestamp = default;
|
||||
format = default;
|
||||
|
||||
if (!source.StartsWith("<t:")) return false;
|
||||
timestamp = source[3..];
|
||||
|
||||
if (timestamp.IndexOf('>') == -1)
|
||||
{
|
||||
timestamp = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
int delimiterIndex = timestamp.IndexOf(':');
|
||||
if (delimiterIndex == 0)
|
||||
{
|
||||
// invalid format <t::*>
|
||||
timestamp = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (delimiterIndex == -1)
|
||||
{
|
||||
// no format, default to relative
|
||||
format = 'R';
|
||||
timestamp = timestamp[..^1]; // trim >
|
||||
}
|
||||
else
|
||||
{
|
||||
// use specified format
|
||||
format = timestamp[^2];
|
||||
timestamp = timestamp[..^3];
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
55
OliverBooth/Markdown/Timestamp/TimestampRenderer.cs
Normal file
55
OliverBooth/Markdown/Timestamp/TimestampRenderer.cs
Normal file
@ -0,0 +1,55 @@
|
||||
using System.ComponentModel;
|
||||
using Humanizer;
|
||||
using Markdig.Renderers;
|
||||
using Markdig.Renderers.Html;
|
||||
|
||||
namespace OliverBooth.Markdown.Timestamp;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a Markdown object renderer that renders <see cref="TimestampInline" /> elements.
|
||||
/// </summary>
|
||||
public sealed class TimestampRenderer : HtmlObjectRenderer<TimestampInline>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Write(HtmlRenderer renderer, TimestampInline obj)
|
||||
{
|
||||
DateTimeOffset timestamp = obj.Timestamp;
|
||||
TimestampFormat format = obj.Format;
|
||||
|
||||
renderer.Write("<span class=\"timestamp\" data-timestamp=\"");
|
||||
renderer.Write(timestamp.ToUnixTimeSeconds().ToString());
|
||||
renderer.Write("\" data-format=\"");
|
||||
renderer.Write(((char)format).ToString());
|
||||
renderer.Write("\" title=\"");
|
||||
renderer.WriteEscape(timestamp.ToString("dddd, d MMMM yyyy HH:mm"));
|
||||
renderer.Write("\">");
|
||||
|
||||
switch (format)
|
||||
{
|
||||
case TimestampFormat.LongDate:
|
||||
renderer.Write(timestamp.ToString("d MMMM yyyy"));
|
||||
break;
|
||||
|
||||
case TimestampFormat.LongDateShortTime:
|
||||
renderer.Write(timestamp.ToString(@"d MMMM yyyy \a\t HH:mm"));
|
||||
break;
|
||||
|
||||
case TimestampFormat.LongDateTime:
|
||||
renderer.Write(timestamp.ToString(@"dddd, d MMMM yyyy \a\t HH:mm"));
|
||||
break;
|
||||
|
||||
case TimestampFormat.Relative:
|
||||
renderer.Write(timestamp.Humanize());
|
||||
break;
|
||||
|
||||
case var _ when !Enum.IsDefined(format):
|
||||
throw new InvalidEnumArgumentException(nameof(format), (int)format, typeof(TimestampFormat));
|
||||
|
||||
default:
|
||||
renderer.Write(timestamp.ToString(((char)format).ToString()));
|
||||
break;
|
||||
}
|
||||
|
||||
renderer.Write("</span>");
|
||||
}
|
||||
}
|
32
OliverBooth/OliverBooth.csproj
Normal file
32
OliverBooth/OliverBooth.csproj
Normal file
@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Alexinea.Extensions.Configuration.Toml" Version="7.0.0"/>
|
||||
<PackageReference Include="BCrypt.Net-Core" Version="1.6.0"/>
|
||||
<PackageReference Include="Humanizer.Core" Version="2.14.1"/>
|
||||
<PackageReference Include="MailKit" Version="4.1.0"/>
|
||||
<PackageReference Include="MailKitSimplified.Sender" Version="2.5.2"/>
|
||||
<PackageReference Include="Markdig" Version="0.32.0"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="7.0.10"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="7.0.10"/>
|
||||
<PackageReference Include="Microsoft.Extensions.FileProviders.Embedded" Version="7.0.10"/>
|
||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="7.0.0"/>
|
||||
<PackageReference Include="Serilog" Version="3.0.1"/>
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="7.0.0"/>
|
||||
<PackageReference Include="Serilog.Extensions.Logging" Version="7.0.0"/>
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="4.1.0"/>
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0"/>
|
||||
<PackageReference Include="SmartFormat.NET" Version="3.2.2"/>
|
||||
<PackageReference Include="X10D" Version="3.2.2"/>
|
||||
<PackageReference Include="X10D.Hosting" Version="3.2.2"/>
|
||||
<PackageReference Include="ZString" Version="2.5.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
163
OliverBooth/Pages/Blog/Article.cshtml
Normal file
163
OliverBooth/Pages/Blog/Article.cshtml
Normal file
@ -0,0 +1,163 @@
|
||||
@page "/blog/{year:int}/{month:int}/{day:int}/{slug}"
|
||||
@using Humanizer
|
||||
@using OliverBooth.Data.Blog
|
||||
@using OliverBooth.Services
|
||||
@inject IBlogPostService BlogPostService
|
||||
@model Article
|
||||
|
||||
@if (Model.ShowPasswordPrompt)
|
||||
{
|
||||
<div class="alert alert-danger" role="alert">
|
||||
This post is private and can only be viewed by those with the password.
|
||||
</div>
|
||||
|
||||
<form method="post">
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Submit</button>
|
||||
</form>
|
||||
return;
|
||||
}
|
||||
|
||||
@if (Model.Post is not { } post)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@{
|
||||
ViewData["Post"] = post;
|
||||
ViewData["Title"] = post.Title;
|
||||
IBlogAuthor author = post.Author;
|
||||
DateTimeOffset published = post.Published;
|
||||
}
|
||||
|
||||
<nav style="--bs-breadcrumb-divider: '>';" aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item">
|
||||
<a asp-page="Index">Blog</a>
|
||||
</li>
|
||||
<li class="breadcrumb-item active" aria-current="page">@post.Title</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
@switch (post.Visibility)
|
||||
{
|
||||
case BlogPostVisibility.Private:
|
||||
<div class="alert alert-danger" role="alert">
|
||||
This post is private and can only be viewed by those with the password.
|
||||
</div>
|
||||
break;
|
||||
|
||||
case BlogPostVisibility.Unlisted:
|
||||
<div class="alert alert-warning" role="alert">
|
||||
This post is unlisted and can only be viewed by those with the link.
|
||||
</div>
|
||||
break;
|
||||
}
|
||||
|
||||
<h1>@post.Title</h1>
|
||||
<p class="text-muted">
|
||||
<img class="blog-author-icon" src="@author.AvatarUrl" alt="@author.DisplayName">
|
||||
@author.DisplayName •
|
||||
|
||||
<abbr data-bs-toggle="tooltip" data-bs-title="@published.ToString("dddd, d MMMM yyyy HH:mm")">
|
||||
Published @published.Humanize()
|
||||
</abbr>
|
||||
|
||||
@if (post.Updated is { } updated)
|
||||
{
|
||||
<span>•</span>
|
||||
<abbr data-bs-toggle="tooltip" data-bs-title="@updated.ToString("dddd, d MMMM yyyy HH:mm")">
|
||||
Updated @updated.Humanize()
|
||||
</abbr>
|
||||
}
|
||||
|
||||
@if (post.EnableComments)
|
||||
{
|
||||
<span>•</span>
|
||||
<a href="#disqus_thread" data-disqus-identifier="@post.GetDisqusIdentifier()">0 Comments</a>
|
||||
}
|
||||
</p>
|
||||
<div>
|
||||
@foreach (string tag in post.Tags)
|
||||
{
|
||||
<a asp-page="Index" asp-route-tag="@tag" class="badge bg-secondary">@tag</a>
|
||||
}
|
||||
</div>
|
||||
<hr>
|
||||
|
||||
<article data-blog-post="true" data-blog-id="@post.Id.ToString("D")">
|
||||
<p class="text-center">Loading ...</p>
|
||||
</article>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-12 col-md-6">
|
||||
@if (BlogPostService.GetPreviousPost(post) is { } previousPost)
|
||||
{
|
||||
<small>Previous Post</small>
|
||||
<p class="lead">
|
||||
<a asp-page="Article"
|
||||
asp-route-year="@previousPost.Published.Year.ToString("0000")"
|
||||
asp-route-month="@previousPost.Published.Month.ToString("00")"
|
||||
asp-route-day="@previousPost.Published.Day.ToString("00")"
|
||||
asp-route-slug="@previousPost.Slug">
|
||||
@previousPost.Title
|
||||
</a>
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div class="col-sm-12 col-md-6" style="text-align: right;">
|
||||
@if (BlogPostService.GetNextPost(post) is { } nextPost)
|
||||
{
|
||||
<small>Next Post</small>
|
||||
<p class="lead">
|
||||
<a asp-page="Article"
|
||||
asp-route-year="@nextPost.Published.Year.ToString("0000")"
|
||||
asp-route-month="@nextPost.Published.Month.ToString("00")"
|
||||
asp-route-day="@nextPost.Published.Day.ToString("00")"
|
||||
asp-route-slug="@nextPost.Slug">
|
||||
@nextPost.Title
|
||||
</a>
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
@if (post.EnableComments)
|
||||
{
|
||||
<div id="disqus_thread"></div>
|
||||
<script>
|
||||
var disqus_config = function () {
|
||||
this.page.url = "@post.GetDisqusUrl()";
|
||||
this.page.identifier = "@post.GetDisqusIdentifier()";
|
||||
this.page.title = "@post.Title";
|
||||
this.page.postId = "@post.GetDisqusPostId()";
|
||||
};
|
||||
|
||||
(function() {
|
||||
const d = document, s = d.createElement("script");
|
||||
s.async = true;
|
||||
s.type = "text/javascript";
|
||||
s.src = "https://oliverbooth-dev.disqus.com/embed.js";
|
||||
s.setAttribute("data-timestamp", (+ new Date()).toString());
|
||||
(d.head || d.body).appendChild(s);
|
||||
})();
|
||||
</script>
|
||||
<script id="dsq-count-scr" src="https://oliverbooth-dev.disqus.com/count.js" async></script>
|
||||
<noscript>
|
||||
Please enable JavaScript to view the
|
||||
<a href="https://disqus.com/?ref_noscript" rel="nofollow">
|
||||
comments powered by Disqus.
|
||||
</a>
|
||||
</noscript>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-center text-muted">Comments are not enabled for this post.</p>
|
||||
}
|
101
OliverBooth/Pages/Blog/Article.cshtml.cs
Normal file
101
OliverBooth/Pages/Blog/Article.cshtml.cs
Normal file
@ -0,0 +1,101 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using OliverBooth.Data.Blog;
|
||||
using OliverBooth.Services;
|
||||
using BC = BCrypt.Net.BCrypt;
|
||||
|
||||
namespace OliverBooth.Pages.Blog;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the page model for the <c>Article</c> page.
|
||||
/// </summary>
|
||||
[Area("blog")]
|
||||
public class Article : PageModel
|
||||
{
|
||||
private readonly IBlogPostService _blogPostService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Article" /> class.
|
||||
/// </summary>
|
||||
/// <param name="blogPostService">The <see cref="IBlogPostService" />.</param>
|
||||
public Article(IBlogPostService blogPostService)
|
||||
{
|
||||
_blogPostService = blogPostService;
|
||||
}
|
||||
|
||||
/*
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the post is a legacy WordPress post.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// <see langword="true" /> if the post is a legacy WordPress post; otherwise, <see langword="false" />.
|
||||
/// </value>
|
||||
public bool IsWordPressLegacyPost => Post.WordPressId.HasValue;
|
||||
*/
|
||||
|
||||
/// <summary>
|
||||
/// Gets the requested blog post.
|
||||
/// </summary>
|
||||
/// <value>The requested blog post.</value>
|
||||
public IBlogPost Post { get; private set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether to show the password prompt.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// <see langword="true" /> if the password prompt should be shown; otherwise, <see langword="false" />.
|
||||
/// </value>
|
||||
public bool ShowPasswordPrompt { get; private set; }
|
||||
|
||||
public IActionResult OnGet(int year, int month, int day, string slug)
|
||||
{
|
||||
var date = new DateOnly(year, month, day);
|
||||
if (!_blogPostService.TryGetPost(date, slug, out IBlogPost? post))
|
||||
{
|
||||
Response.StatusCode = 404;
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(post.Password))
|
||||
{
|
||||
ShowPasswordPrompt = true;
|
||||
}
|
||||
|
||||
if (post.IsRedirect)
|
||||
{
|
||||
return Redirect(post.RedirectUrl!.ToString());
|
||||
}
|
||||
|
||||
Post = post;
|
||||
return Page();
|
||||
}
|
||||
|
||||
public IActionResult OnPost([FromRoute] int year,
|
||||
[FromRoute] int month,
|
||||
[FromRoute] int day,
|
||||
[FromRoute] string slug)
|
||||
{
|
||||
var date = new DateOnly(year, month, day);
|
||||
if (!_blogPostService.TryGetPost(date, slug, out IBlogPost? post))
|
||||
{
|
||||
Response.StatusCode = 404;
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
ShowPasswordPrompt = true;
|
||||
|
||||
if (Request.Form.TryGetValue("password", out StringValues password) && BC.Verify(password, post.Password))
|
||||
{
|
||||
ShowPasswordPrompt = false;
|
||||
}
|
||||
|
||||
if (post.IsRedirect)
|
||||
{
|
||||
return Redirect(post.RedirectUrl!.ToString());
|
||||
}
|
||||
|
||||
Post = post;
|
||||
return Page();
|
||||
}
|
||||
}
|
47
OliverBooth/Pages/Blog/Index.cshtml
Normal file
47
OliverBooth/Pages/Blog/Index.cshtml
Normal file
@ -0,0 +1,47 @@
|
||||
@page
|
||||
@model Index
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Blog";
|
||||
}
|
||||
|
||||
<div id="all-blog-posts">
|
||||
@await Html.PartialAsync("_LoadingSpinner")
|
||||
</div>
|
||||
|
||||
<script id="blog-post-template" type="text/x-handlebars-template">
|
||||
<div class="card-header">
|
||||
<span class="text-muted">
|
||||
<img class="blog-author-icon" src="{{author.avatar}}" alt="{{author.name}}">
|
||||
<span>{{author.name}}<span>
|
||||
<span> • </span>
|
||||
<abbr title="{{ post.formattedDate }}">{{ post.date_humanized }}</abbr>
|
||||
{{#if post.enable_comments}}
|
||||
<span> • </span>
|
||||
<a href="{{post.url}}#disqus_thread" data-disqus-identifier="{{post.disqus_identifier}}">
|
||||
Loading comment count …
|
||||
</a>
|
||||
{{/if}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h2>
|
||||
<a href="{{post.url}}"> {{post.title}}</a>
|
||||
</h2>
|
||||
|
||||
<p>{{{post.excerpt}}}</p>
|
||||
|
||||
{{#if post.trimmed}}
|
||||
<p>
|
||||
<a href="{{post.url}}">
|
||||
Read more...
|
||||
</a>
|
||||
</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
{{#each post.tags}}
|
||||
<a href="?tag={{urlEncode this}}" class="badge text-bg-dark">{{this}}</a>
|
||||
{{/each}}
|
||||
</div>
|
||||
</script>
|
50
OliverBooth/Pages/Blog/Index.cshtml.cs
Normal file
50
OliverBooth/Pages/Blog/Index.cshtml.cs
Normal file
@ -0,0 +1,50 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using OliverBooth.Data.Blog;
|
||||
using OliverBooth.Services;
|
||||
|
||||
namespace OliverBooth.Pages.Blog;
|
||||
|
||||
[Area("blog")]
|
||||
public class Index : PageModel
|
||||
{
|
||||
private readonly IBlogPostService _blogPostService;
|
||||
|
||||
public Index(IBlogPostService blogPostService)
|
||||
{
|
||||
_blogPostService = blogPostService;
|
||||
}
|
||||
|
||||
public IActionResult OnGet([FromQuery(Name = "pid")] Guid? postId = null,
|
||||
[FromQuery(Name = "p")] int? wpPostId = null)
|
||||
{
|
||||
if (postId.HasValue == wpPostId.HasValue)
|
||||
{
|
||||
return Page();
|
||||
}
|
||||
|
||||
return postId.HasValue ? HandleNewRoute(postId.Value) : HandleWordPressRoute(wpPostId!.Value);
|
||||
}
|
||||
|
||||
private IActionResult HandleNewRoute(Guid postId)
|
||||
{
|
||||
return _blogPostService.TryGetPost(postId, out IBlogPost? post) ? RedirectToPost(post) : NotFound();
|
||||
}
|
||||
|
||||
private IActionResult HandleWordPressRoute(int wpPostId)
|
||||
{
|
||||
return _blogPostService.TryGetPost(wpPostId, out IBlogPost? post) ? RedirectToPost(post) : NotFound();
|
||||
}
|
||||
|
||||
private IActionResult RedirectToPost(IBlogPost post)
|
||||
{
|
||||
var route = new
|
||||
{
|
||||
year = post.Published.ToString("yyyy"),
|
||||
month = post.Published.ToString("MM"),
|
||||
day = post.Published.ToString("dd"),
|
||||
slug = post.Slug
|
||||
};
|
||||
return Redirect(Url.Page("/Blog/Article", route)!);
|
||||
}
|
||||
}
|
2
OliverBooth/Pages/Blog/RawArticle.cshtml
Normal file
2
OliverBooth/Pages/Blog/RawArticle.cshtml
Normal file
@ -0,0 +1,2 @@
|
||||
@page "/blog/{year:int}/{month:int}/{day:int}/{slug}/raw"
|
||||
@model RawArticle
|
48
OliverBooth/Pages/Blog/RawArticle.cshtml.cs
Normal file
48
OliverBooth/Pages/Blog/RawArticle.cshtml.cs
Normal file
@ -0,0 +1,48 @@
|
||||
using Cysharp.Text;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using OliverBooth.Data.Blog;
|
||||
using OliverBooth.Services;
|
||||
|
||||
namespace OliverBooth.Pages.Blog;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the page model for the <c>RawArticle</c> page.
|
||||
/// </summary>
|
||||
[Area("blog")]
|
||||
public class RawArticle : PageModel
|
||||
{
|
||||
private readonly IBlogPostService _blogPostService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RawArticle" /> class.
|
||||
/// </summary>
|
||||
/// <param name="blogPostService">The <see cref="IBlogPostService" />.</param>
|
||||
public RawArticle(IBlogPostService blogPostService)
|
||||
{
|
||||
_blogPostService = blogPostService;
|
||||
}
|
||||
|
||||
public IActionResult OnGet(int year, int month, int day, string slug)
|
||||
{
|
||||
var date = new DateOnly(year, month, day);
|
||||
if (!_blogPostService.TryGetPost(date, slug, out IBlogPost? post))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
Response.Headers.Add("Content-Type", "text/plain; charset=utf-8");
|
||||
|
||||
using Utf8ValueStringBuilder builder = ZString.CreateUtf8StringBuilder();
|
||||
builder.AppendLine("# " + post.Title);
|
||||
builder.AppendLine($"Author: {post.Author.DisplayName}");
|
||||
|
||||
builder.AppendLine($"Published: {post.Published:R}");
|
||||
if (post.Updated.HasValue)
|
||||
builder.AppendLine($"Updated: {post.Updated:R}");
|
||||
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(post.Body);
|
||||
return Content(builder.ToString());
|
||||
}
|
||||
}
|
37
OliverBooth/Pages/Contact/Index.cshtml
Normal file
37
OliverBooth/Pages/Contact/Index.cshtml
Normal file
@ -0,0 +1,37 @@
|
||||
@page
|
||||
@{
|
||||
ViewData["Title"] = "Contact";
|
||||
}
|
||||
|
||||
<h1 class="display-4">Contact</h1>
|
||||
<p>
|
||||
Thanks for getting in touch! While I do my best to read to all inquiries, I cannot guarantee that I will be able to
|
||||
respond to your message. Nevertheless, I appreciate you taking the time to reach out to me and I will respond if I
|
||||
can.
|
||||
</p>
|
||||
|
||||
<form method="post" asp-controller="Contact" asp-action="HandleForm">
|
||||
<input type="hidden" name="contact-type" value="other">
|
||||
|
||||
<div class="form-group" style="margin-top: 10px;">
|
||||
<label for="name">Your Name</label>
|
||||
<input type="text" class="form-control" id="name" name="name" placeholder="Who are you?" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: 10px;">
|
||||
<label for="email">Your Email Address</label>
|
||||
<input type="email" class="form-control" id="email" name="email" placeholder="How can I reach you?" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: 10px;">
|
||||
<label for="subject">Subject</label>
|
||||
<input type="text" class="form-control" id="subject" name="subject" placeholder="What's the gist?" maxlength="100" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: 10px;">
|
||||
<label for="message">Message</label>
|
||||
<textarea class="form-control" id="message" name="message" rows="5" required placeholder="What's on your mind?"></textarea>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" style="margin-top: 10px;">Submit</button>
|
||||
</form>
|
25
OliverBooth/Pages/Contact/Result.cshtml
Normal file
25
OliverBooth/Pages/Contact/Result.cshtml
Normal file
@ -0,0 +1,25 @@
|
||||
@page
|
||||
@model OliverBooth.Pages.Contact.Result
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Contact";
|
||||
}
|
||||
|
||||
@if (Model.WasSuccessful)
|
||||
{
|
||||
<h1 class="display-4 text-success"><i class="fa-solid fa-circle-check"></i> Sent successfully!</h1>
|
||||
<p>Thank you for getting in touch. I will get back to you as soon as possible.</p>
|
||||
<p>
|
||||
In the meantime, why not check out my <a asp-page="/Blog/Index">blog</a> or
|
||||
<a asp-page="/Projects/Index">portfolio</a>?
|
||||
</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<h1 class="display-4 text-danger"><i class="fa-solid fa-circle-xmark"></i> A problem occured</h1>
|
||||
<p>Sorry, something went wrong. This has been logged and if it's a problem on my end, I'll get to it soon.</p>
|
||||
<p>
|
||||
You can <a asp-page="Index">try again</a>, or check out my <a asp-page="/Blog/Index">blog</a> or
|
||||
<a asp-page="/Projects/Index">portfolio</a>!
|
||||
</p>
|
||||
}
|
23
OliverBooth/Pages/Contact/Result.cshtml.cs
Normal file
23
OliverBooth/Pages/Contact/Result.cshtml.cs
Normal file
@ -0,0 +1,23 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace OliverBooth.Pages.Contact;
|
||||
|
||||
public class Result : PageModel
|
||||
{
|
||||
public bool WasSuccessful { get; private set; }
|
||||
|
||||
public IActionResult OnGet()
|
||||
{
|
||||
if (!TempData.ContainsKey("Success"))
|
||||
{
|
||||
return RedirectToPage("/Contact/Index");
|
||||
}
|
||||
|
||||
#pragma warning disable S1125
|
||||
WasSuccessful = TempData["Success"] is true;
|
||||
#pragma warning restore S1125
|
||||
TempData.Remove("Success");
|
||||
return Page();
|
||||
}
|
||||
}
|
37
OliverBooth/Pages/Donate.cshtml
Normal file
37
OliverBooth/Pages/Donate.cshtml
Normal file
@ -0,0 +1,37 @@
|
||||
@page
|
||||
@{
|
||||
ViewData["Title"] = "Donate";
|
||||
}
|
||||
|
||||
<h1 class="display-4">Donate</h1>
|
||||
|
||||
<p>
|
||||
I believe in free and open exchange of information, and I want to keep my educational content free for everyone to
|
||||
access. I will never put ads on my site, and I don't want to put behind a paywall resources that should be available
|
||||
to everybody, regardless of their financial situation.
|
||||
</p>
|
||||
<p>
|
||||
However, writing tutorials takes time, and I do have to pay for hosting. While I will never ask you for money, I
|
||||
will always appreciate it if you do decide to donate. It also helps me to know that people are finding my content
|
||||
useful, and that I should continue to make more.
|
||||
</p>
|
||||
<p>
|
||||
If you like what I do and are both willing and able to donate money to me and fund all of this, you can do so using
|
||||
the links below. Thank you for your support!
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<script type="text/javascript" src="https://storage.ko-fi.com/cdn/widget/Widget_2.js"></script>
|
||||
<script type="text/javascript">kofiwidget2.init('Support Me on Ko-fi', '#29abe0', 'C0C4DVDZX');kofiwidget2.draw();</script>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<script type="text/javascript" src="https://cdnjs.buymeacoffee.com/1.0.0/button.prod.min.js" data-name="bmc-button" data-slug="oliverbooth" data-color="#FFDD00" data-emoji="" data-font="Cookie" data-text="Buy me a coffee" data-outline-color="#000000" data-font-color="#000000" data-coffee-color="#ffffff"></script>
|
||||
</p>
|
||||
|
||||
<p>I also accept cryptocurrency donations.</p>
|
||||
|
||||
<ul>
|
||||
<li>BTC: 1LmXvavJr1omscfkXjp7A4VyNf3XhKP9JK</li>
|
||||
<li>ETH: 0x972C6641e36e2736823A6B1e6BA4D2A814b69fD2</li>
|
||||
</ul>
|
34
OliverBooth/Pages/Error.cshtml
Normal file
34
OliverBooth/Pages/Error.cshtml
Normal file
@ -0,0 +1,34 @@
|
||||
@page "/error/{code:int?}"
|
||||
@model OliverBooth.Pages.ErrorModel
|
||||
@{
|
||||
Layout = "_MinimalLayout";
|
||||
ViewData["Title"] = "Error";
|
||||
}
|
||||
|
||||
<h2 class="text-danger">
|
||||
@switch (Model.HttpStatusCode)
|
||||
{
|
||||
case 403:
|
||||
<span>403 Forbidden</span>
|
||||
break;
|
||||
|
||||
case 404:
|
||||
<span>404 Page not found</span>
|
||||
break;
|
||||
|
||||
case 500:
|
||||
<span>Internal server error</span>
|
||||
break;
|
||||
|
||||
default:
|
||||
<span>Something went wrong</span>
|
||||
break;
|
||||
}
|
||||
</h2>
|
||||
|
||||
@if (Model.ShowRequestId)
|
||||
{
|
||||
<p>
|
||||
<strong>Request ID:</strong> <code>@Model.RequestId</code>
|
||||
</p>
|
||||
}
|
@ -2,7 +2,7 @@ using System.Diagnostics;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace oliverbooth.dev.Pages;
|
||||
namespace OliverBooth.Pages;
|
||||
|
||||
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
|
||||
[IgnoreAntiforgeryToken]
|
||||
@ -12,15 +12,17 @@ public class ErrorModel : PageModel
|
||||
|
||||
public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
||||
|
||||
private readonly ILogger<ErrorModel> _logger;
|
||||
public int HttpStatusCode { get; private set; }
|
||||
|
||||
public ErrorModel(ILogger<ErrorModel> logger)
|
||||
public IActionResult OnGet(int? code = null)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
HttpStatusCode = code ?? HttpContext.Response.StatusCode;
|
||||
if (HttpStatusCode == 200)
|
||||
{
|
||||
return RedirectToPage("/Index");
|
||||
}
|
||||
|
||||
public void OnGet()
|
||||
{
|
||||
RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
|
||||
return Page();
|
||||
}
|
||||
}
|
30
OliverBooth/Pages/Index.cshtml
Normal file
30
OliverBooth/Pages/Index.cshtml
Normal file
@ -0,0 +1,30 @@
|
||||
@page
|
||||
|
||||
<h1 class="display-4">Hi, I'm Oliver.</h1>
|
||||
<p class="lead">I'm a tech enthusiast, coffee drinker, and software developer.</p>
|
||||
|
||||
<p class="text-center">
|
||||
<img src="~/img/headshot_512x512_2023.jpg" style="width: 512px; max-width: 100%;">
|
||||
</p>
|
||||
|
||||
<p>
|
||||
My primary focus is C#, though I have dabbled in several other languages such as Java, Kotlin, VB, C/C++, Python,
|
||||
and others. Over the years I've built up a collection of projects. Some of which I'm extremely proud of, and others
|
||||
I've quietly abandoned and buried. I'm currently working on a few projects that I hope to release in the near
|
||||
future, but in the meantime, feel free to check out some of my <a asp-page="/Projects/Index">previous work</a>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
I've also written a few <a asp-page="/Tutorials/Index">tutorials</a> on various topics, usually involving
|
||||
information not readily available elsewhere. I hope you find them useful. On occasion, I also write about other
|
||||
topics that I find interesting, such as
|
||||
<a asp-page="/Blog/Index">my thoughts on the state of the world or the tech industry</a>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
If you want a general overview of stuff I've made, check out my <a href="https://github.com/oliverbooth">GitHub</a>,
|
||||
<a href="https://oliverbooth.itch.io">itch.io</a>, and
|
||||
<a href="https://play.google.com/store/apps/dev?id=9010459220239503006">Google Play</a>.
|
||||
</p>
|
||||
|
||||
<p>If you'd like to get in touch, you can do so by <a asp-page="/Contact/Index">clicking here</a>.</p>
|
66
OliverBooth/Pages/Privacy/FiveOClockSomewhere.cshtml
Normal file
66
OliverBooth/Pages/Privacy/FiveOClockSomewhere.cshtml
Normal file
@ -0,0 +1,66 @@
|
||||
@page "/privacy/five-oclock-somewhere"
|
||||
@{
|
||||
ViewData["Title"] = "It's 5 O'Clock Somewhere Privacy Policy";
|
||||
}
|
||||
|
||||
<h1 class="display-4">@ViewData["Title"]</h1>
|
||||
<p class="lead">Last Updated: 24 September 2023</p>
|
||||
<div class="alert alert-primary">
|
||||
This Privacy Policy differs from the policy that applies to other applications that I published on Google Play. For
|
||||
the generalised privacy policy, please <a asp-page="/Privacy/GooglePlay">click here</a>.
|
||||
</div>
|
||||
|
||||
<p>
|
||||
This Privacy Policy describes how your personal information is collected, used, and shared when you use or interact
|
||||
with the application <em>It's 5 O'Clock Somewhere</em> (the "Application").
|
||||
</p>
|
||||
|
||||
<h2>Introduction</h2>
|
||||
<p>
|
||||
I am committed to protecting your privacy and ensuring the security of any information you provide to me when using
|
||||
my applications. This Privacy Policy outlines my practices regarding the collection, use, and disclosure of
|
||||
information I may gather from users of the Application.
|
||||
</p>
|
||||
|
||||
<h2>Information I Collect</h2>
|
||||
<p>
|
||||
The Application will temporarily read your device's clock to determine the current time. This information is sent to
|
||||
the Application's server to determine an accurate and time-zone aware response to you, the user. This information is
|
||||
not stored or retained on the server. I do not use cookies, tracking technologies, or any other means to collect or
|
||||
track your usage behavior within the Application. I do not have access to any personal data, including your name,
|
||||
email address, or any other personally identifiable information.
|
||||
</p>
|
||||
|
||||
<h2>Use of Information</h2>
|
||||
<p>
|
||||
The Application uses the little information it collects from you to provide the Application's functionality to you.
|
||||
This information is not used for any other purpose.
|
||||
</p>
|
||||
|
||||
|
||||
<h2>Disclosure of Information</h2>
|
||||
<p>
|
||||
I do not disclose your personal information to any third parties unless required by law or with your explicit
|
||||
consent. I maintain strict confidentiality and take reasonable precautions to protect your personal information from
|
||||
unauthorized access, use, or disclosure.
|
||||
</p>
|
||||
|
||||
<h2>Security</h2>
|
||||
<p>
|
||||
I prioritize the security of your personal information and take reasonable precautions to protect it. However,
|
||||
please be aware that no method of transmission over the internet or electronic storage is 100% secure, and I cannot
|
||||
guarantee absolute security.
|
||||
</p>
|
||||
|
||||
<h2>Changes to this Privacy Policy</h2>
|
||||
<p>
|
||||
I may update this Privacy Policy from time to time to reflect changes in my practices or for other operational,
|
||||
legal, or regulatory reasons. I encourage you to review this Privacy Policy periodically for any updates. The
|
||||
revised policy will be effective immediately upon posting.
|
||||
</p>
|
||||
|
||||
<h2>Contact Me</h2>
|
||||
<p>
|
||||
If you have any questions or concerns about this Privacy Policy or my privacy practices, please
|
||||
<a asp-page="/Contact/Index">get in touch</a>.
|
||||
</p>
|
70
OliverBooth/Pages/Privacy/GooglePlay.cshtml
Normal file
70
OliverBooth/Pages/Privacy/GooglePlay.cshtml
Normal file
@ -0,0 +1,70 @@
|
||||
@page "/privacy/google-play"
|
||||
@{
|
||||
ViewData["Title"] = "Google Play Privacy Policy";
|
||||
}
|
||||
|
||||
<h1 class="display-4">@ViewData["Title"]</h1>
|
||||
<p class="lead">Last Updated: 24 September 2023</p>
|
||||
<div class="alert alert-primary">
|
||||
This Privacy Policy differs from the policy that applies to this website. For this website's privacy policy, please
|
||||
<a asp-page="/Privacy/Index">click here</a>.
|
||||
</div>
|
||||
<div class="alert alert-warning">
|
||||
This Privacy Policy does not apply to the application <em>It's 5 O'Clock Somewhere</em>. For the privacy policy that
|
||||
applies to that application, please <a asp-page="/Privacy/FiveOClockSomewhere">click here</a>.
|
||||
</div>
|
||||
|
||||
<p>
|
||||
This Privacy Policy describes how your personal information is collected, used, and shared when you use or interact
|
||||
with applications that I publish on the <a href="https://play.google.com/store/">Google Play Store</a>.
|
||||
</p>
|
||||
|
||||
<h2>Introduction</h2>
|
||||
<p>
|
||||
I am committed to protecting your privacy and ensuring the security of any information you provide to me when using
|
||||
my applications. This Privacy Policy outlines my practices regarding the collection, use, and disclosure of
|
||||
information I may gather from users of my applications.
|
||||
</p>
|
||||
|
||||
<h2>Information I Collect</h2>
|
||||
<p>
|
||||
I do not collect any personally identifiable information about you when you use my applications. I do not use any
|
||||
cookies or similar tracking technologies that can identify individual users or track your usage behavior within the
|
||||
application.
|
||||
</p>
|
||||
<p>
|
||||
However, please note that my applications may make use of third party integrations such as Google Play Services or
|
||||
others. I am not responsible for the privacy practices or content of these third-party services. I encourage you to
|
||||
review the privacy policies of those third-party services before providing any personal information.
|
||||
</p>
|
||||
|
||||
<h2>Use of Information</h2>
|
||||
<p>Since I do not collect any personal information about you, I do not use it for any purpose.</p>
|
||||
|
||||
<h2>Disclosure of Information</h2>
|
||||
<p>I do not share any personal information about you because I do not collect any such information.</p>
|
||||
<p>
|
||||
However, please be aware that my applications contains links to third-party websites and services. I am not
|
||||
responsible for the privacy practices or content of these third-party sites. I encourage you to review the privacy
|
||||
policies of those third-party sites before providing any personal information.
|
||||
</p>
|
||||
|
||||
<h2>Security</h2>
|
||||
<p>
|
||||
I prioritize the security of your personal information and take reasonable precautions to protect it. However,
|
||||
please be aware that no method of transmission over the internet or electronic storage is 100% secure, and I cannot
|
||||
guarantee absolute security.
|
||||
</p>
|
||||
|
||||
<h2>Changes to this Privacy Policy</h2>
|
||||
<p>
|
||||
I may update this Privacy Policy from time to time to reflect changes in my practices or for other operational,
|
||||
legal, or regulatory reasons. I encourage you to review this Privacy Policy periodically for any updates. The
|
||||
revised policy will be effective immediately upon posting.
|
||||
</p>
|
||||
|
||||
<h2>Contact Me</h2>
|
||||
<p>
|
||||
If you have any questions or concerns about this Privacy Policy or my privacy practices, please
|
||||
<a asp-page="/Contact/Index">get in touch</a>.
|
||||
</p>
|
86
OliverBooth/Pages/Privacy/Index.cshtml
Normal file
86
OliverBooth/Pages/Privacy/Index.cshtml
Normal file
@ -0,0 +1,86 @@
|
||||
@page
|
||||
@{
|
||||
ViewData["Title"] = "Privacy Policy";
|
||||
}
|
||||
|
||||
<h1 class="display-4">@ViewData["Title"]</h1>
|
||||
<p class="lead">Last Updated: 26 May 2023</p>
|
||||
<div class="alert alert-primary">
|
||||
This Privacy Policy differs from the policy that applies to my applications published to Google Play. For my
|
||||
applications' privacy policy, please <a asp-page="/Privacy/GooglePlay">click here</a>.
|
||||
</div>
|
||||
|
||||
<p>
|
||||
This Privacy Policy describes how your personal information is collected, used, and shared when you visit or
|
||||
interact with my website <a href="https://oliverbooth.dev/">oliverbooth.dev</a>.
|
||||
</p>
|
||||
|
||||
<h2>Introduction</h2>
|
||||
<p>
|
||||
I am committed to protecting your privacy and ensuring the security of any information you provide to me when using
|
||||
my website. This Privacy Policy outlines my practices regarding the collection, use, and disclosure of information I
|
||||
may gather from users of my website.
|
||||
</p>
|
||||
|
||||
<h2>Information I Collect</h2>
|
||||
<p>
|
||||
When you choose to contact me via the contact form on my website, I collect your name and email address. This
|
||||
information is provided voluntarily by you and is necessary for me to respond to your inquiries and engage in a
|
||||
conversation. I do not collect any additional personally identifiable information about you through the contact
|
||||
form.
|
||||
</p>
|
||||
<p>I do not use any cookies or similar tracking technologies that can identify individual users.</p>
|
||||
<p>
|
||||
Please note that my website includes a Disqus integration for commenting on blog posts. Disqus is a third-party
|
||||
service, and their use of cookies and collection of personal information are governed by their own privacy policies.
|
||||
I have no control over the information collected by Disqus, and I encourage you to review their privacy policy to
|
||||
understand how your information may be used by them.
|
||||
</p>
|
||||
|
||||
<h2>Use of Information</h2>
|
||||
<p>
|
||||
The name and email address you provide through the contact form are used solely for the purpose of responding to
|
||||
your inquiries and engaging in relevant conversation. I keep this information confidential and do not disclose,
|
||||
sell, or share it with any third parties unless required by law or with your explicit consent.
|
||||
</p>
|
||||
<p>
|
||||
I do not use your personal information for marketing purposes or send you any unsolicited communications. Once our
|
||||
conversation is complete and no longer necessary, I will securely delete your personal information unless otherwise
|
||||
required to retain it by applicable laws or regulations.
|
||||
</p>
|
||||
|
||||
<h2>Disclosure of Information</h2>
|
||||
<p>
|
||||
I do not disclose your personal information to any third parties unless required by law or with your explicit
|
||||
consent. I maintain strict confidentiality and take reasonable precautions to protect your personal information from
|
||||
unauthorized access, use, or disclosure.
|
||||
</p>
|
||||
<p>
|
||||
However, please be aware that my website contains links to third-party websites and services. I am not responsible
|
||||
for the privacy practices or content of these third-party sites. I encourage you to review the privacy policies of
|
||||
those third-party sites before providing any personal information.
|
||||
</p>
|
||||
|
||||
<h2>Security</h2>
|
||||
<p>
|
||||
I prioritize the security of your personal information and take reasonable precautions to protect it. However,
|
||||
please be aware that no method of transmission over the internet or electronic storage is 100% secure, and I cannot
|
||||
guarantee absolute security.
|
||||
</p>
|
||||
|
||||
<h2>Changes to this Privacy Policy</h2>
|
||||
<p>
|
||||
I may update this Privacy Policy from time to time to reflect changes in my practices or for other operational,
|
||||
legal, or regulatory reasons. I encourage you to review this Privacy Policy periodically for any updates. The
|
||||
revised policy will be effective immediately upon posting.
|
||||
</p>
|
||||
|
||||
<h2>Contact Me</h2>
|
||||
<p>
|
||||
If you have any questions or concerns about this Privacy Policy or my privacy practices, please
|
||||
<a asp-page="/Contact/Index">get in touch</a>.
|
||||
</p>
|
||||
|
||||
<hr/>
|
||||
|
||||
<p>By using my website, you signify your acceptance of this Privacy Policy.</p>
|
105
OliverBooth/Pages/Projects/Index.cshtml
Normal file
105
OliverBooth/Pages/Projects/Index.cshtml
Normal file
@ -0,0 +1,105 @@
|
||||
@page
|
||||
@using OliverBooth.Data.Web
|
||||
@using OliverBooth.Services
|
||||
@inject IProjectService ProjectService
|
||||
@{
|
||||
ViewData["Title"] = "Projects";
|
||||
}
|
||||
|
||||
<h1 class="display-4">Projects</h1>
|
||||
|
||||
@foreach (IProject[] chunk in ProjectService.GetProjects(ProjectStatus.Ongoing).OrderBy(p => p.Rank).Chunk(2))
|
||||
{
|
||||
<div class="card-group row" style="margin-top: 20px;">
|
||||
@foreach (IProject project in chunk)
|
||||
{
|
||||
<div class="col-xs-1 col-md-6 col-lg-6 d-flex align-items-stretch">
|
||||
<div class="card border-success project-card">
|
||||
<div class="card-header text-bg-success">In Active Development</div>
|
||||
<img src="~/img/projects/hero/@project.HeroUrl" class="card-img-top" alt="@project.Name">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">@project.Name</h5>
|
||||
<p class="card-text">@Html.Raw(ProjectService.GetDescription(project))</p>
|
||||
@if (!string.IsNullOrWhiteSpace(project.RemoteUrl))
|
||||
{
|
||||
<a href="@project.RemoteUrl" class="btn btn-primary">
|
||||
@if (string.IsNullOrWhiteSpace(project.RemoteTarget))
|
||||
{
|
||||
<span>View website</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>View on @project.RemoteTarget</span>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@foreach (IProject[] chunk in ProjectService.GetProjects(ProjectStatus.Past).Chunk(2))
|
||||
{
|
||||
<div class="card-group row" style="margin-top: 20px;">
|
||||
@foreach (IProject project in chunk)
|
||||
{
|
||||
<div class="col-xs-1 col-md-6 col-lg-6 d-flex align-items-stretch">
|
||||
<div class="card border-info project-card">
|
||||
<div class="card-header text-bg-info">Past Work</div>
|
||||
<img src="~/img/projects/hero/@project.HeroUrl" class="card-img-top" alt="@project.Name">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">@project.Name</h5>
|
||||
<p class="card-text">@Html.Raw(ProjectService.GetDescription(project))</p>
|
||||
@if (!string.IsNullOrWhiteSpace(project.RemoteUrl))
|
||||
{
|
||||
<a href="@project.RemoteUrl" class="btn btn-primary">
|
||||
@if (string.IsNullOrWhiteSpace(project.RemoteTarget))
|
||||
{
|
||||
<span>View website</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>View on @project.RemoteTarget</span>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@foreach (IProject[] chunk in ProjectService.GetProjects(ProjectStatus.Hiatus).Chunk(2))
|
||||
{
|
||||
<div class="card-group row" style="margin-top: 20px;">
|
||||
@foreach (IProject project in chunk)
|
||||
{
|
||||
<div class="col-xs-1 col-md-6 col-lg-6 d-flex align-items-stretch">
|
||||
<div class="card border-dark project-card">
|
||||
<div class="card-header text-bg-dark">On Hiatus</div>
|
||||
<img src="~/img/projects/hero/@project.HeroUrl" class="card-img-top" alt="@project.Name">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">@project.Name</h5>
|
||||
<p class="card-text">@Html.Raw(ProjectService.GetDescription(project))</p>
|
||||
@if (!string.IsNullOrWhiteSpace(project.RemoteUrl))
|
||||
{
|
||||
<a href="@project.RemoteUrl" class="btn btn-primary">
|
||||
@if (string.IsNullOrWhiteSpace(project.RemoteTarget))
|
||||
{
|
||||
<span>View website</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>View on @project.RemoteTarget</span>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
@ -0,0 +1,47 @@
|
||||
@page "/psa/binaryformatter"
|
||||
|
||||
<div class="alert alert-danger">
|
||||
<h2 class="alert-heading">⚠️ Stop! This application is unsafe!</h2>
|
||||
<p>
|
||||
This application is using an insecure method to read and write data, and needs to be updated
|
||||
<em>immediately</em>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning">
|
||||
<h4 class="alert-heading">I'm a user, what does this mean?</h4>
|
||||
<p>
|
||||
If you are seeing this message, it means you loaded a payload that I crafted to exploit this vulnerability. Be
|
||||
fortunate, because I could have done much worse including stealing your data or installing malware on your
|
||||
computer.
|
||||
</p>
|
||||
<p>
|
||||
If you're seeing this because you loaded my data from a game, this means it's possible for an attacker to craft
|
||||
a save file that can, for example, steal your Steam credentials and send them to a remote server. Just because
|
||||
you loaded - what seemed to be - a save file!
|
||||
</p>
|
||||
<hr/>
|
||||
<p>
|
||||
<strong>Do not</strong> load any more data into this application until the developer has addressed this issue.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<h4 class="alert-heading">I'm a developer, can you explain more?</h4>
|
||||
<p>
|
||||
<code>BinaryFormatter</code> is a .NET class that is used to serialize and deserialize data such as game saves
|
||||
or configuration files. However, it was discovered that this class is vulnerable to remote code execution when
|
||||
deserializing untrusted data.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Please update your application to use a different serialization method.</strong>
|
||||
</p>
|
||||
<hr/>
|
||||
<p>
|
||||
For more information, please read the
|
||||
<a href="https://learn.microsoft.com/en-us/dotnet/standard/serialization/binaryformatter-security-guide">
|
||||
official security notice
|
||||
</a>
|
||||
from Microsoft.
|
||||
</p>
|
||||
</div>
|
120
OliverBooth/Pages/Shared/_Layout.cshtml
Normal file
120
OliverBooth/Pages/Shared/_Layout.cshtml
Normal file
@ -0,0 +1,120 @@
|
||||
@using OliverBooth.Data.Blog
|
||||
@using OliverBooth.Services
|
||||
@inject IBlogPostService BlogPostService
|
||||
@{
|
||||
HttpRequest request = Context.Request;
|
||||
var url = new Uri($"{request.Scheme}://{request.Host}{request.Path}{request.QueryString}");
|
||||
}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="theme-color" content="#121212">
|
||||
@if (ViewData["Title"] != null)
|
||||
{
|
||||
<title>@ViewData["Title"] - Oliver Booth</title>
|
||||
}
|
||||
else
|
||||
{
|
||||
<title>Oliver Booth</title>
|
||||
}
|
||||
@if (ViewData["Post"] is IBlogPost post)
|
||||
{
|
||||
string excerpt = BlogPostService.RenderExcerpt(post, out bool trimmed);
|
||||
<meta name="title" content="@post.Title">
|
||||
<meta name="description" content="@excerpt">
|
||||
<meta property="og:title" content="@post.Title">
|
||||
<meta property="og:description" content="@excerpt">
|
||||
<meta property="og:type" content="article">
|
||||
<meta property="og:url" content="@url">
|
||||
<meta property="twitter:title" content="@post.Title">
|
||||
<meta property="twitter:description" content="@excerpt">
|
||||
<meta property="twitter:card" content="summary">
|
||||
<meta property="twitter:url" content="@url">
|
||||
}
|
||||
<link rel="shortcut icon" href="/img/favicon.png" asp-append-version="true">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.1/css/bootstrap.min.css" integrity="sha512-Z/def5z5u2aR89OuzYcxmDJ0Bnd5V1cKqBEbvLOiUNWdg9PQeXVvXLI90SE4QOHGlfLqUnDNVAYyZi8UwUTmWQ==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.8/katex.min.css" integrity="sha512-7nTa5CnxbzfQgjQrNmHXB7bxGTUVO/DcYX6rpgt06MkzM0rVXP3EYCv/Ojxg5H0dKbY7llbbYaqgfZjnGOAWGA==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css" integrity="sha512-z3gLpd7yknf1YoNbCzqRKc4qyor8gaKU1qmn+CShxbuBusANI9QpRohGBreCFkKxLhei6S9CQXFEbbKuqLg0DA==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css" integrity="sha512-c42qTSw/wPZ3/5LBzD+Bw5f7bSF2oxou6wEb+I/lqeaKV5FDIfMvvRp772y4jcJLKuGUOpbJMdg/BTl50fJYAw==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@100;400;700&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@200;400;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="~/css/prism.min.css" asp-append-version="true">
|
||||
<link rel="stylesheet" href="~/css/prism.vs.min.css" asp-append-version="true">
|
||||
<link rel="stylesheet" href="~/css/app.min.css" asp-append-version="true">
|
||||
</head>
|
||||
<body>
|
||||
<header class="container" style="margin-top: 20px;">
|
||||
<div id="site-title" class="text-center">
|
||||
<h1>
|
||||
<a href="/"><img src="~/img/ob-256x256.png" alt="Oliver Booth" height="128"> Oliver Booth</a>
|
||||
</h1>
|
||||
</div>
|
||||
</header>
|
||||
<nav>
|
||||
<ul class="site-nav">
|
||||
<li>
|
||||
<a asp-page="/Index">About</a>
|
||||
</li>
|
||||
<li>
|
||||
<a asp-page="/Blog/Index">Blog</a>
|
||||
</li>
|
||||
<li>
|
||||
<a asp-page="/Tutorials/Index">Tutorials</a>
|
||||
</li>
|
||||
<li>
|
||||
<a asp-page="/Projects/Index">Projects</a>
|
||||
</li>
|
||||
<li>
|
||||
<a asp-page="/Contact/Index">Contact</a>
|
||||
</li>
|
||||
<li>
|
||||
<a asp-page="/Donate">Donate</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div style="margin:50px 0;"></div>
|
||||
|
||||
<div class="container">
|
||||
<main role="main" class="pb-3">
|
||||
@RenderBody()
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<footer class="footer text-muted">
|
||||
<div class="container text-center">
|
||||
<hr>
|
||||
|
||||
<ul class="footer-nav">
|
||||
<li><a title="@("@oliver@mastodon.olivr.me")" href="https://mastodon.olivr.me/@@oliver" rel="me" class="brand-mastodon"><i class="fa-brands fa-mastodon"></i></a></li>
|
||||
<li><a title="LinkedIn/oliverlukebooth" href="https://www.linkedin.com/in/oliverlukebooth/" class="brand-linkedin"><i class="fa-brands fa-linkedin"></i></a></li>
|
||||
<li><a title="Blog RSS Feed" asp-controller="Rss" asp-action="OnGet"><i class="fa-solid fa-rss text-orange"></i></a></li>
|
||||
<li><a title="View Source" href="https://git.oliverbooth.dev/oliverbooth/oliverbooth.dev"><i class="fa-solid fa-code"></i></a></li>
|
||||
</ul>
|
||||
|
||||
<ul class="footer-nav" style="margin-top: 20px;">
|
||||
<li>© @DateTime.UtcNow.Year</li>
|
||||
<li><a asp-page="/privacy/index">Privacy</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.1/js/bootstrap.bundle.min.js" integrity="sha512-ToL6UYWePxjhDQKNioSi4AyJ5KkRxY+F1+Fi7Jgh0Hp5Kk2/s8FD7zusJDdonfe5B00Qw+B8taXxF6CFLnqNCw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.8/katex.min.js" integrity="sha512-aoZChv+8imY/U1O7KIHXvO87EOzCuKO0GhFtpD6G2Cyjo/xPeTgdf3/bchB10iB+AojMTDkMHDPLKNxPJVqDcw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/js/all.min.js" integrity="sha512-uKQ39gEGiyUJl4AI6L+ekBdGKpGw4xJ55+xyJG7YFlJokPNYegn9KwQ3P8A7aFQAUtUsAQHep+d/lrGqrbPIDQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/handlebars.js/4.7.8/handlebars.min.js" integrity="sha512-E1dSFxg+wsfJ4HKjutk/WaCzK7S2wv1POn1RRPGh8ZK+ag9l244Vqxji3r6wgz9YBf6+vhQEYJZpSjqWFPg9gg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
<script src="~/js/prism.min.js" asp-append-version="true" data-manual></script>
|
||||
<script src="~/js/app.min.js" asp-append-version="true"></script>
|
||||
|
||||
<script id="loading-spinner-template" type="text/x-handlebars-template">
|
||||
@await Html.PartialAsync("_LoadingSpinner")
|
||||
</script>
|
||||
|
||||
@await RenderSectionAsync("Scripts", required: false)
|
||||
</body>
|
||||
</html>
|
5
OliverBooth/Pages/Shared/_LoadingSpinner.cshtml
Normal file
5
OliverBooth/Pages/Shared/_LoadingSpinner.cshtml
Normal file
@ -0,0 +1,5 @@
|
||||
<div id="blog-loading-spinner" class="d-flex justify-content-center">
|
||||
<div class="spinner-border text-light" role="status">
|
||||
<p class="text-center sr-only">Loading...</p>
|
||||
</div>
|
||||
</div>
|
52
OliverBooth/Pages/Shared/_MinimalLayout.cshtml
Normal file
52
OliverBooth/Pages/Shared/_MinimalLayout.cshtml
Normal file
@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="theme-color" content="#121212">
|
||||
<title>Oliver Booth</title>
|
||||
<link rel="shortcut icon" href="/img/favicon.png" asp-append-version="true">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.1/css/bootstrap.min.css" integrity="sha512-Z/def5z5u2aR89OuzYcxmDJ0Bnd5V1cKqBEbvLOiUNWdg9PQeXVvXLI90SE4QOHGlfLqUnDNVAYyZi8UwUTmWQ==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.8/katex.min.css" integrity="sha512-7nTa5CnxbzfQgjQrNmHXB7bxGTUVO/DcYX6rpgt06MkzM0rVXP3EYCv/Ojxg5H0dKbY7llbbYaqgfZjnGOAWGA==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css" integrity="sha512-z3gLpd7yknf1YoNbCzqRKc4qyor8gaKU1qmn+CShxbuBusANI9QpRohGBreCFkKxLhei6S9CQXFEbbKuqLg0DA==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css" integrity="sha512-c42qTSw/wPZ3/5LBzD+Bw5f7bSF2oxou6wEb+I/lqeaKV5FDIfMvvRp772y4jcJLKuGUOpbJMdg/BTl50fJYAw==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@100;400;700&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@200;400;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="~/css/prism.min.css" asp-append-version="true">
|
||||
<link rel="stylesheet" href="~/css/prism.vs.min.css" asp-append-version="true">
|
||||
<link rel="stylesheet" href="~/css/app.min.css" asp-append-version="true">
|
||||
</head>
|
||||
<body>
|
||||
<header class="container" style="margin-top: 20px;">
|
||||
<div id="site-title" class="text-center">
|
||||
<h1>
|
||||
<a href="/"><img src="~/img/ob-256x256.png" alt="Oliver Booth" height="128"> Oliver Booth</a>
|
||||
</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style="margin:50px 0;"></div>
|
||||
|
||||
<div class="container">
|
||||
<main role="main" class="pb-3">
|
||||
@RenderBody()
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.1/js/bootstrap.bundle.min.js" integrity="sha512-ToL6UYWePxjhDQKNioSi4AyJ5KkRxY+F1+Fi7Jgh0Hp5Kk2/s8FD7zusJDdonfe5B00Qw+B8taXxF6CFLnqNCw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.8/katex.min.js" integrity="sha512-aoZChv+8imY/U1O7KIHXvO87EOzCuKO0GhFtpD6G2Cyjo/xPeTgdf3/bchB10iB+AojMTDkMHDPLKNxPJVqDcw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/js/all.min.js" integrity="sha512-uKQ39gEGiyUJl4AI6L+ekBdGKpGw4xJ55+xyJG7YFlJokPNYegn9KwQ3P8A7aFQAUtUsAQHep+d/lrGqrbPIDQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/handlebars.js/4.7.8/handlebars.min.js" integrity="sha512-E1dSFxg+wsfJ4HKjutk/WaCzK7S2wv1POn1RRPGh8ZK+ag9l244Vqxji3r6wgz9YBf6+vhQEYJZpSjqWFPg9gg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
<script src="~/js/prism.min.js" asp-append-version="true" data-manual></script>
|
||||
<script src="~/js/app.min.js" asp-append-version="true"></script>
|
||||
|
||||
<script id="loading-spinner-template" type="text/x-handlebars-template">
|
||||
@await Html.PartialAsync("_LoadingSpinner")
|
||||
</script>
|
||||
|
||||
@await RenderSectionAsync("Scripts", required: false)
|
||||
</body>
|
||||
</html>
|
17
OliverBooth/Pages/Tutorials/Index.cshtml
Normal file
17
OliverBooth/Pages/Tutorials/Index.cshtml
Normal file
@ -0,0 +1,17 @@
|
||||
@page "/tutorials"
|
||||
@{
|
||||
ViewData["Title"] = "Tutorials";
|
||||
}
|
||||
|
||||
<h1 class="display-4">Tutorials</h1>
|
||||
<p class="lead">Coming Soon</p>
|
||||
<p>
|
||||
Due to Unity's poor corporate decision-making, I'm left in a position where I find it infeasible to write Unity
|
||||
tutorials. I plan to write tutorials for things like Unreal and MonoGame as I learn them, and C# tutorials are
|
||||
still on the table for sure. But tutorials take a lot of time and effort, so unfortunately it may be a while before
|
||||
I can get around to publishing them.
|
||||
</p>
|
||||
<p>
|
||||
I'm sorry for the inconvenience, but I hope you understand my position. Watch this space! New tutorials will be
|
||||
coming. If you have any questions or requests, please feel free to <a asp-page="/Contact/Other">contact me</a>.
|
||||
</p>
|
2
OliverBooth/Pages/_ViewImports.cshtml
Normal file
2
OliverBooth/Pages/_ViewImports.cshtml
Normal file
@ -0,0 +1,2 @@
|
||||
@namespace OliverBooth.Pages
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
@ -1,3 +1,3 @@
|
||||
@{
|
||||
@{
|
||||
Layout = "_Layout";
|
||||
}
|
65
OliverBooth/Program.cs
Normal file
65
OliverBooth/Program.cs
Normal file
@ -0,0 +1,65 @@
|
||||
using Markdig;
|
||||
using OliverBooth.Data.Blog;
|
||||
using OliverBooth.Data.Web;
|
||||
using OliverBooth.Extensions;
|
||||
using OliverBooth.Markdown.Template;
|
||||
using OliverBooth.Markdown.Timestamp;
|
||||
using OliverBooth.Services;
|
||||
using Serilog;
|
||||
|
||||
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.AddSingleton(provider => new MarkdownPipelineBuilder()
|
||||
.Use<TimestampExtension>()
|
||||
.Use(new TemplateExtension(provider.GetRequiredService<ITemplateService>()))
|
||||
.UseAdvancedExtensions()
|
||||
.UseBootstrap()
|
||||
.UseEmojiAndSmiley()
|
||||
.UseSmartyPants()
|
||||
.Build());
|
||||
|
||||
builder.Services.AddDbContextFactory<BlogContext>();
|
||||
builder.Services.AddDbContextFactory<WebContext>();
|
||||
builder.Services.AddSingleton<ITemplateService, TemplateService>();
|
||||
builder.Services.AddSingleton<IBlogPostService, BlogPostService>();
|
||||
builder.Services.AddSingleton<IBlogUserService, BlogUserService>();
|
||||
builder.Services.AddSingleton<IProjectService, ProjectService>();
|
||||
builder.Services.AddRazorPages().AddRazorRuntimeCompilation();
|
||||
builder.Services.AddControllersWithViews();
|
||||
builder.Services.AddRouting(options => options.LowercaseUrls = true);
|
||||
|
||||
if (builder.Environment.IsProduction())
|
||||
{
|
||||
builder.WebHost.AddCertificateFromEnvironment(2845, 5049);
|
||||
}
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
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.UseStatusCodePagesWithRedirects("/error/{0}");
|
||||
app.UseStaticFiles();
|
||||
app.UseRouting();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapControllers();
|
||||
app.MapRazorPages();
|
||||
|
||||
app.Run();
|
173
OliverBooth/Services/BlogPostService.cs
Normal file
173
OliverBooth/Services/BlogPostService.cs
Normal file
@ -0,0 +1,173 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Humanizer;
|
||||
using Markdig;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OliverBooth.Data.Blog;
|
||||
|
||||
namespace OliverBooth.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an implementation of <see cref="IBlogPostService" />.
|
||||
/// </summary>
|
||||
internal sealed class BlogPostService : IBlogPostService
|
||||
{
|
||||
private readonly IDbContextFactory<BlogContext> _dbContextFactory;
|
||||
private readonly IBlogUserService _blogUserService;
|
||||
private readonly MarkdownPipeline _markdownPipeline;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="BlogPostService" /> class.
|
||||
/// </summary>
|
||||
/// <param name="dbContextFactory">
|
||||
/// The <see cref="IDbContextFactory{TContext}" /> used to create a <see cref="BlogContext" />.
|
||||
/// </param>
|
||||
/// <param name="blogUserService">The <see cref="IBlogUserService" />.</param>
|
||||
/// <param name="markdownPipeline">The <see cref="MarkdownPipeline" />.</param>
|
||||
public BlogPostService(IDbContextFactory<BlogContext> dbContextFactory,
|
||||
IBlogUserService blogUserService,
|
||||
MarkdownPipeline markdownPipeline)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_blogUserService = blogUserService;
|
||||
_markdownPipeline = markdownPipeline;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int GetBlogPostCount()
|
||||
{
|
||||
using BlogContext context = _dbContextFactory.CreateDbContext();
|
||||
return context.BlogPosts.Count();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<IBlogPost> GetAllBlogPosts(int limit = -1)
|
||||
{
|
||||
using BlogContext context = _dbContextFactory.CreateDbContext();
|
||||
IQueryable<BlogPost> ordered = context.BlogPosts
|
||||
.Where(p => p.Visibility == BlogPostVisibility.Published)
|
||||
.OrderByDescending(post => post.Published);
|
||||
if (limit > -1)
|
||||
{
|
||||
ordered = ordered.Take(limit);
|
||||
}
|
||||
|
||||
return ordered.AsEnumerable().Select(CacheAuthor).ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<IBlogPost> GetBlogPosts(int page, int pageSize = 10)
|
||||
{
|
||||
using BlogContext context = _dbContextFactory.CreateDbContext();
|
||||
return context.BlogPosts
|
||||
.Where(p => p.Visibility == BlogPostVisibility.Published)
|
||||
.OrderByDescending(post => post.Published)
|
||||
.Skip(page * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToArray().Select(CacheAuthor).ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IBlogPost? GetNextPost(IBlogPost blogPost)
|
||||
{
|
||||
using BlogContext context = _dbContextFactory.CreateDbContext();
|
||||
return context.BlogPosts
|
||||
.Where(p => p.Visibility == BlogPostVisibility.Published)
|
||||
.OrderBy(post => post.Published)
|
||||
.FirstOrDefault(post => post.Published > blogPost.Published);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IBlogPost? GetPreviousPost(IBlogPost blogPost)
|
||||
{
|
||||
using BlogContext context = _dbContextFactory.CreateDbContext();
|
||||
return context.BlogPosts
|
||||
.Where(p => p.Visibility == BlogPostVisibility.Published)
|
||||
.OrderByDescending(post => post.Published)
|
||||
.FirstOrDefault(post => post.Published < blogPost.Published);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string RenderExcerpt(IBlogPost post, out bool wasTrimmed)
|
||||
{
|
||||
string body = post.Body;
|
||||
int moreIndex = body.IndexOf("<!--more-->", StringComparison.Ordinal);
|
||||
|
||||
if (moreIndex == -1)
|
||||
{
|
||||
string excerpt = body.Truncate(255, "...");
|
||||
wasTrimmed = body.Length > 255;
|
||||
return Markdig.Markdown.ToHtml(excerpt, _markdownPipeline);
|
||||
}
|
||||
|
||||
wasTrimmed = true;
|
||||
return Markdig.Markdown.ToHtml(body[..moreIndex], _markdownPipeline);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string RenderPost(IBlogPost post)
|
||||
{
|
||||
return Markdig.Markdown.ToHtml(post.Body, _markdownPipeline);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryGetPost(Guid id, [NotNullWhen(true)] out IBlogPost? post)
|
||||
{
|
||||
using BlogContext context = _dbContextFactory.CreateDbContext();
|
||||
post = context.BlogPosts.Find(id);
|
||||
if (post is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
CacheAuthor((BlogPost)post);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryGetPost(int id, [NotNullWhen(true)] out IBlogPost? post)
|
||||
{
|
||||
using BlogContext context = _dbContextFactory.CreateDbContext();
|
||||
post = context.BlogPosts.FirstOrDefault(p => p.WordPressId == id);
|
||||
if (post is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
CacheAuthor((BlogPost)post);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryGetPost(DateOnly publishDate, string slug, [NotNullWhen(true)] out IBlogPost? post)
|
||||
{
|
||||
using BlogContext context = _dbContextFactory.CreateDbContext();
|
||||
post = context.BlogPosts.FirstOrDefault(post => post.Published.Year == publishDate.Year &&
|
||||
post.Published.Month == publishDate.Month &&
|
||||
post.Published.Day == publishDate.Day &&
|
||||
post.Slug == slug);
|
||||
|
||||
if (post is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
CacheAuthor((BlogPost)post);
|
||||
return true;
|
||||
}
|
||||
|
||||
private BlogPost CacheAuthor(BlogPost post)
|
||||
{
|
||||
// ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
|
||||
if (post.Author is not null)
|
||||
{
|
||||
return post;
|
||||
}
|
||||
|
||||
if (_blogUserService.TryGetUser(post.AuthorId, out IUser? user) && user is IBlogAuthor author)
|
||||
{
|
||||
post.Author = author;
|
||||
}
|
||||
|
||||
return post;
|
||||
}
|
||||
}
|
38
OliverBooth/Services/BlogUserService.cs
Normal file
38
OliverBooth/Services/BlogUserService.cs
Normal file
@ -0,0 +1,38 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OliverBooth.Data.Blog;
|
||||
|
||||
namespace OliverBooth.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an implementation of <see cref="IBlogUserService" />.
|
||||
/// </summary>
|
||||
internal sealed class BlogUserService : IBlogUserService
|
||||
{
|
||||
private readonly IDbContextFactory<BlogContext> _dbContextFactory;
|
||||
private readonly ConcurrentDictionary<Guid, IUser> _userCache = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="BlogUserService" /> class.
|
||||
/// </summary>
|
||||
/// <param name="dbContextFactory">
|
||||
/// The <see cref="IDbContextFactory{TContext}" /> used to create a <see cref="BlogContext" />.
|
||||
/// </param>
|
||||
public BlogUserService(IDbContextFactory<BlogContext> dbContextFactory)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryGetUser(Guid id, [NotNullWhen(true)] out IUser? user)
|
||||
{
|
||||
if (_userCache.TryGetValue(id, out user)) return true;
|
||||
|
||||
using BlogContext context = _dbContextFactory.CreateDbContext();
|
||||
user = context.Users.Find(id);
|
||||
|
||||
if (user is not null) _userCache.TryAdd(id, user);
|
||||
return user is not null;
|
||||
}
|
||||
}
|
111
OliverBooth/Services/IBlogPostService.cs
Normal file
111
OliverBooth/Services/IBlogPostService.cs
Normal file
@ -0,0 +1,111 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using OliverBooth.Data.Blog;
|
||||
|
||||
namespace OliverBooth.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a service for managing blog posts.
|
||||
/// </summary>
|
||||
public interface IBlogPostService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns a collection of all blog posts.
|
||||
/// </summary>
|
||||
/// <param name="limit">The maximum number of posts to return. A value of -1 returns all posts.</param>
|
||||
/// <returns>A collection of all blog posts.</returns>
|
||||
/// <remarks>
|
||||
/// This method may slow down execution if there are a large number of blog posts being requested. It is
|
||||
/// recommended to use <see cref="GetBlogPosts" /> instead.
|
||||
/// </remarks>
|
||||
IReadOnlyList<IBlogPost> GetAllBlogPosts(int limit = -1);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the total number of blog posts.
|
||||
/// </summary>
|
||||
/// <returns>The total number of blog posts.</returns>
|
||||
int GetBlogPostCount();
|
||||
|
||||
/// <summary>
|
||||
/// Returns a collection of blog posts from the specified page, optionally limiting the number of posts
|
||||
/// returned per page.
|
||||
/// </summary>
|
||||
/// <param name="page">The zero-based index of the page to return.</param>
|
||||
/// <param name="pageSize">The maximum number of posts to return per page.</param>
|
||||
/// <returns>A collection of blog posts.</returns>
|
||||
IReadOnlyList<IBlogPost> GetBlogPosts(int page, int pageSize = 10);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the next blog post from the specified blog post.
|
||||
/// </summary>
|
||||
/// <param name="blogPost">The blog post whose next post to return.</param>
|
||||
/// <returns>The next blog post from the specified blog post.</returns>
|
||||
IBlogPost? GetNextPost(IBlogPost blogPost);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the previous blog post from the specified blog post.
|
||||
/// </summary>
|
||||
/// <param name="blogPost">The blog post whose previous post to return.</param>
|
||||
/// <returns>The previous blog post from the specified blog post.</returns>
|
||||
IBlogPost? GetPreviousPost(IBlogPost blogPost);
|
||||
|
||||
/// <summary>
|
||||
/// Renders the excerpt of the specified blog post.
|
||||
/// </summary>
|
||||
/// <param name="post">The blog post whose excerpt to render.</param>
|
||||
/// <param name="wasTrimmed">
|
||||
/// When this method returns, contains <see langword="true" /> if the excerpt was trimmed; otherwise,
|
||||
/// <see langword="false" />.
|
||||
/// </param>
|
||||
/// <returns>The rendered HTML of the blog post's excerpt.</returns>
|
||||
string RenderExcerpt(IBlogPost post, out bool wasTrimmed);
|
||||
|
||||
/// <summary>
|
||||
/// Renders the body of the specified blog post.
|
||||
/// </summary>
|
||||
/// <param name="post">The blog post to render.</param>
|
||||
/// <returns>The rendered HTML of the blog post.</returns>
|
||||
string RenderPost(IBlogPost post);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to find a blog post with the specified ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID of the blog post to find.</param>
|
||||
/// <param name="post">
|
||||
/// When this method returns, contains the blog post with the specified ID, if the blog post is found;
|
||||
/// otherwise, <see langword="null" />.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// <see langword="true" /> if a blog post with the specified ID is found; otherwise, <see langword="false" />.
|
||||
/// </returns>
|
||||
bool TryGetPost(Guid id, [NotNullWhen(true)] out IBlogPost? post);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to find a blog post with the specified WordPress ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID of the blog post to find.</param>
|
||||
/// <param name="post">
|
||||
/// When this method returns, contains the blog post with the specified WordPress ID, if the blog post is found;
|
||||
/// otherwise, <see langword="null" />.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// <see langword="true" /> if a blog post with the specified WordPress ID is found; otherwise,
|
||||
/// <see langword="false" />.
|
||||
/// </returns>
|
||||
bool TryGetPost(int id, [NotNullWhen(true)] out IBlogPost? post);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to find a blog post with the specified publish date and URL slug.
|
||||
/// </summary>
|
||||
/// <param name="publishDate">The date the blog post was published.</param>
|
||||
/// <param name="slug">The URL slug of the blog post to find.</param>
|
||||
/// <param name="post">
|
||||
/// When this method returns, contains the blog post with the specified publish date and URL slug, if the blog
|
||||
/// post is found; otherwise, <see langword="null" />.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// <see langword="true" /> if a blog post with the specified publish date and URL slug is found; otherwise,
|
||||
/// <see langword="false" />.
|
||||
/// </returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="slug" /> is <see langword="null" />.</exception>
|
||||
bool TryGetPost(DateOnly publishDate, string slug, [NotNullWhen(true)] out IBlogPost? post);
|
||||
}
|
23
OliverBooth/Services/IBlogUserService.cs
Normal file
23
OliverBooth/Services/IBlogUserService.cs
Normal file
@ -0,0 +1,23 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using OliverBooth.Data.Blog;
|
||||
|
||||
namespace OliverBooth.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);
|
||||
}
|
57
OliverBooth/Services/IProjectService.cs
Normal file
57
OliverBooth/Services/IProjectService.cs
Normal file
@ -0,0 +1,57 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using OliverBooth.Data.Web;
|
||||
|
||||
namespace OliverBooth.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a service for interacting with projects.
|
||||
/// </summary>
|
||||
public interface IProjectService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the description of the specified project.
|
||||
/// </summary>
|
||||
/// <param name="project">The project whose description to get.</param>
|
||||
/// <returns>The description of the specified project.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="project" /> is <see langword="null" />.</exception>
|
||||
string GetDescription(IProject project);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all projects.
|
||||
/// </summary>
|
||||
/// <returns>A read-only list of projects.</returns>
|
||||
IReadOnlyList<IProject> GetAllProjects();
|
||||
|
||||
/// <summary>
|
||||
/// Gets all projects with the specified status.
|
||||
/// </summary>
|
||||
/// <param name="status">The status of the projects to get.</param>
|
||||
/// <returns>A read-only list of projects with the specified status.</returns>
|
||||
IReadOnlyList<IProject> GetProjects(ProjectStatus status = ProjectStatus.Ongoing);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to find a project with the specified ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID of the project.</param>
|
||||
/// <param name="project">
|
||||
/// When this method returns, contains the project associated with the specified ID, if the project is found;
|
||||
/// otherwise, <see langword="null" />.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// <see langword="true" /> if a project with the specified ID is found; otherwise, <see langword="false" />.
|
||||
/// </returns>
|
||||
bool TryGetProject(Guid id, [NotNullWhen(true)] out IProject? project);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to find a project with the specified slug.
|
||||
/// </summary>
|
||||
/// <param name="slug">The slug of the project.</param>
|
||||
/// <param name="project">
|
||||
/// When this method returns, contains the project associated with the specified slug, if the project is found;
|
||||
/// otherwise, <see langword="null" />.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// <see langword="true" /> if a project with the specified slug is found; otherwise, <see langword="false" />.
|
||||
/// </returns>
|
||||
bool TryGetProject(string slug, [NotNullWhen(true)] out IProject? project);
|
||||
}
|
55
OliverBooth/Services/ITemplateService.cs
Normal file
55
OliverBooth/Services/ITemplateService.cs
Normal file
@ -0,0 +1,55 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using OliverBooth.Data.Web;
|
||||
using OliverBooth.Markdown.Template;
|
||||
|
||||
namespace OliverBooth.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a service that renders MediaWiki-style templates.
|
||||
/// </summary>
|
||||
public interface ITemplateService
|
||||
{
|
||||
/// <summary>
|
||||
/// Renders the specified global template with the specified arguments.
|
||||
/// </summary>
|
||||
/// <param name="templateInline">The global template to render.</param>
|
||||
/// <returns>The rendered global template.</returns>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// <paramref name="templateInline" /> is <see langword="null" />.
|
||||
/// </exception>
|
||||
string RenderGlobalTemplate(TemplateInline templateInline);
|
||||
|
||||
/// <summary>
|
||||
/// Renders the specified global template with the specified arguments.
|
||||
/// </summary>
|
||||
/// <param name="templateInline">The global template to render.</param>
|
||||
/// <param name="template">The database template object.</param>
|
||||
/// <returns>The rendered global template.</returns>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// <paramref name="templateInline" /> is <see langword="null" />.
|
||||
/// </exception>
|
||||
string RenderTemplate(TemplateInline templateInline, ITemplate? template);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to get the template with the specified name.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the template.</param>
|
||||
/// <param name="template">
|
||||
/// When this method returns, contains the template with the specified name, if the template is found;
|
||||
/// otherwise, <see langword="null" />.
|
||||
/// </param>
|
||||
/// <returns><see langword="true" /> if the template exists; otherwise, <see langword="false" />.</returns>
|
||||
bool TryGetTemplate(string name, [NotNullWhen(true)] out ITemplate? template);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to get the template with the specified name and variant.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the template.</param>
|
||||
/// <param name="variant">The variant of the template.</param>
|
||||
/// <param name="template">
|
||||
/// When this method returns, contains the template with the specified name and variant, if the template is
|
||||
/// found; otherwise, <see langword="null" />.
|
||||
/// </param>
|
||||
/// <returns><see langword="true" /> if the template exists; otherwise, <see langword="false" />.</returns>
|
||||
bool TryGetTemplate(string name, string variant, [NotNullWhen(true)] out ITemplate? template);
|
||||
}
|
62
OliverBooth/Services/ProjectService.cs
Normal file
62
OliverBooth/Services/ProjectService.cs
Normal file
@ -0,0 +1,62 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Markdig;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OliverBooth.Data.Web;
|
||||
|
||||
namespace OliverBooth.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a service for interacting with projects.
|
||||
/// </summary>
|
||||
internal sealed class ProjectService : IProjectService
|
||||
{
|
||||
private readonly IDbContextFactory<WebContext> _dbContextFactory;
|
||||
private readonly MarkdownPipeline _markdownPipeline;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ProjectService" /> class.
|
||||
/// </summary>
|
||||
/// <param name="dbContextFactory">The database context factory.</param>
|
||||
/// <param name="markdownPipeline">The Markdown pipeline.</param>
|
||||
public ProjectService(IDbContextFactory<WebContext> dbContextFactory, MarkdownPipeline markdownPipeline)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_markdownPipeline = markdownPipeline;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetDescription(IProject project)
|
||||
{
|
||||
return Markdig.Markdown.ToHtml(project.Description, _markdownPipeline);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<IProject> GetAllProjects()
|
||||
{
|
||||
using WebContext context = _dbContextFactory.CreateDbContext();
|
||||
return context.Projects.OrderBy(p => p.Rank).ThenBy(p => p.Name).ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<IProject> GetProjects(ProjectStatus status = ProjectStatus.Ongoing)
|
||||
{
|
||||
using WebContext context = _dbContextFactory.CreateDbContext();
|
||||
return context.Projects.Where(p => p.Status == status).OrderBy(p => p.Rank).ThenBy(p => p.Name).ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryGetProject(Guid id, [NotNullWhen(true)] out IProject? project)
|
||||
{
|
||||
using WebContext context = _dbContextFactory.CreateDbContext();
|
||||
project = context.Projects.Find(id);
|
||||
return project is not null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryGetProject(string slug, [NotNullWhen(true)] out IProject? project)
|
||||
{
|
||||
using WebContext context = _dbContextFactory.CreateDbContext();
|
||||
project = context.Projects.FirstOrDefault(p => p.Slug == slug);
|
||||
return project is not null;
|
||||
}
|
||||
}
|
98
OliverBooth/Services/TemplateService.cs
Normal file
98
OliverBooth/Services/TemplateService.cs
Normal file
@ -0,0 +1,98 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OliverBooth.Data.Web;
|
||||
using OliverBooth.Formatting;
|
||||
using OliverBooth.Markdown.Template;
|
||||
using SmartFormat;
|
||||
using SmartFormat.Extensions;
|
||||
|
||||
namespace OliverBooth.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a service that renders MediaWiki-style templates.
|
||||
/// </summary>
|
||||
internal sealed class TemplateService : ITemplateService
|
||||
{
|
||||
private static readonly Random Random = new();
|
||||
private readonly IDbContextFactory<WebContext> _webContextFactory;
|
||||
private readonly SmartFormatter _formatter;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TemplateService" /> class.
|
||||
/// </summary>
|
||||
/// <param name="serviceProvider">The <see cref="IServiceProvider" />.</param>
|
||||
/// <param name="webContextFactory">The <see cref="WebContext" /> factory.</param>
|
||||
public TemplateService(IServiceProvider serviceProvider,
|
||||
IDbContextFactory<WebContext> webContextFactory)
|
||||
{
|
||||
_formatter = Smart.CreateDefaultSmartFormat();
|
||||
_formatter.AddExtensions(new DefaultSource());
|
||||
_formatter.AddExtensions(new ReflectionSource());
|
||||
_formatter.AddExtensions(new DateFormatter());
|
||||
_formatter.AddExtensions(new MarkdownFormatter(serviceProvider));
|
||||
|
||||
_webContextFactory = webContextFactory;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string RenderGlobalTemplate(TemplateInline templateInline)
|
||||
{
|
||||
if (templateInline is null) throw new ArgumentNullException(nameof(templateInline));
|
||||
|
||||
return TryGetTemplate(templateInline.Name, templateInline.Variant, out ITemplate? template)
|
||||
? RenderTemplate(templateInline, template)
|
||||
: GetDefaultRender(templateInline);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string RenderTemplate(TemplateInline templateInline, ITemplate? template)
|
||||
{
|
||||
if (template is null)
|
||||
{
|
||||
return GetDefaultRender(templateInline);
|
||||
}
|
||||
|
||||
Span<byte> randomBytes = stackalloc byte[20];
|
||||
Random.NextBytes(randomBytes);
|
||||
|
||||
var formatted = new
|
||||
{
|
||||
templateInline.ArgumentList,
|
||||
templateInline.ArgumentString,
|
||||
templateInline.Params,
|
||||
RandomInt = BinaryPrimitives.ReadInt32LittleEndian(randomBytes[..4]),
|
||||
RandomGuid = new Guid(randomBytes[4..]).ToString("N"),
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
return _formatter.Format(template.FormatString, formatted);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return GetDefaultRender(templateInline);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryGetTemplate(string name, [NotNullWhen(true)] out ITemplate? template)
|
||||
{
|
||||
return TryGetTemplate(name, string.Empty, out template);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryGetTemplate(string name, string variant, [NotNullWhen(true)] out ITemplate? template)
|
||||
{
|
||||
using WebContext context = _webContextFactory.CreateDbContext();
|
||||
template = context.Templates.FirstOrDefault(t => t.Name == name && t.Variant == variant);
|
||||
return template is not null;
|
||||
}
|
||||
|
||||
private static string GetDefaultRender(TemplateInline templateInline)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(templateInline.ArgumentString)
|
||||
? $"{{{{{templateInline.Name}}}}}"
|
||||
: $"{{{{{templateInline.Name}|{templateInline.ArgumentString}}}}}";
|
||||
}
|
||||
}
|
7
README.md
Normal file
7
README.md
Normal file
@ -0,0 +1,7 @@
|
||||
<h1 align="center"><img src="icon.png"></h1>
|
||||
<h1 align="center">oliverbooth.dev</h1>
|
||||
<p align="center">
|
||||
<a href="https://github.com/oliverbooth/oliverbooth.dev/actions/workflows/dotnet.yml"><img src="https://img.shields.io/github/actions/workflow/status/oliverbooth/oliverbooth.dev/dotnet.yml?style=flat-square" alt="GitHub Workflow Status" title="GitHub Workflow Status"></a>
|
||||
<a href="https://github.com/oliverbooth/oliverbooth.dev/issues"><img src="https://img.shields.io/github/issues/oliverbooth/oliverbooth.dev?style=flat-square" alt="GitHub Issues" title="GitHub Issues"></a>
|
||||
<a href="https://github.com/oliverbooth/oliverbooth.dev/blob/master/LICENSE.md"><img src="https://img.shields.io/github/license/oliverbooth/oliverbooth.dev?style=flat-square" alt="MIT License" title="MIT License"></a>
|
||||
</p>
|
@ -3,7 +3,9 @@ services:
|
||||
oliverbooth:
|
||||
container_name: oliverbooth.dev
|
||||
pull_policy: build
|
||||
build: .
|
||||
build:
|
||||
context: .
|
||||
dockerfile: OliverBooth/Dockerfile
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/log/oliverbooth/site
|
||||
@ -11,9 +13,9 @@ services:
|
||||
- type: bind
|
||||
source: /etc/oliverbooth/site
|
||||
target: /app/data
|
||||
ports:
|
||||
- "2845:2845"
|
||||
restart: always
|
||||
environment:
|
||||
- MYSQL_HOST=${MYSQL_HOST}
|
||||
- MYSQL_DATABASE=${MYSQL_DATABASE}
|
||||
- MYSQL_USER=${MYSQL_USER}
|
||||
- MYSQL_PASSWORD=${MYSQL_PASSWORD}
|
||||
- SSL_CERT_PATH=${SSL_CERT_PATH}
|
||||
- SSL_KEY_PATH=${SSL_KEY_PATH}
|
||||
|
@ -1,16 +0,0 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "oliverbooth.dev", "oliverbooth.dev\oliverbooth.dev.csproj", "{A58A6FA3-480C-400B-822A-3786741BF39C}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{A58A6FA3-480C-400B-822A-3786741BF39C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A58A6FA3-480C-400B-822A-3786741BF39C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A58A6FA3-480C-400B-822A-3786741BF39C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A58A6FA3-480C-400B-822A-3786741BF39C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
@ -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>
|
@ -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://docs.microsoft.com/aspnet/core">building Web apps with ASP.NET Core</a>.</p>
|
||||
</div>
|
@ -1,18 +0,0 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace oliverbooth.dev.Pages;
|
||||
|
||||
public class IndexModel : PageModel
|
||||
{
|
||||
private readonly ILogger<IndexModel> _logger;
|
||||
|
||||
public IndexModel(ILogger<IndexModel> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void OnGet()
|
||||
{
|
||||
}
|
||||
}
|
@ -1,37 +0,0 @@
|
||||
@page
|
||||
@model OliverBooth.Pages.Privacy.Index
|
||||
@{
|
||||
ViewData["Title"] = "Privacy Policy";
|
||||
}
|
||||
<h1 class="display-4">@ViewData["Title"]</h1>
|
||||
<p class="lead">Last Updated: 26 May 2023</p>
|
||||
<p>This Privacy Policy describes how your personal information is collected, used, and shared when you visit or interact with my website <a href="https://oliverbooth.dev/">oliverbooth.dev</a>.</p>
|
||||
|
||||
<h2>Introduction</h2>
|
||||
<p>I am committed to protecting your privacy and ensuring the security of any information you provide to me when using my website. This Privacy Policy outlines my practices regarding the collection, use, and disclosure of information I may gather from users of my website.</p>
|
||||
|
||||
<h2>Information I Collect</h2>
|
||||
<p>When you choose to contact me via the contact form on my website, I collect your name and email address. This information is provided voluntarily by you and is necessary for me to respond to your inquiries and engage in a conversation. I do not collect any additional personally identifiable information about you through the contact form.</p>
|
||||
<p>I do not use any cookies or similar tracking technologies that can identify individual users.</p>
|
||||
<p>Please note that my website includes a Disqus integration for commenting on blog posts. Disqus is a third-party service, and their use of cookies and collection of personal information are governed by their own privacy policies. I have no control over the information collected by Disqus, and I encourage you to review their privacy policy to understand how your information may be used by them.</p>
|
||||
|
||||
<h2>Use of Information</h2>
|
||||
<p>The name and email address you provide through the contact form are used solely for the purpose of responding to your inquiries and engaging in relevant conversation. I keep this information confidential and do not disclose, sell, or share it with any third parties unless required by law or with your explicit consent.</p>
|
||||
<p>I do not use your personal information for marketing purposes or send you any unsolicited communications. Once our conversation is complete and no longer necessary, I will securely delete your personal information unless otherwise required to retain it by applicable laws or regulations.</p>
|
||||
|
||||
<h2>Disclosure of Information</h2>
|
||||
<p>I do not disclose your personal information to any third parties unless required by law or with your explicit consent. I maintain strict confidentiality and take reasonable precautions to protect your personal information from unauthorized access, use, or disclosure.</p>
|
||||
<p>However, please be aware that my website contains links to third-party websites and services. I am not responsible for the privacy practices or content of these third-party sites. I encourage you to review the privacy policies of those third-party sites before providing any personal information.</p>
|
||||
|
||||
<h2>Security</h2>
|
||||
<p>I prioritize the security of your personal information and take reasonable precautions to protect it. However, please be aware that no method of transmission over the internet or electronic storage is 100% secure, and I cannot guarantee absolute security.</p>
|
||||
|
||||
<h2>Changes to this Privacy Policy</h2>
|
||||
<p>I may update this Privacy Policy from time to time to reflect changes in my practices or for other operational, legal, or regulatory reasons. I encourage you to review this Privacy Policy periodically for any updates. The revised policy will be effective immediately upon posting.</p>
|
||||
|
||||
<h2>Contact Me</h2>
|
||||
<p>If you have any questions or concerns about this Privacy Policy or my privacy practices, please <a asp-page="/contact/privacy" asp-route-which="website">get in touch</a>.</p>
|
||||
|
||||
<hr/>
|
||||
|
||||
<p>By using my website, you signify your acceptance of this Privacy Policy.</p>
|
@ -1,7 +0,0 @@
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace OliverBooth.Pages.Privacy;
|
||||
|
||||
public class Index : PageModel
|
||||
{
|
||||
}
|
@ -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.dev</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.dev.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.dev</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">
|
||||
© 2023 - oliverbooth.dev - <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>
|
@ -1,48 +0,0 @@
|
||||
/* Please see documentation at https://docs.microsoft.com/aspnet/core/client-side/bundling-and-minification
|
||||
for details on configuring this project to bundle and minify static web assets. */
|
||||
|
||||
a.navbar-brand {
|
||||
white-space: normal;
|
||||
text-align: center;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #0077cc;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
color: #fff;
|
||||
background-color: #1b6ec2;
|
||||
border-color: #1861ac;
|
||||
}
|
||||
|
||||
.nav-pills .nav-link.active, .nav-pills .show > .nav-link {
|
||||
color: #fff;
|
||||
background-color: #1b6ec2;
|
||||
border-color: #1861ac;
|
||||
}
|
||||
|
||||
.border-top {
|
||||
border-top: 1px solid #e5e5e5;
|
||||
}
|
||||
.border-bottom {
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.box-shadow {
|
||||
box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .05);
|
||||
}
|
||||
|
||||
button.accept-policy {
|
||||
font-size: 1rem;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
line-height: 60px;
|
||||
}
|
@ -1,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>
|
@ -1,3 +0,0 @@
|
||||
@using oliverbooth.dev
|
||||
@namespace oliverbooth.dev.Pages
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
@ -1,22 +0,0 @@
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddRazorPages();
|
||||
builder.Services.AddRouting(options => options.LowercaseUrls = true);
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
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.MapRazorPages();
|
||||
|
||||
app.Run();
|
@ -1,37 +0,0 @@
|
||||
{
|
||||
"iisSettings": {
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:20342",
|
||||
"sslPort": 44312
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5049",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:7295;http://localhost:5049",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"IIS Express": {
|
||||
"commandName": "IISExpress",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
{
|
||||
"DetailedErrors": true,
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
@ -1,16 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="..\.dockerignore">
|
||||
<Link>.dockerignore</Link>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
@ -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;
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user