Compare commits

...

312 Commits

Author SHA1 Message Date
1200318326
feat: add password protection to blog posts (WEB-3) 2023-09-26 12:46:18 +01:00
40d8052116
fix: remove invalid contact refs 2023-09-25 20:08:05 +01:00
c3706213f7
chore: remove unused imports 2023-09-25 20:06:30 +01:00
e65e4aeeb6
feat: add basic error pages 2023-09-25 20:06:05 +01:00
33c3b434d7
fix: redirect to error message on contact failure 2023-09-25 19:58:11 +01:00
00aed04181
style: reword failure message 2023-09-25 19:57:47 +01:00
5644dfd2e3
style: add title attr to footer links
Also separates socials from copyright/privacy
2023-09-25 19:56:00 +01:00
f912fa580d
refactor!: merge contact into one form (WEB-2) 2023-09-25 19:55:34 +01:00
bcb2e9292a
feat: add 5 O'Clock Somewhere policy option 2023-09-25 17:31:41 +01:00
4f8ab1db4f
docs: update google play privacy policy
Important amendment about lack of usage tracking!
2023-09-24 19:40:00 +01:00
3a7d807ac4
feat: add specific app policy
The policy that applies to It's 5 O'Clock Somewhere must be different since it collects minor information about the user.
2023-09-24 19:39:26 +01:00
fd2ecf0b5c
style: add brand colours to social links 2023-09-24 19:11:27 +01:00
e0b236831b
feat: add LinkedIn to footer 2023-09-24 18:39:34 +01:00
b04e63a8a3
fix: fix URI encoding for tag filter (WEB-1) 2023-09-24 18:39:21 +01:00
a0fd48e6ca
fix: encode tag for URI (WEB-1)
The tag 'c#' was causing problems since # is a special URI char oops.
2023-09-24 17:15:40 +01:00
f48713c470
feat: add tag filtering (resolves WEB-1) 2023-09-24 17:03:06 +01:00
856c33a74f
chore: add MailKit 2023-09-24 16:46:49 +01:00
dd2a0c027b
style: space footer nav instead of using bullet points 2023-09-24 16:44:36 +01:00
4b3e345a1b
style: add headshot image to index 2023-09-24 16:39:58 +01:00
6b1a75bfcc
feat: load projects from db 2023-09-24 14:37:38 +01:00
dede552729
fix: add SAMP.NET hero 2023-09-24 00:05:44 +01:00
dd111cb0de
style: add hover transition for .project-card 2023-09-24 00:05:29 +01:00
3076f58485
feat: add working privacy policy contact form 2023-09-24 00:04:44 +01:00
5283985026
style: add header to blog cards 2023-09-23 22:09:26 +01:00
a9c4b3a144
feat: display post tags 2023-09-23 22:08:25 +01:00
2ea52759b8
style: <mark> instead of <strong> for more visual clarity 2023-09-23 21:10:23 +01:00
7cad3204df
style: s/and/but 2023-09-23 21:10:02 +01:00
eb152aaa09
fix: fix typo in Discord 2023-09-22 16:47:28 +01:00
a75536d08b
fix: remove invalid @model ref for projects page 2023-09-22 14:57:21 +01:00
08eed3c71e
feat: add Other contact page submission 2023-09-22 14:57:04 +01:00
1a20749809
fix: allow arbitrary string input in starting salary 2023-09-20 17:56:15 +01:00
eed1e3ad8d
fix: use correct field names on project idea form 2023-09-20 17:56:01 +01:00
6ef492016c
refactor: remove your- prefix from form fields 2023-09-20 17:55:35 +01:00
bb088e3107
refactor: update projects, cancel Unity API docs 2023-09-20 17:20:41 +01:00
5558aecb5a
style: fix spacing on links in index 2023-09-20 14:50:43 +01:00
39b455caf0
refactor: call toString on numeric timestamp 2023-09-20 14:49:54 +01:00
9885bfaed9
feat: display private/unlisted alert for article 2023-09-20 14:49:02 +01:00
73f5e4e4a2
fix: use correct route for /blog?p= redirect 2023-09-20 14:48:26 +01:00
d114870f87
feat: add links to next and prev articles 2023-09-19 19:29:21 +01:00
2fd4b704cd
feat: add binaryformatter psa 2023-09-16 14:48:26 +01:00
7ae8a749d2
feat: add tutorials page 2023-09-16 14:48:14 +01:00
ffaa2b2fa4
fix(blog): fix rss permalink 2023-08-20 14:23:50 +01:00
0e583de316
feat(blog): add post visibility and password 2023-08-20 14:23:04 +01:00
06fd256ec8
refactor: remove redundant models for privacy policy pages 2023-08-20 13:30:11 +01:00
9295c4a848
refactor: remove ctrl+u capture 2023-08-16 15:23:30 +01:00
70bf8aca19
fix: add name to checkbox 2023-08-16 15:22:35 +01:00
bcbf963cd8
style: fix typo 2023-08-16 15:22:10 +01:00
7dc9c4c6f7
style: add 20px margin to faq accordion 2023-08-16 15:17:48 +01:00
e5fd4b106e
fix: match any case hex char 2023-08-16 15:17:05 +01:00
1c044a9c96
style: fix typo on index page 2023-08-16 14:51:08 +01:00
b36a3207ca
docs(blog): use rename interface in param 2023-08-16 14:44:57 +01:00
f18ae5eba4
refactor(web): make dbcontext internal 2023-08-16 14:09:43 +01:00
9e2fa951f1
refactor: use PascalCase for asp-page routes 2023-08-16 14:08:33 +01:00
8202bb7440
style: reword open source text 2023-08-16 14:02:58 +01:00
1e1f67b9b4
fix: allow styled mark tags 2023-08-16 13:56:13 +01:00
09f3535d77
refactor: remove unused ns import 2023-08-15 17:07:49 +01:00
20eabeeb1e
refactor: remove unneeded log line 2023-08-15 17:06:00 +01:00
fe4701c1bf
chore(debug): set minlevel for DEBUG config 2023-08-15 17:05:16 +01:00
d9c6034aa0
fix(blog): link to relative index not root index 2023-08-15 17:04:58 +01:00
7ee9d3637c
feat: add support for template variants 2023-08-15 17:04:43 +01:00
1cdad4c17c
fix(blog): set post in ViewData for article 2023-08-14 00:58:58 +01:00
b9e2597bc0
feat(prism): define hex/binary langs for better highlighting 2023-08-14 00:58:32 +01:00
e7cbe0330b
feat: add post metadata for social embed support 2023-08-14 00:57:46 +01:00
9ce4b844fe
style: format RSS link child 2023-08-14 00:57:13 +01:00
c3e64a6cde
style: add theme-color #121212 2023-08-14 00:56:59 +01:00
adf9e63008
style: remove self-closing / 2023-08-14 00:56:45 +01:00
fdea721f4f
fix: fix support for inline templates 2023-08-14 00:56:05 +01:00
eb2a63e136
fix(blog): use tag helpers to generate href 2023-08-13 18:12:42 +01:00
193b2486a1
refactor(blog): use /blog prefix for article endpoints 2023-08-13 18:12:13 +01:00
dc83309db7
perf(blog): cache users 2023-08-13 18:03:08 +01:00
bbc76bc305
refactor(blog): use api controller for /blog/feed endpoint 2023-08-13 18:02:58 +01:00
369436ccce
refactor: use global templates 2023-08-13 18:02:19 +01:00
67d89c1831
fix(blog): fix reading of url due to schema change 2023-08-13 17:35:20 +01:00
0a9c2e82d5
refactor: combine sites into one
CORS was "cors"ing some issues (heh).

