58 Commits

Author SHA1 Message Date
363497d354 Add Plausible 2026-04-05 20:46:41 +01:00
b97c9ea3ee First implementation of comments 2026-02-19 12:58:42 +00:00
82def98e7e First implementation of comments 2026-02-19 12:46:43 +00:00
a68033df6e Hide posts published in the future 2026-02-10 13:49:18 +00:00
c5ab8f281c Changes to support galleries 2026-02-09 23:20:15 +00:00
49c7d3eddf Update templates 2026-02-09 16:55:55 +00:00
26c09eadd2 Add categories 2026-02-06 22:15:19 +00:00
e6ee67a46f Add categories 2026-02-06 21:43:34 +00:00
46605e5f75 Add links 2026-02-06 20:44:53 +00:00
9b2b15c570 Add dynamic branding 2026-01-18 20:55:27 +00:00
3bdf66cb98 Refactoring 2025-11-11 20:49:23 +00:00
dba64a0051 Add caching to resized images 2025-11-05 20:37:56 +00:00
8ad638f496 Fix some sizing issues 2025-10-17 19:11:27 +01:00
6838f603a1 Revert "Add AdSense"
This reverts commit 32b1c4a3b4.
2025-10-16 22:24:06 +01:00
32b1c4a3b4 Add AdSense 2025-10-16 21:42:33 +01:00
dff93fb807 CSS Tweaks 2025-10-10 17:21:28 +01:00
a5a3da62eb CSS Tweaks 2025-10-10 17:19:26 +01:00
60c4e01778 CSS Tweaks 2025-10-10 17:17:03 +01:00
d64e7f4fa5 Prevent security test from failing build 2025-10-10 17:10:46 +01:00
bd735cb1e5 Prevent security test from failing build 2025-10-10 17:08:48 +01:00
5a09d74670 Prevent security test from failing build 2025-10-10 17:06:45 +01:00
c9927f8301 Bump Python 2025-10-10 17:02:50 +01:00
7286199411 Merge branch 'master' of git.jakecharman.co.uk:jake/jc-ng 2025-10-10 17:00:23 +01:00
c35e2b3ef0 Bump Python 2025-10-10 16:59:24 +01:00
6b338ce9ff CSS Tweaks 2025-10-10 16:52:04 +01:00
587b321205 Fix short articles 2025-09-26 14:12:57 +01:00
fe06477bec Swap image and video 2025-09-26 13:15:21 +01:00
ffaea83e09 Additions to racing 2025-08-10 15:30:32 +01:00
52e85b806c Update racing section 2025-08-09 21:49:41 +01:00
a78a2df86f Fix background image on desktop 2025-08-03 19:53:30 +01:00
0b0be06a4d Only scan when there is a new build 2025-07-21 22:16:12 +01:00
d10f4ceb69 move SVGs to lfs 2025-07-21 22:08:51 +01:00
805a60db72 track SVGs 2025-07-21 22:06:58 +01:00
ab18085b8c Design improvements on mobile, updated technology section 2025-07-21 22:06:05 +01:00
1e4a188acb Make cert logos smaller on desktop 2025-07-19 00:26:22 +01:00
61b3f2da7b Add Oracle cert 2025-07-19 00:06:11 +01:00
09bb22ea1a Jenkinsfile fixes 2025-07-18 23:11:48 +01:00
da013ff6ce Fix GCP Deployment 2025-07-18 15:06:02 +01:00
edf9665502 Fix typo 2025-07-18 15:01:20 +01:00
8b2c19b7c6 Specify GCP project 2025-07-18 15:00:28 +01:00
cf4827c09c Automate deployment to GCP 2025-07-18 14:57:02 +01:00
c424f0b32a Improved logging 2025-07-18 14:45:01 +01:00
33c27c1cba Improved logging 2025-07-18 14:38:14 +01:00
ac5c83506d Add push to GCP 2025-07-18 13:33:29 +01:00
0f3b0ccafa Move registry to Gitea 2025-07-18 10:51:28 +01:00
7e3bd6f665 Always use HTTPS 2025-06-15 15:57:32 +01:00
4e860d14e6 Add site map 2025-06-15 15:41:17 +01:00
3b96c59bcf Upgrade to Python 3.13 2025-06-11 21:46:41 +01:00
d933723fce Fix scrollbars 2025-06-08 18:50:54 +01:00
b7f9afb3f0 Fix build 2025-06-08 16:16:17 +01:00
9b25a0b93a Changes for code blocks 2025-06-08 15:59:49 +01:00
c9861ddbfc Bump setuptools to avoid GHSA-5rjg-fvgr-3xxf 2025-06-04 22:12:33 +01:00
4a386dd4dc Upgrade pip 2025-06-04 21:58:42 +01:00
c1e9013353 Fix typo 2025-06-04 21:55:27 +01:00
bd510b5e89 Fix typo 2025-06-04 21:53:31 +01:00
4c1fcc60ed Fix typo 2025-06-04 21:51:47 +01:00
9cf9067a64 Merge branch 'master' of git.jakecharman.co.uk:jake/jc-ng 2025-06-04 21:49:48 +01:00
9f34c2d544 Scan for vulnerabilities on build 2025-06-04 21:46:00 +01:00
56 changed files with 1110 additions and 431 deletions

1
.gitattributes vendored
View File

