1 Commits

Author SHA1 Message Date
50bf391450 Start implementing comments 2025-11-06 21:26:09 +00:00
26 changed files with 445 additions and 731 deletions

1
.gitignore vendored
View File

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

6
src/.buildinfo.json Normal file
View File

@@ -0,0 +1,6 @@
{
"tag": "jc-ng-localtest:",
"date": "2025-11-02",
"host": "jake-e580",
"user": "jake"
}

30
src/comments.py Normal file
View File

@@ -0,0 +1,30 @@
#!/usr/bin/python3
import sqlite3
from os import path
from datetime import datetime
import json
from flask import request, Response, redirect
from index import app
from projects import md_directory, get_by_meta_key
database = '/tmp/db' #path.join(md_directory, 'comments.db')
with sqlite3.Connection(database) as db:
db.execute('CREATE TABLE IF NOT EXISTS comments (date INTEGER, article TEXT, name TEXT, comment TEXT)')
db.commit()
@app.route('/comments/<article>', methods=['GET', 'POST'])
def post_comments(article: str):
match request.method:
case 'POST':
if len(get_by_meta_key(md_directory, 'id', article)) == 0:
return Response(status=404)
with sqlite3.Connection(database) as db:
db.execute('INSERT INTO comments (date, article, name, comment) VALUES (?, ?, ?, ?)', (datetime.now(), article, request.form.get('name'), request.form.get('comment')))
db.commit()
return redirect(f'/projects/{article}')
case 'GET':
if len(get_by_meta_key(md_directory, 'id', article)) == 1:
with sqlite3.Connection(database) as db:
res = db.execute('SELECT * FROM comments WHERE `article` = ?', (article,))
return json.dumps([{'author': x[2], 'date': x[0], 'comment': x[3]} for x in res.fetchall()])

65
src/contact.py Executable file
View File

@@ -0,0 +1,65 @@
#!/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
from traceback import format_exc
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()
},
timeout=30
).json()
return cf_response.get('success', False)
def send_to_discord(form: dict) -> bool:
try:
discord_hook = environ['DISCORD_WEBHOOK']
except KeyError:
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
app.logger.error(discord_response.status_code, discord_response.text)
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 - ')

88
src/index.py Executable file
View File

@@ -0,0 +1,88 @@
#!/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 sitemap # pylint: disable=wrong-import-position,unused-import
import projects # pylint: disable=wrong-import-position,unused-import
import contact # pylint: disable=wrong-import-position,unused-import
import comments # 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)))
if __name__ == '__main__':
app.run()

View File

@@ -1,217 +0,0 @@
#!/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

@@ -1,60 +0,0 @@
#!/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)

View File

@@ -1,70 +0,0 @@
#!/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 - ')

View File

@@ -1,156 +0,0 @@
#!/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')

View File

@@ -1,13 +0,0 @@
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

@@ -1,28 +0,0 @@
#!/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

