Refactoring

This commit is contained in:
2025-11-11 20:49:23 +00:00
parent dba64a0051
commit 3bdf66cb98
16 changed files with 336 additions and 286 deletions

134
src/jakecharman/__init__.py Normal file
View File

@@ -0,0 +1,134 @@
#!/usr/bin/python3
import traceback
from os import environ
import threading
import logging
import xml.etree.ElementTree as ET
from os import path
from re import match
import json
from requests import post
from flask import Flask, render_template, Response, url_for, request
from .content import ContentArea
from .contact import ContactForm
from .storage import LocalStorage
app = Flask(__name__)
md_path = path.join(path.realpath(path.dirname(__file__)), path.normpath('../projects/'))
projects = ContentArea(
directory=LocalStorage(md_path),
name='projects',
import_name=__name__)
app.register_blueprint(projects, url_prefix='/projects')
app.register_blueprint(ContactForm('contact', __name__), url_prefix='/contact')
class DiscordLogger(logging.Handler):
''' Simple logging handler to send a message to Discord '''
level = logging.ERROR
def __init__(self, webhook):
super().__init__()
self._webhook = webhook
def send_to_discord(self, msg: logging.LogRecord):
''' Send the message '''
if msg.exc_info is not None:
message_to_send = f'{msg.msg}\n\n{"".join(traceback.format_exception(*msg.exc_info))}'
else:
message_to_send = msg.msg
if len(message_to_send) > 2000:
chars_to_lose = len(message_to_send) - 2000
if msg.exc_info is not None:
message_to_send = f'{msg.msg}\n\n{"".join(traceback.format_exception(*msg.exc_info))[-chars_to_lose:]}'
else:
message_to_send = msg.msg[-chars_to_lose:]
post(self._webhook, data={'content': message_to_send}, timeout=30)
def emit(self, record: logging.LogRecord) -> None:
''' Take in the record and start a new thread to send it to Discord '''
app.logger.info('Sending error to Discord')
dc_thread = threading.Thread(target=self.send_to_discord, args=[record])
dc_thread.start()
discord_logger = DiscordLogger(environ['DISCORD_ERR_HOOK'])
app.logger.addHandler(discord_logger)
@app.route('/')
def index() -> str:
''' Load the homepage '''
return render_template('index.html')
@app.route('/error/<code>')
def error(code) -> str:
''' Render a nicer error page for a given code '''
error_definitions = {
400: 'Bad Request',
403: 'Forbidden',
404: 'Page Not Found',
418: 'I\'m a Teapot',
500: 'Internal Server Error',
503: 'Service Temporarily Unavailable',
505: 'HTTP Version Not Supported'
}
error_desc = {
400: 'Sorry, I didn\'t understand your request.',
403: 'Sorry, you aren\'t allowed to view this page.',
404: 'Sorry, that page doesn\'t exist.',
418: 'I can\'t brew coffee as I am, in fact, a teapot.',
500: 'Something went wrong on my end.',
503: 'My website is experiencing some issues and will be back shortly.',
505: 'Your browser tried to use a HTTP version I don\'t support. Check it is up to date.'
}
if not code.isdigit():
code=400
elif int(code) not in error_definitions:
return Response(status=code)
return render_template('error.html',
error=f'{code}: {error_definitions.get(int(code))}',
description=error_desc.get(int(code)))
def get_routes() -> list:
''' Get a list of all routes that make up the app '''
routes = []
for rule in app.url_map.iter_rules():
if 0 >= len(rule.arguments):
url = url_for(rule.endpoint, **(rule.defaults or {}))
routes.append(url)
return routes
def get_build_date():
''' Get the build date of the Docker container we're running in '''
try:
with open('/var/www/jc/.buildinfo.json', encoding='utf8') as build:
build_json = json.load(build)
return build_json['date']
except Exception: # pylint: disable=broad-exception-caught
return '1970-01-01'
@app.route('/sitemap.xml')
def sitemap():
''' Return an XML site map '''
date = get_build_date()
root = ET.Element('urlset', xmlns='http://www.sitemaps.org/schemas/sitemap/0.9')
base_url = match(r'^https?:\/\/.+:?\d*(?=\/)', request.base_url).group()
base_url = base_url.replace('http://', 'https://')
for route in get_routes():
url = ET.SubElement(root, 'url')
ET.SubElement(url, 'loc').text = base_url + route
ET.SubElement(url, 'lastmod').text = date
for article in projects.get_all_posts():
if 'link' in article.metadata:
continue
url = ET.SubElement(root, 'url')
ET.SubElement(url, 'loc').text = f'{base_url}/projects/{article.metadata['id']}'
ET.SubElement(url, 'lastmod').text = article.metadata['date'].strftime('%Y-%m-%d')
return Response(ET.tostring(root, encoding='utf-8'), 200, {'content-type': 'application/xml'})