@@ -1,3 +1,4 @@
*.jpeg filter=lfs diff=lfs merge=lfs -text
*.jpg filter=lfs diff=lfs merge=lfs -text
*.png filter=lfs diff=lfs merge=lfs -text
*.svg filter=lfs diff=lfs merge=lfs -text

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
__pycache__
.vscode
.buildinfo.json

View File

@@ -1,8 +1,11 @@
FROM httpd:2.4
FROM python:3.14-bookworm
RUN apt-get update
RUN apt-get -y install libapache2-mod-wsgi-py3 python3 python3-pip
RUN apt-get -y install apache2 apache2-dev
COPY src/requirements.txt /var/www/jc/requirements.txt
RUN pip3 install -r /var/www/jc/requirements.txt || pip3 install --break-system-packages -r /var/www/jc/requirements.txt
COPY --chown=www-data:www-data config/httpd.conf /usr/local/apache2/conf/httpd.conf
RUN /usr/local/bin/pip3 install --upgrade pip
RUN /usr/local/bin/pip3 install -r /var/www/jc/requirements.txt
COPY --chown=www-data:www-data config/httpd.conf /etc/apache2/apache2.conf
COPY --chown=www-data:www-data src/ /var/www/jc
RUN httpd -t
RUN apache2 -t
EXPOSE 80
ENTRYPOINT ["apache2", "-D", "FOREGROUND"]

61
Jenkinsfile vendored
View File

@@ -33,19 +33,39 @@ pipeline {
credentialsId: 'Git',
url: 'git@git.jakecharman.co.uk:jake/jc-ng.git'
sh "./build.sh registry.jakecharman.co.uk/jakecharman.co.uk $BUILD_NUMBER"
sh "./build.sh git.jakecharman.co.uk/jake/jakecharman.co.uk $BUILD_NUMBER"
sh "./build.sh europe-west2-docker.pkg.dev/jakecharman/web/jakecharman.co.uk $BUILD_NUMBER"
}
}
stage('Push to registry') {
stage('Security scan') {
when {
expression {
return params.Build == true
}
}
steps {
sh "docker push registry.jakecharman.co.uk/jakecharman.co.uk:$BUILD_NUMBER"
sh "docker push registry.jakecharman.co.uk/jakecharman.co.uk:latest"
catchError(buildResult: 'SUCCESS', stageResult: 'FAILURE') {
sh "docker kill sectest || true"
sh "docker rm sectest || true"
sh "docker run -d --name sectest git.jakecharman.co.uk/jake/jakecharman.co.uk:$BUILD_NUMBER"
sh "docker exec sectest pip3 install pip-audit --break-system-packages"
sh "docker exec sectest pip-audit"
sh "docker stop sectest"
sh "docker rm sectest"
}
}
}
stage('Push to local registry') {
when {
expression {
return params.Build == true
}
}
steps {
sh "docker push git.jakecharman.co.uk/jake/jakecharman.co.uk:$BUILD_NUMBER"
sh "docker push git.jakecharman.co.uk/jake/jakecharman.co.uk:latest"
}
}
@@ -57,10 +77,10 @@ pipeline {
}
steps{
node('web-staging') {
sh "docker pull registry.jakecharman.co.uk/jakecharman.co.uk:latest"
sh "docker pull git.jakecharman.co.uk/jake/jakecharman.co.uk:latest"
sh "docker stop jake || true"
sh "docker rm jake || true"
sh "docker run --name jake -e DISCORD_ERR_HOOK=$DISCORD_ERR_STAGING -e DISCORD_WEBHOOK=$DISCORD -e TURNSTILE_SECRET=$TS --restart always --network containers_default -v /opt/containers/jc/projects/:/var/www/jc/projects/ -d registry.jakecharman.co.uk/jakecharman.co.uk:latest"
sh "docker run --name jake -e DISCORD_ERR_HOOK=$DISCORD_ERR_STAGING -e DISCORD_WEBHOOK=$DISCORD -e TURNSTILE_SECRET=$TS --restart always --network containers_default -v /opt/containers/jc/projects/:/var/www/jc/projects/ -d git.jakecharman.co.uk/jake/jakecharman.co.uk:latest"
}
}
}
@@ -89,20 +109,25 @@ pipeline {
}
}
stage('Deploy to production server') {
stage('Push to GCP registry') {
when {
expression {
return params.Build == true
}
}
steps {
sh "docker push europe-west2-docker.pkg.dev/jakecharman/web/jakecharman.co.uk:latest"
}
}
stage('Deploy to production') {
when {
expression {
return params.Deploy == true
}
}
steps{
node('web-server') {
sh "docker pull registry.jakecharman.co.uk/jakecharman.co.uk:latest"
sh "docker stop jake || true"
sh "docker rm jake || true"
sh "docker run --name jake -e DISCORD_ERR_HOOK=$DISCORD_ERR_PROD -e DISCORD_WEBHOOK=$DISCORD -e TURNSTILE_SECRET=$TS --restart always --network containers_default -v /opt/containers/jc/projects/:/var/www/jc/projects/ -d registry.jakecharman.co.uk/jakecharman.co.uk:latest"
sh "/home/jenkins/clearCFCache/clearCache.py a514fb61e1413b88aabbb19df16b8508"
}
sh "gcloud run deploy --project jakecharman --region europe-west1 --image europe-west2-docker.pkg.dev/jakecharman/web/jakecharman.co.uk:latest jakecharman-co-uk"
}
}
@@ -113,13 +138,17 @@ pipeline {
}
}
steps {
node('web-server') {
git branch: 'master',
credentialsId: 'Git',
url: 'git@git.jakecharman.co.uk:jake/jc-content.git'
sh "rsync -rv --delete ./ /opt/containers/jc/projects/"
sh "gsutil rsync -rcd . gs://jakecharman.co.uk"
}
}
stage('Clear cache') {
steps{
sh "/var/lib/jenkins/clearCFCache/clearCache.py a514fb61e1413b88aabbb19df16b8508"
}
}
}
}