@@ -1,44 +0,0 @@
{% 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,35 +0,0 @@
{% 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 %}

167
src/projects.py Executable file
View File

@@ -0,0 +1,167 @@
#!/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 = md_directory) -> 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,7 +3,7 @@
import sys import sys
sys.path.append('/var/www/jc') sys.path.append('/var/www/jc')
from jakecharman import app as application from index import app as application
if __name__ == '__main__': if __name__ == '__main__':
application.run(debug=True) application.run(debug=True)

43
src/sitemap.py Executable file
View File

@@ -0,0 +1,43 @@
#!/usr/bin/python3
import xml.etree.ElementTree as ET
import json
from flask import url_for, request, Response
from re import match
from index import app
from projects import get_all_posts
def get_routes() -> list:
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():
try:
with open('/var/www/jc/.buildinfo.json', encoding='utf8') as build:
build_json = json.load(build)
return build_json['date']
except:
return '1970-01-01'
@app.route('/sitemap.xml')
def sitemap():
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 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'})

View File

@@ -114,18 +114,4 @@
.gallery>img { .gallery>img {
width: calc(33% - 40px); 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

@@ -28,7 +28,6 @@ footer{
justify-content: center; justify-content: center;
align-items: center; align-items: center;
height: 10vh; height: 10vh;
clear: both;
} }
#logo-container{ #logo-container{
@@ -283,60 +282,3 @@ pre{
padding: 10px; padding: 10px;
max-width: 80vw; 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;
}

26
src/templates/article.html Executable file
View File

@@ -0,0 +1,26 @@
{% include 'header.html' %}
<main>
<section id="article">
<h1>{{ metadata.title}} </h1>
<p>{{ metadata.date | human_date }}</p>
<hr />
{{post|safe}}
</section>
<section id="comments">
<h2>{{ comments | length }} comments</h2>
{% for comment in comments %}
<div class="comment">
<strong>{{ comment[2] }}</strong>
<p>{{ comment[3] }}</p>
</div>
{% endfor %}
<form action="/comments/{{ metadata.id }}" method="post">
<label for="name">Name:</label>
<input type="text" name="name" />
<label for="comment">Comment:</label>
<textarea name="comment"></textarea>
<input type="submit" name="submit" value="Submit">
</form>
</section>
</main>
{% include 'footer.html' %}

View File

@@ -1,6 +1,4 @@
{% extends 'main.html' %} {% include 'header.html' %}
{% block content %}
<main id="contact-main"> <main id="contact-main">
<h2>Contact Me</h2> <h2>Contact Me</h2>
<p>Got a question or want to talk about something on this site? Drop me a message below:</p> <p>Got a question or want to talk about something on this site? Drop me a message below:</p>
@@ -19,4 +17,4 @@
<p id="{{ 'contact-error' if error else 'contact-message' }}">{{ user_message }}</p> <p id="{{ 'contact-error' if error else 'contact-message' }}">{{ user_message }}</p>
{% endif %} {% endif %}
</main> </main>
{% endblock %} {% include 'footer.html' %}

View File

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

5
src/templates/footer.html Executable file
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

@@ -3,7 +3,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <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."> <meta name="description" content="Personal website of Jake Charman. A technology professional based in the UK.">
<title>{{ page_title }}{{branding}}</title> <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=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 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/mobile.css" />
@@ -12,12 +12,10 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="icon" type="image/x-icon" href="/static/images/jc.ico"> <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/filter_projects.js"></script>
<script src="/static/js/update_copyright.js"></script> <script src="/static/js/update_copyright.js"></script>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></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 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> <script>
window.dataLayer = window.dataLayer || []; window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);} function gtag(){dataLayer.push(arguments);}
@@ -25,7 +23,6 @@
gtag('config', 'G-6WMXXY0RL0'); gtag('config', 'G-6WMXXY0RL0');
</script> </script>
{% block head %}{% endblock %}
</head> </head>
<body> <body>
<header> <header>
@@ -33,19 +30,10 @@
<a href="/">About</a> <a href="/">About</a>
<a href="/projects/">Projects</a> <a href="/projects/">Projects</a>
<a href="/contact/">Contact</a> <a href="/contact/">Contact</a>
<a href="/links/">Links</a>
</nav> </nav>
<div id="logo-container"> <div id="logo-container">
<div id="logo"> <div id="logo">
<a href='/'><h1>{{branding|upper}}</h1></a> <a href='/'><h1>Jake Charman</h1></a>
</div> </div>
</div> </div>
</header> </header>
{% block content %}{% endblock %}
<footer>
<p>&copy <span id="cr-year"></span> {{branding}}. This site uses cookies.</p>
</footer>
</body>
</html>

View File

@@ -1,6 +1,4 @@
{% extends 'main.html' %} {% include 'header.html' %}
{% block content %}
<main> <main>
<section id="technology"> <section id="technology">
<div class="gradient gradient-left"> <div class="gradient gradient-left">
@@ -61,7 +59,7 @@
<hr /> <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>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>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> <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"> <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> <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"> <img src="/static/images/jake_tf_1.jpg">
@@ -77,4 +75,4 @@
</div> </div>
</section> </section>
</main> </main>
{% endblock %} {% include 'footer.html' %}

View File

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