But also it is easier to maintain this way. Development was made much more difficult when I separated it. Combining it all also improves SEO
2023-08-13 17:34:38 +01:00
be44fb4b4b
chore: add per-project Dockerfile 2023-08-13 16:38:56 +01:00
9475205196
style(blog): use extension-method invocation of ToTable 2023-08-13 15:25:25 +01:00
f878bff8f3
refactor: use shared Markdig pipeline 2023-08-13 15:24:57 +01:00
a84f537dc1
refactor(blog): swap to using new API host for client-side fetch 2023-08-13 15:24:24 +01:00
7495da56cb
fix(blog): add missing TemplateService impl 2023-08-13 13:30:55 +01:00
6bbdd0a74d
fix: add missing ns import 2023-08-13 13:28:25 +01:00
287af40501
feat: share template system among all projects 2023-08-13 13:27:44 +01:00
bd5fd6114a
refactor: remove blog CORS policy from principal project 2023-08-13 13:11:50 +01:00
f60b9c754a
fix: remove ref to removed service 2023-08-13 00:53:22 +01:00
692d688dc3
refactor: remove unused config service 2023-08-12 22:38:28 +01:00
58799594ae
refactor: switch to serilog 2023-08-12 21:06:48 +01:00
ad59c3190a
fix: read BLOG_URL env var 2023-08-12 20:52:03 +01:00
43f0b38fd2
chore: add blog site to docker-compose 2023-08-12 20:45:09 +01:00
aca79b0e69
chore: use correct project name in Dockerfile 2023-08-12 20:44:24 +01:00
617f58afad
chore: add common files to sln 2023-08-12 20:43:06 +01:00
1432c8e0f1
fix: amend 4c86a43a84 2023-08-12 20:41:14 +01:00
419aae741d
fix: use shared assets in root site 2023-08-12 20:40:46 +01:00
4c86a43a84
chore: remove redundant gitignore 2023-08-12 20:40:31 +01:00
43c3670a40
fix: no wait, it should be 2846
Seriously oliver, config file.
2023-08-12 20:37:54 +01:00
904ea689a6
fix: add missing _View* files 2023-08-12 20:34:35 +01:00
5ecd915d72
fix: use the correct port. this should really be in a config file 2023-08-12 20:34:00 +01:00
a6a0adc419
chore: build assets to Common wwwroot 2023-08-12 20:33:00 +01:00
f49b8aee9c
chore: add blog project to sln 2023-08-12 20:32:39 +01:00
a55c657d91
fix: add missing refs for blog project 2023-08-12 20:32:27 +01:00
86bbf803b5
feat: share wwwroot assets 2023-08-12 20:30:10 +01:00
0b7218b11a
chore: bump AspNetCore refs to 7.0.10 2023-08-12 20:14:20 +01:00
e8bc50bbdf
refactor: move blog to separate app
I'd ideally like to keep the blog. subdomain the same, and while there are a few ways to achieve this it is much simpler to just dedicate a separate application for the subdomain.