View File

@@ -5,6 +5,15 @@ set -x -o pipefail
tag=$1
build=$2
cat <<EOF >src/.buildinfo.json
{
"tag": "${tag}:${build}",
"date": "$(date -I)",
"host": "$(hostname -f)",
"user": "${USER}"
}
EOF
docker build -t ${tag}:latest .
if [[ $build != "" ]]; then
docker build -t ${tag}:${build} .

View File

@@ -28,7 +28,7 @@
# same ServerRoot for multiple httpd daemons, you will need to change at
# least PidFile.
#
ServerRoot "/usr/local/apache2"
ServerRoot "/usr/lib/apache2"
#
# Mutex: Allows you to set the mutex mechanism and mutex file directory
@@ -123,7 +123,7 @@ LoadModule deflate_module modules/mod_deflate.so
#LoadModule brotli_module modules/mod_brotli.so
LoadModule mime_module modules/mod_mime.so
#LoadModule ldap_module modules/mod_ldap.so
LoadModule log_config_module modules/mod_log_config.so
#LoadModule log_config_module modules/mod_log_config.so
#LoadModule log_debug_module modules/mod_log_debug.so
#LoadModule log_forensic_module modules/mod_log_forensic.so
#LoadModule logio_module modules/mod_logio.so
@@ -137,7 +137,7 @@ LoadModule headers_module modules/mod_headers.so
#LoadModule usertrack_module modules/mod_usertrack.so
#LoadModule unique_id_module modules/mod_unique_id.so
LoadModule setenvif_module modules/mod_setenvif.so
LoadModule version_module modules/mod_version.so
#LoadModule version_module modules/mod_version.so
#LoadModule remoteip_module modules/mod_remoteip.so
#LoadModule proxy_module modules/mod_proxy.so
#LoadModule proxy_connect_module modules/mod_proxy_connect.so
@@ -171,7 +171,7 @@ LoadModule version_module modules/mod_version.so
#LoadModule lbmethod_bytraffic_module modules/mod_lbmethod_bytraffic.so
#LoadModule lbmethod_bybusyness_module modules/mod_lbmethod_bybusyness.so
#LoadModule lbmethod_heartbeat_module modules/mod_lbmethod_heartbeat.so
LoadModule unixd_module modules/mod_unixd.so
#LoadModule unixd_module modules/mod_unixd.so
#LoadModule heartbeat_module modules/mod_heartbeat.so
#LoadModule heartmonitor_module modules/mod_heartmonitor.so
#LoadModule dav_module modules/mod_dav.so
@@ -198,8 +198,7 @@ LoadModule dir_module modules/mod_dir.so
LoadModule alias_module modules/mod_alias.so
#LoadModule rewrite_module modules/mod_rewrite.somod_wsgi-express start-server
LoadModule wsgi_module /usr/lib/apache2/modules/mod_wsgi.so
LoadModule wsgi_module /usr/local/lib/python3.14/site-packages/mod_wsgi/server/mod_wsgi-py314.cpython-314-x86_64-linux-gnu.so
<IfModule unixd_module>
#
@@ -368,7 +367,7 @@ LogLevel warn
# TypesConfig points to the file containing the list of mappings from
# filename extension to MIME-type.
#
TypesConfig conf/mime.types
TypesConfig /etc/mime.types
#
# AddType allows you to add to or override the MIME configuration
@@ -423,7 +422,6 @@ ErrorDocument 503 /error/503
ErrorDocument 505 /error/505
WSGISocketPrefix /var/run/wsgi
WSGIDaemonProcess jc-wsgi user=www-data group=www-data threads=5
WSGIProcessGroup jc-wsgi
WSGIScriptAlias / /var/www/jc/projects.wsgi
@@ -434,6 +432,14 @@ WSGIScriptAlias / /var/www/jc/projects.wsgi
Require all granted
</Directory>
Alias "/static" "/var/www/jc/static/"
<Directory /var/www/jc/static>
Order allow,Deny
Allow from all
</Directory>
Alias "/robots.txt" "/var/www/jc/static/robots.txt"
<Files jc.wsgi>
Require all granted
</Files>

9
debug_local.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/bin/bash --noprofile
run() {
python3 src/projects.wsgi || run
}
export DISCORD_ERR_HOOK='dummy'
run
unset DISCORD_ERR_HOOK

View File