70
src/jakecharman/contact.py Executable file
View File

@@ -0,0 +1,70 @@
#!/usr/bin/python3
from os import environ
from uuid import uuid4
from textwrap import dedent
from traceback import format_exc
from requests import post
from flask import request, render_template, Blueprint, current_app
class ContactForm(Blueprint):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.add_url_rule('/', view_func=self.contact, methods=['GET', 'POST'])
def validate_turnstile(self, response: str, ip: str) -> bool:
turnstile_secret = environ['TURNSTILE_SECRET']
cf_response = post(
url='https://challenges.cloudflare.com/turnstile/v0/siteverify',
data={
'secret': turnstile_secret,
'response': response,
'remoteip': ip,
'idempotency_key': uuid4()
},
timeout=30
).json()
return cf_response.get('success', False)
def send_to_discord(self, form: dict) -> bool:
try:
discord_hook = environ['DISCORD_WEBHOOK']
except KeyError:
current_app.logger.error(format_exc())
return False
discord_msg = dedent(
f'''
__**New Contact Form Response**__
**From:** {form.get('name')} <{form.get('email')}>
''')
if form.get("message") == '':
discord_msg += '*No Message*'
else:
discord_msg += f'>>> {form.get("message")}'
discord_response = post(
url=discord_hook,
data={
'username': form.get('name'),
'content': discord_msg
},
timeout=30
)
if discord_response.status_code == 204:
return True
current_app.logger.error(discord_response.status_code, discord_response.text)
return False
def contact(self):
if request.method == 'POST':
if not self.validate_turnstile(request.form['cf-turnstile-response'], request.remote_addr):
return render_template('contact.html', error=True, user_message='You appear to be a robot.')
send_result = self.send_to_discord(request.form)
if send_result:
return render_template('contact.html', user_message='Your message has been sent!')
return render_template('contact.html', error=True, user_message='An error occurred.')
else:
return render_template('contact.html', page_title='Contact - ')

180
src/jakecharman/content.py Executable file
View File