This change also adjusts the webhost builder extensions to default to ports 443/80, and each app now explicitly sets the port it needs.
2023-08-12 20:13:47 +01:00
b3fd6e9420
chore: add toml config to common lib 2023-08-12 19:04:18 +01:00
67231c86af
refactor: delegate ssl cert read to common lib 2023-08-12 18:35:57 +01:00
9b9143632a
refactor: remove ref to jquery validation lib 2023-08-12 16:49:37 +01:00
641313f97a
refactor: remove Author schema
Introducing new User which serves both as author model and credential model
2023-08-12 14:24:27 +01:00
47b648f327
fix: fix markdown formatting inside templates 2023-08-11 21:51:16 +01:00
6f7fa67135
refactor: move DateFormatter to child ns 2023-08-11 21:34:04 +01:00
034bd66b29
feat: format template arguments 2023-08-11 21:33:14 +01:00
9d0e16abc1
feat: add CORS for /api/blog controller 2023-08-11 17:16:26 +01:00
415726cdcd
fix: add missing _ViewImports for blog area 2023-08-11 17:16:04 +01:00
0aee2aafbc
fix: add missing asp-area for article breadcrumb 2023-08-11 17:15:54 +01:00
944fc5ced3
perf: +=5 in loop step because clearly I was stupid 2023-08-11 17:15:39 +01:00
597d7c8b4c
perf: cache author for faster lookup 2023-08-11 17:15:21 +01:00
cd0f38764d
fix: send charset=utf-8 for content-type header 2023-08-11 16:42:12 +01:00
7bd1c5a45a
refactor: remove redundant write of StatusCode 2023-08-11 16:41:53 +01:00
e9d9836238
refactor: move Template extension to subnamespace 2023-08-11 16:35:13 +01:00
37a35d5aab
feat: add author id to class 2023-08-11 16:34:10 +01:00
049601a6fb
fix: remove explicit routing 2023-08-11 16:32:29 +01:00
54f3706ba0
refactor: move blog api controller to project root 2023-08-11 16:32:11 +01:00
e060ab4dea
fix: use string for entity uuid 2023-08-11 16:31:51 +01:00
1fe65aed3b
feat: add support for future spoiler tags 2023-08-11 16:30:42 +01:00
143131b413
style: remove BOM 2023-08-11 15:51:20 +01:00
1a726b4962
style: format url as yyyy/MM/dd 2023-08-11 15:43:06 +01:00
becd70c865
fix: use area "blog" for page link 2023-08-11 15:42:48 +01:00
1bdd2a04f0
docs: add xmldoc in api controller 2023-08-11 15:41:02 +01:00
fb8848270f
refactor: remove unused ns imports 2023-08-11 14:29:26 +01:00
2844904723
fix: add missing _ViewStart to inherit shared layout 2023-08-11 14:27:38 +01:00
19a398d694
refactor: remove unused Post property 2023-08-11 14:27:13 +01:00
eb2edcb3f6
fix: amend 0ecef1a547
Forgot these two
2023-08-11 14:26:38 +01:00
2d4d6d3823
refactor: amend c2deccafae
Define new Area for blog
2023-08-11 14:26:21 +01:00
0ecef1a547
refactor: match db change from INT to UUID for pkeys 2023-08-11 14:09:13 +01:00
c2deccafae
refactor: move blog to new asp area 2023-08-11 14:08:14 +01:00
3c6a2209c2
style: remove self-closing tag 2023-08-11 13:52:43 +01:00
31794a1238
feat: add rss icon to layout footer 2023-08-11 13:24:57 +01:00
69e1279a8b
refactor: remove redundant asp-area attr 2023-08-11 13:23:03 +01:00
bb0483d4ae
refactor: rename index link 2023-08-11 02:27:17 +01:00
682b7c2a87
style: use meaningful placeholders 2023-08-11 02:27:07 +01:00
69963a5b81
style: swap roles of strong/underline 2023-08-11 02:26:56 +01:00
bea35a2015
style: improve feel of spinner removal 2023-08-11 02:08:48 +01:00
ca31a63bb7
feat: use ViewData title in layout 2023-08-11 02:08:18 +01:00
cbb7d07844
fix: add missing ViewData title assignment 2023-08-11 02:08:03 +01:00
1c73ada81c
style: use breadcrumbs for child contact pages 2023-08-11 02:07:39 +01:00
7219c948e6
refactor: remove weird namespace declspec 2023-08-11 02:07:13 +01:00
95b7ed0ae7
feat: create partial for spinner, register as handlebars template 2023-08-11 02:06:52 +01:00
06fab21cd5
feat: link to blog from index 2023-08-11 02:05:27 +01:00
3e3f074e63
style: use dynamic bmc button 2023-08-11 02:05:18 +01:00
f048c619f3
style: reword donate page 2023-08-11 02:05:08 +01:00
b2a7bf3536
feat: read ssl pem/key path from env 2023-08-10 23:33:15 +01:00
e3702878cd
fix: register pre-existing bs tooltips 2023-08-10 23:31:34 +01:00
4cc9efce42
fix: escape <mark> first 2023-08-10 23:28:37 +01:00
d1eeea3c85
refactor: ignore <mark> tag from highlighting 2023-08-10 23:28:23 +01:00
3e20e41565
refactor: use HttpGet for api routes 2023-08-10 22:57:24 +01:00
d3958fc22c
feat: validate referer on all routes 2023-08-10 22:56:49 +01:00
159e1ad65d
refactor: return Ok(...) instead of building a JsonResult 2023-08-10 22:55:52 +01:00
9d46d6495e
fix: 0-pad Published properties 2023-08-10 22:53:15 +01:00
d6c24d80c1
fix: remove stdout diagnostic print 2023-08-10 22:52:43 +01:00
74e7187cba
chore: remove redundant NLog ref
this is implicitly ref'd by NLog.Extensions.Logging
2023-08-10 22:50:43 +01:00
8dd4468c1a
feat: gracefully shutdown logmanager on exit 2023-08-10 22:49:44 +01:00
dcbc402bfb
refactor: remove redundant ns import 2023-08-10 22:49:15 +01:00
cf615e1e81
fix: fix already-formatted <mark>
no really, I hate this.
2023-08-10 22:48:32 +01:00
086a8a665c
feat: capture ctrl+u for git repo redirect 2023-08-10 22:48:08 +01:00
cf2a5c2ffb
feat: add kb shortcut detection 2023-08-10 22:47:28 +01:00
e939174040
refactor: delegate timestamp render to UI class 2023-08-10 16:21:35 +01:00
b4c991e44f
docs: add @param to UI methods 2023-08-10 16:18:52 +01:00
ab5277bacb
refactor: delegate card creation to UI class 2023-08-10 16:18:24 +01:00
5bb6463a4b
fix: use manual Prism highlighting 2023-08-10 16:16:29 +01:00
4244f9f014
fix: add disqus comment counter after posts have loaded 2023-08-10 16:14:48 +01:00
8a3061f23a
chore: ok fine 2022 then 2023-08-10 15:32:44 +01:00
506347ce9c
feat: add pre-formatted date "dddd, d MMMM yyyy HH:mm" 2023-08-10 15:32:34 +01:00
d67955f28a
refactor: allow localised element query 2023-08-10 15:31:51 +01:00
94a1ee00e1
refactor: render bootstrap tooltips from UI class 2023-08-10 15:31:17 +01:00
28f310e315
feat: render TeX from UI class 2023-08-10 15:28:53 +01:00
f3ad04ff1f
feat: add disqus comment count injection to asp page 2023-08-10 15:15:36 +01:00
42af5ebcdd
refactor: delegate refresh to UI class 2023-08-10 15:15:06 +01:00
522caa6add
refactor: remove now-redundant @ts-ignore 2023-08-10 15:13:20 +01:00
350247806d
chore: target ES2023 2023-08-10 15:12:18 +01:00
cdc6b1df84
chore: add tsconfig to sln 2023-08-10 15:12:07 +01:00
221a6f0007
fix: add missing method from BlogService 2023-08-10 14:37:52 +01:00
87c54fa5a4
fix: add missing ns import
amends 0b9841a724
2023-08-10 14:37:25 +01:00
217c1d660e
feat: use Handlebars to render template excerpt card 2023-08-10 14:36:51 +01:00
3868fcbaa8
feat: add pre-humanized timestamp to api schema 2023-08-10 14:34:52 +01:00
1de869c6f0
chore: add UI.ts to sln 2023-08-10 14:34:28 +01:00
fa17f63b82
chore: build ts / bundle js in series 2023-08-10 14:34:01 +01:00
fb6eabf55f
chore: ignore tmp/ 2023-08-10 14:33:40 +01:00
9ef9e6ca2c
fix: amend cb331ff54f 2023-08-10 14:32:56 +01:00
085bdafda2
feat: move some UI stuff to UI class 2023-08-10 14:19:11 +01:00
f989b4c02f
refactor: use readonly fields in Author 2023-08-10 14:18:29 +01:00
aa69713e49
feat: add handlebars 4.7.8 2023-08-10 14:08:35 +01:00
cb331ff54f
refactor: use - not _ for id word splitter 2023-08-10 14:08:22 +01:00
20656e74e8
refactor: remove redundant function 2023-08-10 04:57:17 +01:00
2036970fa2
fix: remove redundant article tag 2023-08-10 04:56:28 +01:00
95dd7e51e5
perf: add dynamic fetch of blog posts to speed up page load 2023-08-10 04:56:12 +01:00
d11e3f616b
chore: move tsconfig to project root, add ES2020/DOM libs 2023-08-10 04:48:02 +01:00
e64d8b47b8
feat: add support for Discord-style timestamps 2023-08-10 01:49:09 +01:00
7279c448da
fix: don't display line numbers for one-line blocks 2023-08-10 00:29:51 +01:00
fa51e0a189
chore: use es2020 2023-08-10 00:29:31 +01:00
434c61d7fa
fix: remove non-existent css link 2023-08-10 00:29:24 +01:00
4e032c3aa5
refactor: render katex in delegate. window.onload is redundant 2023-08-09 23:20:35 +01:00
738bf1f3ba
feat: use emoji support 2023-08-09 23:19:39 +01:00
5c55318577
feat: use smarty pants extension 2023-08-09 23:19:29 +01:00
0b9841a724
refactor: simplify md pipeline 2023-08-09 23:19:19 +01:00
190e247067
feat: add /raw route to blog posts for markdown output 2023-08-09 23:17:16 +01:00
e3b40a94c0
style: add abbr styling 2023-08-09 23:16:52 +01:00
290d261771
style: use lighter background to reduce ghost images in eyesight 2023-08-09 23:16:34 +01:00
42d1115df4
style: nest article blockquote 2023-08-09 23:14:54 +01:00
a138c38009
feat: add mastodon to footer 2023-08-09 22:13:09 +01:00
26b022f7ba
refactor: set OpeningChars for parser 2023-08-09 21:09:50 +01:00
2e17daea52
docs: add xmldoc to TemplateExtension 2023-08-09 21:09:32 +01:00
07071dc7a5
refactor: remove unused ns import 2023-08-09 21:09:14 +01:00
8a1cd689ea
feat: add rss output at route /blog/feed 2023-08-09 21:08:57 +01:00
6743918f44
fix: slice from initial index 2023-08-08 22:25:23 +01:00
030e5fdd3d
perf: inline redundant var 2023-08-08 22:25:06 +01:00
a6afe46891
fix: skip value read for key without = 2023-08-08 22:20:58 +01:00
6d8a1ac5b9
fix: consume token for template param value 2023-08-08 22:19:57 +01:00
dbbc18b8a6
perf: optimize and cleanup template parser 2023-08-08 22:18:42 +01:00
2d64bccc50
feat: enable bs tooltips for all elements with title attr 2023-08-08 21:05:09 +01:00
0120ac6dee
style: remove padding from :last-child of alert 2023-08-08 21:04:50 +01:00
68bff36fa6
style: remove double blank line 2023-08-08 21:04:27 +01:00
aee4052954
docs: add xmldoc to BlogContext members 2023-08-08 21:04:13 +01:00
6af41cba5a
feat: add support for MediaWiki-style templates 2023-08-08 21:03:41 +01:00
da5fe30c7a
feat: add font awesome 6.4.2 2023-08-08 21:01:31 +01:00
0fa43704ea
style: add alt to author image, reorder anchor attrs 2023-08-08 21:01:13 +01:00
77288a0ab5
feat: add operator== for BlogPost 2023-08-08 12:47:34 +01:00
4d10fd4ea1
docs: add xmldoc to members 2023-08-08 12:47:21 +01:00
50a25dad6f
chore: suppress NonReadonlyMemberInGetHashCode 2023-08-08 12:47:05 +01:00
2ef1e47ece
style: order members alphabetically 2023-08-08 12:46:52 +01:00
aaafcaf760
perf: handle content processing in BlogService 2023-08-08 12:46:24 +01:00
983e636635
perf: structure query get a bit better 2023-08-08 12:41:12 +01:00
56a8ba7368
fix: add missing lookup 2023-08-08 12:40:51 +01:00
9de1a84446
refactor: determine disqus param in model; reduce duplication 2023-08-08 12:40:18 +01:00
95a5a9e93b
refactor: readonly IDbContextFactory 2023-08-08 12:25:28 +01:00
96ec83e525
feat: add new permalink to all posts 2023-08-08 12:07:30 +01:00
299e315ddd
style: reword contact page 2023-08-08 11:35:48 +01:00
f14239bcfb
style: add bs alert for legal disclaimer 2023-08-08 11:35:36 +01:00
7f4ef10960
refactor: delegate id lookup to BlogService 2023-08-08 11:35:22 +01:00
6b18b36b96
refactor: get author using BlogService 2023-08-08 11:34:41 +01:00
3c62a42d32
fix: revert markdig escape for <mark> tags 2023-08-08 02:11:12 +01:00
ea656f6513
feat: use bs-tooltip for img title 2023-08-08 02:10:42 +01:00
6583a8eba6
feat: add line numbers to prism blocks 2023-08-08 02:10:17 +01:00
204269396e
feat: use KaTeX to render math exprs 2023-08-08 02:08:50 +01:00
2df24a99e7
feat: add bs tooltips 2023-08-08 02:08:18 +01:00
a8158611b8
chore: target es6 2023-08-08 02:07:08 +01:00
7abc44a58c
feat: use aspnetcore route helper instead of hardcoding url 2023-08-08 02:06:58 +01:00
6d40452fb3
feat: display Update timestamp if post has been updated 2023-08-08 02:06:38 +01:00
84814da85b
feat: describe project sections 2023-08-08 02:06:20 +01:00
69a6f4a3af
refactor: delegate post lookup to BlogService 2023-08-08 02:06:11 +01:00
4a94a404b3
fix: fix legacy 301 route 2023-08-08 01:56:13 +01:00
b584bb84a2
style: guard clause article page 2023-08-08 01:34:27 +01:00
83e5757429
feat: delegate blog listing to BlogService 2023-08-08 01:31:05 +01:00
79a45643cb
fix: remove incorrect background colour for codeblocks 2023-08-08 01:29:37 +01:00
80e0f04e93
style: add codeblock marking 2023-08-08 01:28:26 +01:00
852df0acf2
style: remove css line counter 2023-08-08 01:28:02 +01:00
6b864d5ab3
style: scale blog card on hover 2023-08-08 01:26:49 +01:00
8f345e493a
style: add rounded author pfp 2023-08-08 01:26:33 +01:00
3ec44a59b4
style: center non-text article elements 2023-08-08 01:26:24 +01:00
5b524337ca
style: add blockquote styling 2023-08-08 01:23:42 +01:00
81cd1dcf1d
style: remove hljs selectors 2023-08-08 01:23:02 +01:00
8768a90d48
fix: force disqus to use dark theme 2023-08-08 01:21:15 +01:00
951e44743f
chore: copy prebuilt css/js assets 2023-08-08 01:20:17 +01:00
c62a939f2e
style: max-width:700px for .container 2023-08-08 01:19:54 +01:00
1377ed012c
feat: add prism lib assets 2023-08-08 01:19:38 +01:00
b9e7e938ba
refactor: remove references to hljs 2023-08-08 01:17:23 +01:00
6524a4f618
feat: inject custom markdown pipeline 2023-08-08 00:34:15 +01:00
d69ef231eb
feat: add KaTeX 2023-08-08 00:31:15 +01:00
e2ecc6dc57
refactor: route blog to asp not remote page 2023-08-08 00:30:57 +01:00
3db59d6ca2
feat: use prism instead of highlightjs 2023-08-08 00:30:43 +01:00
12309c0bf1
style: improve layout of blog/article 2023-08-08 00:29:39 +01:00
e5f01f66a9
refactor: add day to article route 2023-08-08 00:21:22 +01:00
cebaac553c
feat: add gravatar hash calculation 2023-08-08 00:20:52 +01:00
6f3961901e
feat: add comment toggle schema 2023-08-08 00:20:38 +01:00
8b2f0fb454
feat: add post redirect schema 2023-08-08 00:20:21 +01:00
0c594ac306
feat: add Updated property to BlogPost 2023-08-07 23:42:59 +01:00
2aa218d105
feat: add source image assets 2023-08-06 16:05:44 +01:00
480363dd5a
chore: ignore dynamically generated assets 2023-08-06 15:58:13 +01:00
bd999f0ed8
feat: add preliminary /blog routes
This change also introduces toml file config
2023-08-06 15:57:44 +01:00
ba8e186cb5
style: don't trim padding for formatted pre code 2023-08-06 15:56:38 +01:00
518ea1b933
feat: add NLog 2023-08-06 15:56:08 +01:00
d24f9d3996
fix: add rider hot reload support 2023-08-06 15:55:12 +01:00
8ad324227a
refactor: remove localization 2023-08-06 13:55:39 +01:00
d7d3cb6986
refactor: remove RouteCultureProvider 2023-08-06 13:55:27 +01:00
060514aca2
style: use bs-theme "dark" 2023-08-06 13:54:34 +01:00
47eeb276c5
style: use explicit https protocol 2023-08-06 13:54:18 +01:00
494a85a447
style: format privacy policy pages 2023-08-06 13:54:02 +01:00
bcee08347a
feat: add projects page 2023-08-06 02:29:10 +01:00
b2ada7d720
fix: amend 75eed18bc8 2023-08-06 02:27:16 +01:00
75eed18bc8
refactor: remove i18n
This can be a problem for Future Me™️
2023-08-06 02:26:45 +01:00
e93db2c7a0
feat: add donation page 2023-08-05 23:28:01 +01:00
6205648e53
refactor: rename project to OliverBooth 2023-08-05 23:27:50 +01:00
a495973c44
feat: add basic landing page 2023-08-05 21:03:02 +01:00
bb4a0238be
feat: map controllers 2023-08-05 21:01:47 +01:00
057c22d9a7
chore: add "localizer" to sln dict 2023-08-05 21:00:48 +01:00
aeeb7dcfa5
fix: use correct model namespace 2023-08-05 21:00:19 +01:00
6ed7097c0c
refactor: remove ref to page model 2023-08-05 20:59:31 +01:00
55ee3ba5a9
feat: add culture route param 2023-08-05 20:58:03 +01:00
b4d8b4636c
chore: set target and ui culture 2023-08-05 20:56:10 +01:00
485fe2bc0e
chore: add ms localization 7.0.9 2023-08-05 20:55:57 +01:00
33faa4365d
chore: add X10D 3.2.2 2023-08-05 20:55:42 +01:00
cd44b9cf2a
feat: add layout of contact forms 2023-08-05 20:54:55 +01:00
185d035388
refactor: remove launchSettings and appSettings 2023-08-05 19:41:36 +01:00
f077c8f6fc
chore: remove BOM 2023-08-04 12:58:37 +01:00
ddb7a3c1f2
refactor: rename root namespace 2023-08-04 12:57:35 +01:00
b2765b002a
chore: update link and style refs 2023-08-04 12:56:56 +01:00
f2da5f9363
feat: add ts build pipeline 2023-08-04 12:55:16 +01:00
46008c2acb
chore: add sass to build pipeline 2023-08-04 12:45:10 +01:00
d24b492b91
feat: add sass files to sln 2023-08-04 12:44:50 +01:00
c87223dcdb
refactor: rename site scss to app 2023-08-04 12:44:03 +01:00
1279bfb82e
chore: add package.json 2023-08-04 12:36:29 +01:00
a48fd2d625
chore: add node to gitignore 2023-08-04 12:35:21 +01:00
567a47c62e
style: add side-wide SCSS 2023-08-04 12:31:05 +01:00
6c04fd1bf1
feat: add google play policy privacy 2023-08-04 02:12:51 +01:00
2065832f58
docs: add README 2023-08-04 01:44:12 +01:00
5d561c99db
refactor: set root namespace 2023-08-04 01:43:52 +01:00
131 changed files with 12883 additions and 371 deletions