@@ -1,62 +0,0 @@
#!/usr/bin/python3
from index import app
from os import environ
from flask import request, render_template
from requests import post, get
from uuid import uuid4
from textwrap import dedent
def validate_turnstile(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()
}
).json()
return cf_response.get('success', False)
def send_to_discord(form: dict) -> bool:
try:
discord_hook = environ['DISCORD_WEBHOOK']
except:
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
}
).status_code
if discord_response == 204:
return True
return False
@app.route('/contact/', methods=('GET', 'POST'))
def contact():
if request.method == 'POST':
if not 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 = 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 - ')

View File

@@ -1,83 +0,0 @@
#!/usr/bin/python3
import traceback
from os import environ
import threading
import logging
from requests import post
from flask import Flask, render_template, Response
app = Flask(__name__)
# These imports need to come after our app is defined as they add routes to it.
import projects # pylint: disable=wrong-import-position,unused-import
import contact # pylint: disable=wrong-import-position,unused-import
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)))

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

@@ -0,0 +1,217 @@
#!/usr/bin/python3
import traceback
from os import environ, path
import threading
import logging
import xml.etree.ElementTree as ET
from urllib.parse import urlsplit
from re import match
import json
from io import BytesIO
from requests import post
from flask import Flask, render_template, Response, url_for, request, send_from_directory, make_response
from PIL import Image, UnidentifiedImageError
from .content import ContentArea
from .contact import ContactForm
from .storage import LocalStorage
from .links import Links
from .comments import *
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')
app.register_blueprint(Links(path.join(md_path, 'links.json'), 'links', __name__), url_prefix='/links')
app.register_blueprint(Approval(path.join(projects.md_directory.uri, 'comments.db'), 'comments', __name__), url_prefix='/comments')
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.context_processor
def inject_branding() -> dict:
''' Modify branding depending on the URL being used '''
req_domain = urlsplit(request.base_url).netloc.lower()
match req_domain:
case 'jakecharman.co.uk':
brand = 'Jake Charman'
case _:
brand = req_domain
return {'branding': brand}
@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_live_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'})
def resize_image(image_name: str, size: tuple):
w = size[0]
h = size[1]
md_directory = LocalStorage(md_path)
the_image = Image.open(path.join(md_directory.uri, 'images', image_name))
max_width, max_height = the_image.size
if path.exists(path.join(md_directory.uri, 'images', f'{w}-{h}-{image_name}')) or (w >= max_width and h >= max_height):
raise FileExistsError()
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(md_directory.uri, 'images', f'{w}-{h}-{image_name}'), the_image.format)
@app.route('/image/<image_name>')
def image( image_name: str) -> Response:
''' Resize and return an image. '''
md_directory = LocalStorage(md_path)
w = int(request.args.get('w', 0))
h = int(request.args.get('h', 0))
if w == 0 and h == 0:
return send_from_directory(md_directory.uri, path.join('images', image_name))
if path.exists(path.join(md_directory.uri, 'images', f'{w}-{h}-{image_name}')):
return send_from_directory(md_directory.uri, path.join('images', f'{w}-{h}-{image_name}'))
try:
resize_image(image_name, (w, h))
except FileNotFoundError:
return Response(status=404)
except UnidentifiedImageError:
return send_from_directory(md_directory.uri, path.join('images', image_name))
except FileExistsError:
return send_from_directory(md_directory.uri, path.join('images', image_name))
return send_from_directory(md_directory.uri, path.join('images', f'{w}-{h}-{image_name}'))
@app.route('/image/thumb/<image_name>')
def img_thumb(image_name: str):
''' Flask route to load an image '''
md_directory = LocalStorage(md_path)
w = 400
h = 0
thumb_file = path.join(md_directory.uri, 'images', f'{w}-{h}-{image_name}')
if path.exists(thumb_file):
return send_from_directory(md_directory.uri, path.join('images', f'{w}-{h}-{image_name}'))
try:
resize_image(image_name, (w, h))
except FileNotFoundError:
return Response(status=404)
except UnidentifiedImageError:
return send_from_directory(md_directory.uri, path.join('images', image_name))
except FileExistsError:
return send_from_directory(md_directory.uri, path.join('images', f'{w}-{h}-{image_name}'))
return send_from_directory(md_directory.uri, path.join('images', f'{w}-{h}-{image_name}'))

View File