@@ -0,0 +1,180 @@
#!/usr/bin/python3
from os import path
import json
from datetime import datetime
from io import BytesIO
from PIL import Image, UnidentifiedImageError
import frontmatter
from markdown import markdown
from bs4 import BeautifulSoup
from flask import render_template, Response, send_from_directory, request, make_response, Blueprint
from .storage import LocalStorage
class ContentArea(Blueprint):
def __init__(self, directory: LocalStorage, *args, **kwargs):
self.md_directory = directory
super().__init__(*args, **kwargs)
self.add_app_template_filter(self.category_title, 'category_title')
self.add_app_template_filter(self.human_date, 'human_date')
self.add_app_template_filter(self.to_html, 'to_html')
self.context_processor(self.processor)
self.add_url_rule('/', view_func=self.projects)
self.add_url_rule('/category/<category_id>/', view_func=self.category)
self.add_url_rule('/<article_id>', view_func=self.article)
self.add_url_rule('/image/<image_name>', view_func=self.image)
def processor(self) -> dict:
''' Jninja processors '''
def get_excerpt(post: frontmatter.Post) -> str:
html = markdown(post.content)
post_soup = BeautifulSoup(html, 'html.parser')
all_text = ' '.join([x.get_text() for x in post_soup.findAll('p')])
return ' '.join(all_text.split()[:200])
return dict(get_excerpt=get_excerpt)
def category_title(self, category_id: str) -> str:
''' Jninja filter to get a category title by its ID '''
with self.md_directory.open('categories.json') as categories_file:
categories = json.load(categories_file)
return categories.get(category_id).get('title', '')
def human_date(self, iso_date: str) -> str:
''' Jninja filter to convert an ISO date to human readable. '''
try:
return datetime.fromisoformat(str(iso_date)).strftime('%A %d %B %Y')
except ValueError:
return iso_date
def to_html(self, content: str) -> str:
''' Jninja filter to wrap markdown '''
return markdown(content)
def get_all_posts(self) -> list:
''' Get all posts in the posts directory '''
abs_paths = [x.path for x in self.md_directory.ls() if x.path.endswith('.md')]
posts = []
for p in abs_paths:
with self.md_directory.open(p) as f:
posts.append(frontmatter.load(f))
return posts
def get_by_meta_key(self, key: str, value: str) -> list:
''' Get posts by a metadata key value pair '''
return [x for x in self.get_all_posts() if x.get(key) == value or isinstance(x.get(key, []), list) and value in x.get(key, [])]
def projects(self) -> str:
''' Load the projects page '''
articles_to_return = sorted(
self.get_all_posts(),
key=lambda d: d.metadata.get('date'),
reverse=True
)
if len(articles_to_return) < 1:
return render_template('error.html',
error='There\'s nothing here... yet.',
description='I\'m still working on this page. Check back soon for some content.')
try:
with self.md_directory.open('categories.json') as categories_file:
categories = json.load(categories_file)
except FileNotFoundError:
return render_template('error.html',
error='There\'s nothing here... yet.',
description='I\'m still working on this page. Check back soon for some content.')
return render_template('projects.html',
articles=articles_to_return,
all_categories=categories,
title='Projects',
page_title='Projects - ',
description='A selection of projects I\'ve been involved in')
def category(self, category_id: str) -> str:
''' Load the page for a given category '''
try:
with self.md_directory.open('categories.json') as categories_file:
categories = json.load(categories_file)
the_category = categories.get(category_id)
except FileNotFoundError:
return render_template('error.html',
error='There\'s nothing here... yet.',
description='I\'m still working on this page. Check back soon for some content.')
if the_category is None:
return Response(status=404)
articles_to_return = sorted(
self.get_by_meta_key(
'categories', category_id),
key=lambda d: d.metadata.get('date'),
reverse=True
)
if len(articles_to_return) < 1:
return render_template('error.html',
error='There\'s nothing here... yet.',
description='I\'m still working on this page. Check back soon for some content.')
return render_template('projects.html', articles=articles_to_return,
title=the_category['title'],
description=the_category['long_description'],
page_title=f'{the_category["title"]} - ',
all_categories=categories,
current_category=category_id)
def article(self, article_id: str) -> str:
''' Load a single article '''
articles = self.get_by_meta_key('id', article_id)
if len(articles) == 0:
return Response(status=404)
if len(articles) > 1:
return Response(status=500)
the_article = articles[0]
return render_template('article.html', post=markdown(the_article.content),
metadata=the_article.metadata,
page_title=f'{the_article.metadata["title"]} - ')
def image(self, image_name: str) -> Response:
''' Resize and return an image. '''
w = int(request.args.get('w', 0))
h = int(request.args.get('h', 0))
if w == 0 and h == 0:
return send_from_directory(self.md_directory.uri, path.join('images', image_name))
try:
the_image = Image.open(path.join(self.md_directory.uri, 'images', image_name))
except FileNotFoundError:
return Response(status=404)
except UnidentifiedImageError:
return send_from_directory(self.md_directory.uri, path.join('images', image_name))
max_width, max_height = the_image.size
if (w >= max_width and h >= max_height):
return send_from_directory(self.md_directory.uri, path.join('images', image_name))
if path.exists(path.join('images', f'{w}-{h}-{image_name}')):
return send_from_directory(self.md_directory.uri, path.join('images', f'{w}-{h}-{image_name}'))
req_size = [max_width, max_height]
if w > 0:
req_size[0] = w
if h > 0:
req_size[1] = h
resized_img = BytesIO()
the_image.thumbnail(tuple(req_size))
the_image.save(resized_img, format=the_image.format)
the_image.save(path.join(self.md_directory.uri, 'images', f'{w}-{h}-{image_name}'), the_image.format)
response = make_response(resized_img.getvalue())
response.headers.set('Content-Type', f'image/{the_image.format}')
return response