View File

@ -1,4 +1,4 @@
**/.dockerignore
**/.dockerignore
**/.env
**/.git
**/.gitignore

132
.gitignore vendored
View File

@ -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.*

View File

@ -1,20 +0,0 @@
FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443
FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build
WORKDIR /src
COPY ["oliverbooth.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
View 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
View 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

View 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
View File

@ -0,0 +1 @@
wwwroot

View 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
}
};
}
}

View 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();
}
}

View 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;
}
}

View 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());
}
}

View 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();
}
}

View 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
}

View 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());
}
}

View 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();
}
}

View 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);
}

View 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();
}

View 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);
}

View 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";
}

View 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();
}

View 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!;
}

View 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 };
}

View 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!;
}

View 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;
}
}

View 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);
}
}

View 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="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);
}
}

View 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();
}
}

View 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; }
}

View 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; }
}

View 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();
}
}

View 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
}

View 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();
}
}

View 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);
}
}

View 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
View 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"]

View 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));
});
}
}

View 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;
}
}

View 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;
}
}

View 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));
}
}
}

View 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;
}

View 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 == '}';
}
}

View 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));
}
}

View 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>();
}
}
}

View 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',
}

View 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; }
}

View 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;
}
}

View 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>");
}
}

View 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>

View 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 &bull;
<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>&bull;</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>&bull;</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>
}