@@ -0,0 +1,60 @@
#!/usr/bin/python3
import sqlite3
from flask import Blueprint, Response, request
from uuid import uuid4
from requests import post
from os import environ
class PostComments():
def __init__(self, post_id: str, db_path: str):
self.__db_path = db_path
self.__post_id = post_id
self._webhook = environ['DISCORD_WEBHOOK']
with sqlite3.connect(db_path) as db:
cursor = db.cursor()
cursor.execute("CREATE TABLE IF NOT EXISTS comments (name TEXT, comment TEXT, date INT, post_id TEXT, approved BOOL, key TEXT)")
self.comments = cursor.execute(
"SELECT name, comment, date FROM comments WHERE approved = 1 AND post_id = ? ORDER BY date DESC",
(post_id,)
).fetchall()
def send_to_discord(self, name: str, comment: str, comment_id: int, key: str):
''' Send the message '''
message_to_send = f'New comment from {name}\n\n{comment}'
if len(message_to_send) > 2000:
chars_to_lose = len(message_to_send) - 1900
message_to_send = message_to_send[-chars_to_lose:]
message_to_send += f'\n\n[Approve](https://jakecharman.co.uk/comments/approve/{comment_id}?key={key})'
post(self._webhook, data={'content': message_to_send}, timeout=30)
def make_comment(self, name: str, comment: str):
with sqlite3.connect(self.__db_path) as db:
key = str(uuid4())
cursor = db.cursor()
cursor.execute(
"INSERT INTO comments (name, comment, date, post_id, approved, key) VALUES (?, ?, datetime('now'), ?, 0, ?)",
(name, comment, self.__post_id, key)
)
db.commit()
self.send_to_discord(name, comment, cursor.lastrowid, key)
return cursor.lastrowid
class Approval(Blueprint):
def __init__(self, db_path: str, *args, **kwargs):
super().__init__(*args, **kwargs)
self.__db_path = db_path
self.add_url_rule('/approve/<comment_id>', view_func=self.approve)
def approve(self, comment_id: str):
with sqlite3.connect(self.__db_path) as db:
cursor = db.cursor()
key = cursor.execute("SELECT key FROM comments WHERE rowid = ?", (comment_id,)).fetchone()[0]
if request.args.get('key') == key:
with sqlite3.connect(self.__db_path) as db:
cursor = db.cursor()
cursor.execute("UPDATE comments SET approved = 1 WHERE rowid = ?", (comment_id,))
db.commit()
return Response(status=200)
return Response(status=403)

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 - ')

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

@@ -0,0 +1,156 @@
#!/usr/bin/python3
from os import path
import json
from datetime import datetime
import frontmatter
from markdown import markdown
from bs4 import BeautifulSoup
from flask import render_template, Response, Blueprint, request, redirect
from .storage import LocalStorage
from .comments import PostComments, Approval
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('/<article_id>/comment', view_func=self.comment, methods=['POST'])
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_live_posts(self) -> list:
''' Get all posts in the posts directory excluding ones with a date in the future '''
return [x for x in self.get_all_posts() if x.metadata.get('date') <= datetime.now().date()]
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_live_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_live_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]
comments = PostComments(the_article.metadata['id'], path.join(self.md_directory.uri, 'comments.db')).comments
return render_template('article.html', post=markdown(the_article.content),
metadata=the_article.metadata,
comments = comments,
page_title=f'{the_article.metadata["title"]} - ')
def comment(self, article_id: str):
PostComments(article_id, path.join(self.md_directory.uri, 'comments.db')).make_comment(
request.form['name'],
request.form['comment']
)
return redirect(f'/projects/{article_id}?comment=true#comments')

13
src/jakecharman/links.py Normal file
View File

@@ -0,0 +1,13 @@
from flask import Blueprint, render_template
import json
class Links(Blueprint):
def __init__(self, file, *args, **kwargs):
super().__init__(*args, **kwargs)
self.add_url_rule('/', view_func=self.links)
self.file = file
def links(self):
with open(self.file, encoding='utf8') as file:
links = json.load(file)
return render_template('links.html', links=links, page_title='Useful Links - ')

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,44 @@
{% extends 'main.html' %}
{% block head %}
{% if metadata.gallery %}
<script>
document.addEventListener("DOMContentLoaded",function(){
post_gallery = new Viewer(document.getElementById('article'), {
'url': 'realsrc',
'title': false
});
});
</script>
{% endif %}
{% endblock %}
{% block content %}
<main>
<section id="article">
<h1>{{ metadata.title}} </h1>
<p>{{ metadata.date | human_date }}</p>
<hr />
{{post|safe}}
</section>
</main>
<section id="comments">
<h2>{{comments | length}} Comment{% if comments | length != 1 %}s{% endif %}</h2>
{% for comment in comments %}
<div class="comment">
<strong>{{ comment[0] }} - {{ comment[2] | human_date }}</strong>
<p>{{ comment[1] }}</p>
</div>
{% endfor %}
<h3>Leave a comment</h3>
{% if request.args.get('comment') is none %}
<form action="./{{metadata.id}}/comment" method="post">
<input type="text", name="name" placeholder="Name" required>
<textarea name="comment" placeholder="Comment" required></textarea>
<input type="submit" value="Submit">
</form>
{% else %}
<p>Thank you! Your comment will appear once it is approved</p>
{% endif %}
</section>
{% endblock %}

View File

@@ -1,4 +1,6 @@
{% include 'header.html' %}
{% extends 'main.html' %}
{% block content %}
<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>
@@ -17,4 +19,4 @@
<p id="{{ 'contact-error' if error else 'contact-message' }}">{{ user_message }}</p>
{% endif %}
</main>
{% include 'footer.html' %}
{% endblock %}

View File

@@ -1,4 +1,6 @@
{% include 'header.html' %}
{% extends 'main.html' %}
{% block content %}
<main>
<div id="error-container">
<div id='error'>
@@ -8,4 +10,4 @@
</div>
</div>
</main>
{% include 'footer.html' %}
{% endblock %}

View File

@@ -0,0 +1,80 @@
{% extends 'main.html' %}
{% block content %}
<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://jakecharman.co.uk/projects/topfuel_dragster">wrote about this on the Nitro Junkie website, and later extended that post here.</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>
{% endblock %}

View File