View File

@@ -0,0 +1,28 @@
#!/usr/bin/python3
import os
class File():
def __init__(self, storage: LocalStorage, path: str): # pylint: disable=used-before-assignment
self.path = path
self.storage = storage
def open(self, *args, **kwargs):
''' Open the file using the relevant storage '''
return(self.storage.open(self.path, *args, **kwargs))
class LocalStorage():
''' Class to represent a location on a local file system '''
def __init__(self, uri: str):
self.uri = uri
def ls(self, path: str = '') -> list[File]:
''' Return a listing of a directory '''
if path.startswith('/'):
path = path[1:]
fullpath = os.path.join(self.uri, path)
return [File(self, os.path.join(fullpath, x)) for x in os.listdir(fullpath)]
def open(self, path: str, *args, **kwargs):
''' Open a file '''
return open(os.path.join(self.uri, path), *args, **kwargs, encoding='utf8')

View File

@@ -0,0 +1,10 @@
{% include 'header.html' %}
<main>
<section id="article">
<h1>{{ metadata.title}} </h1>
<p>{{ metadata.date | human_date }}</p>
<hr />
{{post|safe}}
</section>
</main>
{% include 'footer.html' %}

View File

@@ -0,0 +1,20 @@
{% include 'header.html' %}
<main id="contact-main">
<h2>Contact Me</h2>
<p>Got a question or want to talk about something on this site? Drop me a message below:</p>
<form action="#" method="post">
<label for="name">Name:</label>
<input type="text" name="name" required>
<label for="email">Email Address:</label>
<input type="email" name="email" required>
<label for="message">Message:</label>
<textarea name="message" rows="10"></textarea>
<div class="cf-turnstile" data-sitekey="0x4AAAAAAA45FeR26JuvqKy7"></div>
<input type="submit" name="submit" value="Submit">
</form>
{% if user_message is not none %}
<p id="{{ 'contact-error' if error else 'contact-message' }}">{{ user_message }}</p>
{% endif %}
</main>
{% include 'footer.html' %}

View File

@@ -0,0 +1,11 @@
{% include 'header.html' %}
<main>
<div id="error-container">
<div id='error'>
<h2>{{ error }}</h2>
<p>{{ description }}</p>
<a href='/'>Click here to return to the homepage</a>
</div>
</div>
</main>
{% include 'footer.html' %}

View File

@@ -0,0 +1,5 @@
<footer>
<p>&copy <span id="cr-year"></span> Jake Charman. This site uses cookies.</p>
</footer>
</body>
</html>

View File

@@ -0,0 +1,39 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Personal website of Jake Charman. A technology professional based in the UK.">
<title>{{ page_title }}Jake Charman</title>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400..900&family=Tourney:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100..900;1,100..900&family=Orbitron:wght@400..900&family=Tourney:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/style/mobile.css" />
<link rel="stylesheet" href="/static/style/desktop.css" />
<link rel="stylesheet" href="/static/fonts/fontawesome/css/all.min.css" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="icon" type="image/x-icon" href="/static/images/jc.ico">
<script src="/static/js/filter_projects.js"></script>
<script src="/static/js/update_copyright.js"></script>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<script async src="https://www.googletagmanager.com/gtag/js?id=G-6WMXXY0RL0"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-6WMXXY0RL0');
</script>
</head>
<body>
<header>
<nav id="top-nav">
<a href="/">About</a>
<a href="/projects/">Projects</a>
<a href="/contact/">Contact</a>
</nav>
<div id="logo-container">
<div id="logo">
<a href='/'><h1>Jake Charman</h1></a>
</div>
</div>
</header>