View 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();
}
}

View 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> &bull; </span>
<abbr title="{{ post.formattedDate }}">{{ post.date_humanized }}</abbr>
{{#if post.enable_comments}}
<span> &bull; </span>
<a href="{{post.url}}#disqus_thread" data-disqus-identifier="{{post.disqus_identifier}}">
Loading comment count &hellip;
</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>

View 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)!);
}
}

View File

@ -0,0 +1,2 @@
@page "/blog/{year:int}/{month:int}/{day:int}/{slug}/raw"
@model RawArticle

View 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());
}
}

View 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>

View 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>
}

View 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();
}
}

View 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>

View 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>
}

View File

@ -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();
}
}

View 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>

View 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>

View 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>

View 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>

View 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>
}

View File

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

View 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>&copy; @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>

View 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>

View 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>

View 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>

View File

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

View File

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

65
OliverBooth/Program.cs Normal file
View 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();

View 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;
}
}

View 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;
}
}

View 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);
}

View 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);
}

View 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);
}

View 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);
}

View 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;
}
}

View 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
View 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>

View File

@ -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}

BIN
icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

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

View File

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

View File

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

View File

@ -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()
{
}
}

View File

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

View File

@ -1,7 +0,0 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace OliverBooth.Pages.Privacy;
public class Index : PageModel
{
}

View File

@ -1,51 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>@ViewData["Title"] - oliverbooth.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">
&copy; 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>

View File

@ -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;
}

View File

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

View File

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

View File

@ -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();

View File

@ -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"
}
}
}
}

View File

@ -1,9 +0,0 @@
{
"DetailedErrors": true,
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@ -1,9 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

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

View File

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

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