@@ -0,0 +1,35 @@
{% extends 'main.html' %}
{% block content %}
<main id="links-main">
<h2>Useful Links</h2>
<section id="links">
{% for category in links %}
<div class="category">
<h3>{{category}}</h3>
{% for link in links.get(category) %}
<div class="link">
{% if link.get('img') is not none %}
<a href="{{link.src}}" target="_blank"></a>
<img class="link-thumb"
srcset="
{% for i in range(200, 5100, 100) %}
/image/{{ link.img }}?w={{i}} {{i}}w{{"," if not loop.last}}
{% endfor %}
"
sizes="
(max-width: 999px) 80vw,
(min-width: 1000px) 20vw
"
src="/image/{{ link.img }}">
</a>
{% endif %}
<a href="{{link.src}}" target="_blank"><h3>{{ link.title }}</h3></a>
<p>{{ link.description }}</p>
</div>
{% endfor %}
</div>
{% endfor %}
</section>
</main>
{% endblock %}

View File

@@ -3,7 +3,7 @@
<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>
<title>{{ page_title }}{{branding}}</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" />
@@ -12,10 +12,18 @@
<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">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/viewerjs/1.11.7/viewer.css" integrity="sha512-9NawOLzuLE2GD22PJ6IPWXEjPalb/FpdH1qMpgXdaDM+0OfxEV75ZCle6KhZi0vM6ZWvMrNnIZv6YnsL+keVmA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<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>
<!-- Privacy-friendly analytics by Plausible -->
<script async src="https://analytics.jakecharman.co.uk/js/pa-BBRBzeeo-AC_Nvfm4VWc9.js"></script>
<script>
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init()
</script>
<script async src="https://www.googletagmanager.com/gtag/js?id=G-6WMXXY0RL0"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/viewerjs/1.11.7/viewer.min.js" integrity="sha512-lZD0JiwhtP4UkFD1mc96NiTZ14L7MjyX5Khk8PMxJszXMLvu7kjq1sp4bb0tcL6MY+/4sIuiUxubOqoueHrW4w==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
@@ -23,6 +31,7 @@
gtag('config', 'G-6WMXXY0RL0');
</script>
{% block head %}{% endblock %}
</head>
<body>
<header>
@@ -30,10 +39,19 @@
<a href="/">About</a>
<a href="/projects/">Projects</a>
<a href="/contact/">Contact</a>
<a href="/links/">Links</a>
</nav>
<div id="logo-container">
<div id="logo">
<a href='/'><h1>Jake Charman</h1></a>
<a href='/'><h1>{{branding|upper}}</h1></a>
</div>
</div>
</header>
{% block content %}{% endblock %}
<footer>
<p>&copy <span id="cr-year"></span> {{branding}}. This site uses cookies.</p>
</footer>
</body>
</html>

View File

@@ -1,4 +1,6 @@
{% include 'header.html' %}
{% extends 'main.html' %}
{% block content %}
<main id="project-main">
<nav id="filter">
<label for="filter_category">Filter:</label>
@@ -24,14 +26,14 @@
<img class="project-thumb"
srcset="
{% for i in range(200, 5100, 100) %}
/projects/image/{{ row.image }}?w={{i}} {{i}}w{{"," if not loop.last}}
/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 }}">
src="/image/{{ row.image }}">
</a>
<div class="project-text">
{% if row.get('link') is not none %}
@@ -51,4 +53,4 @@
{% endfor %}
</section>
</main>
{% include 'footer.html' %}
{% endblock %}

View File