View File

@@ -0,0 +1,78 @@
{% include 'header.html' %}
<main>
<section id="technology">
<div class="gradient gradient-left">
<div class="spacer"></div>
<div class="text text-left">
<div>
<h2>Technology</h2>
<hr>
<p>Technology is my day job. I design and build distributed systems for large companies around the world.</p>
<p>
I also work with technology in my spare time, building systems for <a href="#motorsport">Nitro Junkie</a>, and for me to use personally. For example, the current iteration of this website is written in Python and deployed via a Jenkins pipeline to Google Cloud Run as a Docker container.
</p>
<p>
Below are some of the technologies I'm using most at the moment.
My personal projects can also be found over on the <a href="/projects/">projects</a> page. Be aware though that if I'm able to write about a project here, it most likely wasn't done professionally so some may be a little rough around the edges.
</p>
<div id="techlogos">
<a href="https://ansible.com"><img src="/static/images/technology/ansible.png" /></a>
<a href="https://aws.amazon.com/"><img src="/static/images/technology/aws.png" /></a>
<a href="https://azure.microsoft.com/en-gb"><img src="/static/images/technology/azure.png" /></a>
<a href="https://dotnet.microsoft.com/en-us/languages/csharp"><img src="/static/images/technology/csharp.png" /></a>
<a href="https://www.debian.org/"><img src="/static/images/technology/debian.png" /></a>
<a href="https://www.digitalocean.com/"><img src="/static/images/technology/digitalocean.png" /></a>
<a href="https://www.docker.com/"><img src="/static/images/technology/docker.png" /></a>
<a href="https://www.freeipa.org/"><img src="/static/images/technology/freeipa.png" /></a>
<a href="https://cloud.google.com/"><img src="/static/images/technology/gcloud.png" /></a>
<a href="https://git-scm.com/"><img src="/static/images/technology/git.png" /></a>
<a href="https://www.grafana.com/"><img src="/static/images/technology/grafana.png" /></a>
<a href="https://hadoop.apache.org/"><img src="/static/images/technology/hadoop.svg" /></a>
<a href="https://hive.apache.org/"><img src="/static/images/technology/hive.svg" /></a>
<a href="https://www.java.com/"><img src="/static/images/technology/java.png" /></a>
<a href="https://www.jenkins.io/"><img src="/static/images/technology/jenkins.png" /></a>
<a href="https://mariadb.org/"><img src="/static/images/technology/mariadb.png" /></a>
<a href="https://nginx.org/"><img src="/static/images/technology/nginx.svg" /></a>
<a href="https://www.proxmox.com/"><img src="/static/images/technology/proxmox.png" /></a>
<a href="https://www.python.org/"><img src="/static/images/technology/python.png" /></a>
<a href="https://www.redhat.com/en/technologies/linux-platforms/enterprise-linux"><img src="/static/images/technology/redhat.png" /></a>
<a href="https://rockylinux.org/"><img src="/static/images/technology/rocky.png" /></a>
<a href="https://subversion.apache.org/"><img src="/static/images/technology/svn.svg" /></a>
</div>
<div id="certs">
<a href="https://catalog-education.oracle.com/ords/certview/sharebadge?id=A6C9B3D3D21627EB9C2504395194FAF772E01412911D4ADC78063133D54579ED"><img src="/static/images/certs/OCIF2023CA.png" /></a>
</div>
<div class="social">
<a class="button" href="https://www.linkedin.com/in/jakecharman/"><i class="fa-brands fa-linkedin-in"></i></a>
<a class="button" href="https://github.com/jcharman/"><i class="fa-brands fa-github"></i></a>
</div>
</div>
</div>
</div>
</section>
<section id="motorsport">
<div class="gradient gradient-right">
<div class="spacer"></div>
<div class="text text-right">
<div>
<h2>Racing</h2>
<hr />
<p>When I'm not at work, I can often be found at <a href="https://santapod.com">Santa Pod Raceway</a> with <a href="https://nitrojunkie.uk">Nitro Junkie Racing</a></p>
<p>We run a Top Fuel motorcycle purpose built for drag racing. The engine is loosely based on a Kawasaki Z 1000, but supercharged, and nitromethane injected to produce around 1,000 horsepower.</p>
<p>I've also had the opportunity to work on a 10,000 horsepower Top Fuel Dragster. I <a href="https://nitrojunkie.uk/news/post/10000_horses">wrote about this on the Nitro Junkie website.</a></p>
<div class="gallery">
<iframe class="yt" src="https://www.youtube.com/embed/Wa-V8mQTXFk?si=W-LzfR3Qj_jgAdkY&amp;clip=UgkxV9lBj4pP1cvnR5seb82RQpWeE7RdnOXB&amp;clipt=EOrWtgsYyqu6Cw&amp;autoplay=1&amp;mute=1" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
<img src="/static/images/jake_tf_1.jpg">
</div>
<div class="social">
<a class="button" href="https://nitrojunkie.uk"><i class="fa-solid fa-globe"></i></a>
<a class="button" href="https://www.youtube.com/@NitroJunkieUK"><i class="fa-brands fa-youtube"></i></a>
<a class="button" href="https://www.facebook.com/nitrojunkie.uk"><i class="fa-brands fa-facebook"></i></a>
<a class="button" href="https://instagr.am/nitrojunkieuk"><i class="fa-brands fa-instagram"></i></a>
</div>
</div>
</div>
</div>
</section>
</main>
{% include 'footer.html' %}