@@ -1,167 +0,0 @@
#!/usr/bin/python3
from os import path
import json
from datetime import datetime
from io import BytesIO
from glob import glob
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
from index import app
md_directory = path.join(path.realpath(path.dirname(__file__)), path.normpath('projects/'))
@app.context_processor
def processor() -> 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)
@app.template_filter('category_title')
def category_title(category_id: str) -> str:
''' Jninja filter to get a category title by its ID '''
with open(path.join(md_directory, 'categories.json'), encoding='utf8') as categories_file:
categories = json.load(categories_file)
return categories.get(category_id).get('title', '')
@app.template_filter('human_date')
def human_date(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
@app.template_filter('to_html')
def to_html(content: str) -> str:
''' Jninja filter to wrap markdown '''
return markdown(content)
def get_all_posts(directory: str) -> list:
''' Get all posts in the posts directory '''
abs_paths = [path.join(directory, x) for x in glob(f'{directory}/*.md')]
return [frontmatter.load(x) for x in abs_paths]
def get_by_meta_key(directory: str, key: str, value: str) -> list:
''' Get posts by a metadata key value pair '''
return [x for x in get_all_posts(directory) if x.get(key) == value or type(x.get(key, [])) is list and value in x.get(key, [])]
@app.route('/projects/')
def projects() -> str:
''' Load the projects page '''
articles_to_return = sorted(
get_all_posts(
md_directory),
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 open(path.join(md_directory, 'categories.json'), encoding='utf8') 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')
@app.route('/projects/category/<category_id>/')
def category(category_id: str) -> str:
''' Load the page for a given category '''
try:
with open(path.join(md_directory, 'categories.json'), encoding='utf8') 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(
get_by_meta_key(
md_directory, '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)
@app.route('/projects/<article_id>')
def article(article_id: str) -> str:
''' Load a single article '''
articles = get_by_meta_key(md_directory, '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"]} - ')
@app.route('/projects/image/<image_name>')
def image(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(md_directory, path.join('images', image_name))
try:
the_image = Image.open(path.join(md_directory, 'images', image_name))
except FileNotFoundError:
return Response(status=404)
except UnidentifiedImageError:
return send_from_directory(md_directory, path.join('images', image_name))
max_width, max_height = the_image.size
if (w >= max_width and h >= max_height):
return send_from_directory(md_directory, path.join('images', 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)
response = make_response(resized_img.getvalue())
response.headers.set('Content-Type', f'image/{the_image.format}')
return response

View File

@@ -3,4 +3,7 @@
import sys
sys.path.append('/var/www/jc')
from index import app as application
from jakecharman import app as application
if __name__ == '__main__':
application.run(debug=True)

View File

@@ -1,3 +1,4 @@
setuptools>=78.1.1
flask>=2.2.3
flask-markdown>=0.3
markdown>=3.4.1
@@ -5,3 +6,4 @@ beautifulsoup4>=4.11.1
python-frontmatter>=1.1.0
requests>=2.32.3
pillow>=11.0.0
mod_wsgi>=5.0.2

BIN
src/static/images/certs/OCIF2023CA.png (Stored with Git LFS) Executable file

Binary file not shown.

BIN
src/static/images/jake_tf_1.jpg (Stored with Git LFS) Executable file

Binary file not shown.

BIN
src/static/images/technology/ansible.png (Stored with Git LFS) Executable file

Binary file not shown.

BIN
src/static/images/technology/aws.png (Stored with Git LFS) Executable file

Binary file not shown.

BIN
src/static/images/technology/azure.png (Stored with Git LFS) Executable file

Binary file not shown.

BIN
src/static/images/technology/csharp.png (Stored with Git LFS) Executable file

Binary file not shown.

BIN
src/static/images/technology/debian.png (Stored with Git LFS) Executable file

Binary file not shown.

BIN
src/static/images/technology/digitalocean.png (Stored with Git LFS) Executable file

Binary file not shown.

BIN
src/static/images/technology/docker.png (Stored with Git LFS) Executable file

Binary file not shown.

BIN
src/static/images/technology/freeipa.png (Stored with Git LFS) Executable file

Binary file not shown.

BIN
src/static/images/technology/gcloud.png (Stored with Git LFS) Executable file

Binary file not shown.

BIN
src/static/images/technology/git.png (Stored with Git LFS) Executable file

Binary file not shown.

BIN
src/static/images/technology/grafana.png (Stored with Git LFS) Executable file

Binary file not shown.

BIN
src/static/images/technology/hadoop.svg (Stored with Git LFS) Executable file

Binary file not shown.

BIN
src/static/images/technology/hive.svg (Stored with Git LFS) Executable file

Binary file not shown.

BIN
src/static/images/technology/java.png (Stored with Git LFS) Executable file

Binary file not shown.

BIN
src/static/images/technology/jenkins.png (Stored with Git LFS) Executable file

Binary file not shown.

BIN
src/static/images/technology/mariadb.png (Stored with Git LFS) Executable file

Binary file not shown.

BIN
src/static/images/technology/nginx.svg (Stored with Git LFS) Executable file

Binary file not shown.

BIN
src/static/images/technology/proxmox.png (Stored with Git LFS) Executable file

Binary file not shown.

BIN
src/static/images/technology/python.png (Stored with Git LFS) Executable file

Binary file not shown.

BIN
src/static/images/technology/redhat.png (Stored with Git LFS) Executable file

Binary file not shown.

BIN
src/static/images/technology/rocky.png (Stored with Git LFS) Executable file

Binary file not shown.

BIN
src/static/images/technology/svn.svg (Stored with Git LFS) Executable file

Binary file not shown.

0
src/static/js/gallery.js Normal file
View File

4
src/static/robots.txt Executable file
View File

@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://jakecharman.co.uk/sitemap.xml

View File

@@ -1,22 +1,25 @@
@media (min-width: 1000px) {
#technology{
background-position-x: 35vw;
height: 75vh;
height: fit-content;
min-height: 75vh;
background-position-y: 780px;
}
#motorsport{
background-position-y: -180px;
background-position-x: -200px;
background-size: auto;
height: fit-content;
min-height: 75vh;
height: 75vh;
}
.text{
width: 50%;
height: 100%;
align-items: flex-start;
height: fit-content;
min-height: 75vh
}
.text-right{
@@ -29,6 +32,13 @@
margin-right: auto;
}
.gradient {
display: flex;
align-items: flex-end;
background-image: linear-gradient(to top, rgba(23, 22, 20, 1) 70%, rgba(23, 22, 20, 0));
min-height: 75vh;
}
.gradient-left{
background-image: linear-gradient(to right, rgba(23, 22, 20, 1), rgba(23, 22, 20, 1), rgba(23, 22, 20, 0));
}
@@ -38,7 +48,7 @@
}
#projects{
align-items: center;
align-items: stretch;
justify-content: center;
flex-direction: row;
flex-wrap: wrap;
@@ -48,7 +58,6 @@
.project{
width: 20vw;
height: 25vw;
}
#top-nav{
@@ -67,4 +76,56 @@
#article>p>img{
display: inline;
}
#article>video{
display: inline;
}
#certs>a {
max-width: 15%;
}
#techlogos {
max-height: 40%;
}
#techlogos>a{
max-width: 5%;
}
.spacer {
display: none;
}
.yt {
width: calc(66% - 40px);
min-height: 100%;
}
.gallery {
display: flex;
height: fit-content;
padding: 10px 0;
}
.gallery * {
padding: 0 20px;
}
.gallery>img {
width: calc(33% - 40px);
}
#links {
padding-left: 20px;
padding-right: 20px;
}
.link>a>h3 {
padding-top: 20px;
}
.photo-gallery>img {
width: 20vw;
padding: 10px;
height: 14vw;
}
}

View File

@@ -13,17 +13,22 @@ h1, h2, h3{
}
header{
background-color: 4c4c4c;
background-color: #4c4c4c;
height: 25vh;
width: 100%;
}
main{
min-height: 65vh;
}
footer{
background-color: 4c4c4c;
background-color: #4c4c4c;
display: flex;
justify-content: center;
align-items: center;
height: 10vh;
clear: both;
}
#logo-container{
@@ -48,33 +53,32 @@ footer h2, section h2{
#technology{
background-image: url(../images/njr-code.png);
height: 100vh;
height: fit-content;
margin: 0;
background-position: center;
background-position-x: -400px;
background-position-y: 400px;
}
#motorsport{
background-image: url(../images/topfuel_startline.jpg.jpeg);
height: 100vh;
height: fit-content;
margin: 0;
background-position: center;
background-position-x: -65px;
background-position-y: -90px;
background-position-y: -100px;
background-size: 200%;
}
.gradient{
width: 100%;
height: 100%;
background-image: linear-gradient(to top, rgba(23, 22, 20, 1) 70%, rgba(23, 22, 20, 0));
display: flex;
align-items: flex-end;
height: fit-content;
}
.text{
height: 70%;
height: fit-content;
width: 100%;
background-color: rgba(23, 22, 20, 1);
}
.text>div{
@@ -209,10 +213,130 @@ label{
#article{
padding: 0 10px 0 10px;
margin-bottom: 25px;
}
#article>p>img{
display: block;
margin: 0 auto 0 auto;
max-width: 100%;
max-height: 50vh;
}
#article>video{
display: block;
margin: 0 auto 0 auto;
max-width: 100%;
max-height: 50vh;
}
pre{
overflow-x: auto;
background-color: #36332f;
padding: 10px;
}
#certs {
display: flex;
flex-direction: row;
justify-content: center;
padding: 20px 0;
}
#certs>a {
max-width: 25%;
}
#certs>a>img {
width: 100%;
}
#techlogos {
display: flex;
flex-direction: row;
justify-content: center;
flex-wrap: wrap;
max-height:30%;
}
#techlogos>a {
max-width: 10%;
padding: 10px;
}
#techlogos>a>img {
width: 100%;
}
.spacer {
height: 30vh;
width: 100%;
background-image: linear-gradient(to top, rgba(23, 22, 20, 1) 10%, rgba(23, 22, 20, 0));
}
.yt {
width: 100%;
height: 22vh;
}
.gallery * {
padding: 10px;
max-width: 80vw;
}
#links-main{
padding: 20px 10px 20px 10px;
min-height: 65vh;
}
.link {
border-top: 1px solid #e5e5e5;
clear: both;
overflow: hidden;
}
.link>img {
float: left;
padding-right: 20px;
width: 100px;
}
.link>a>h3 {
padding-top: 10px;
margin: 0
}
.category {
padding-top: 10px;
border-bottom: 1px solid #e5e5e5;
}
.category>h3 {
margin-bottom: 10px;
}
.photo-gallery {
display: flex;
flex-direction: row;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 20px;
}
.photo-gallery>img {
width: 45vw;
padding: 5px;
height: 31vw;
object-fit: cover;
}
#comments {
border-top: 1px solid #e5e5e5;
}
.comment {
background-color: #4c4c4c;
margin: 10px;
padding: 5px;
border-radius: 10px;
}

View File

@@ -1,10 +0,0 @@
{% 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

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

View File

@@ -1,45 +0,0 @@
{% include 'header.html' %}
<main>
<section id="technology">
<div class="gradient gradient-left">
<div class="text text-left">
<div>
<h2>Technology</h2>
<hr>
<p>Working with technology is my day job, I currently specialise in:</p>
<ul>
<li>Linux (primarily RHEL & Debian based)</li>
<li>SCM with Git and Subversion</li>
<li>Big Data (Hadoop & Cloud storage)</li>
<li>Programming (Python, Bash & C#)</li>
</ul>
<p>I also run some services for personal use and occasionally write software. I may write about some of the services I run in the future. For now, my code can be found on <a href="https://github.com/jcharman">GitHub</a>.</p>
<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="text text-right">
<div>
<h2>Racing</h2>
<hr />
<p>When not working on tech, I can usually be found at <a href="https://santapod.com">Santa Pod Raceway</a> working on my Dad's <a href="https://nitrojunkie.uk">NitroJunkie.UK</a> "All In" Top Fuel Bike. The bike is purpose built from the ground up with a supercharged, nitromethane injected engine capable of producing approximately 1000 horsepower. </p>
<p>The photo for this section was taken by <a href="https://www.facebook.com/BlackettPhotography">Blackett Photography</a></p>
<p>You can read more about our racing at <a href="https://nitrojunkie.uk">nitrojunkie.uk</a></p>
<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' %}