View File

@@ -0,0 +1,54 @@
{% include 'header.html' %}
<main id="project-main">
<nav id="filter">
<label for="filter_category">Filter:</label>
<select id="filter_category" name="filter_category" onchange="update_filter()">
<option value="all">All Categories</option>
{% for category in all_categories %}
<option {{ 'selected' if current_category==category }} value="{{ category }}">
{{all_categories.get(category).get('title')}}</option>
{% endfor %}
</select>
</nav>
<h2>{{ title }}</h2>
<p>{{ description }}</p>
<p style="text-align: center; font-weight: bold; font-style: italic;">This page is relatively new. I'm adding projects as fast as I can but feel free to <a href="/contact">contact me</a> if you want to know about something in particular.</p>
<section id="projects">
{% for row in articles %}
<div class="project">
{% if row.get('link') is not none %}
<a href="{{ row.link }}">
{% else %}
<a href="/projects/{{ row.id }}">
{% endif %}
<img class="project-thumb"
srcset="
{% for i in range(200, 5100, 100) %}
/projects/image/{{ row.image }}?w={{i}} {{i}}w{{"," if not loop.last}}
{% endfor %}
"
sizes="
(max-width: 999px) 80vw,
(min-width: 1000px) 20vw
"
src="/projects/image/{{ row.image }}">
</a>
<div class="project-text">
{% if row.get('link') is not none %}
<a href="{{ row.link }}">
{% else %}
<a href="/projects/{{ row.id }}">
{% endif %}
<h3>{{ row.title }}</h3>
</a>
<p class="article-description">{{ row.description }}</p>
<p class="article-date">{{ row.date | human_date }}</p>
{% for category in row.categories %}
<a class="article-category" href="/projects/category/{{ category }}">{{ category | category_title }}{{ ", " if not loop.last }}</loop></a>
{% endfor %}
</div>
</div>
{% endfor %}
</section>
</main>
{% include 'footer.html' %}