Refactoring

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

1
.gitignore vendored
View File

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

View File

@@ -1,65 +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
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 - ')

60
src/index.py → src/jakecharman/__init__.py Executable file → Normal file
View File

@@ -4,15 +4,27 @@ import traceback
from os import environ from os import environ
import threading import threading
import logging import logging
import xml.etree.ElementTree as ET
from os import path
from re import match
import json
from requests import post from requests import post
from flask import Flask, render_template, Response from flask import Flask, render_template, Response, url_for, request
from .content import ContentArea
from .contact import ContactForm
from .storage import LocalStorage
app = Flask(__name__) app = Flask(__name__)
# These imports need to come after our app is defined as they add routes to it. md_path = path.join(path.realpath(path.dirname(__file__)), path.normpath('../projects/'))
import projects # pylint: disable=wrong-import-position,unused-import
import contact # pylint: disable=wrong-import-position,unused-import projects = ContentArea(
import sitemap # pylint: disable=wrong-import-position,unused-import directory=LocalStorage(md_path),
name='projects',
import_name=__name__)
app.register_blueprint(projects, url_prefix='/projects')
app.register_blueprint(ContactForm('contact', __name__), url_prefix='/contact')
class DiscordLogger(logging.Handler): class DiscordLogger(logging.Handler):
''' Simple logging handler to send a message to Discord ''' ''' Simple logging handler to send a message to Discord '''
@@ -82,3 +94,41 @@ def error(code) -> str:
return render_template('error.html', return render_template('error.html',
error=f'{code}: {error_definitions.get(int(code))}', error=f'{code}: {error_definitions.get(int(code))}',
description=error_desc.get(int(code))) description=error_desc.get(int(code)))
def get_routes() -> list:
''' Get a list of all routes that make up the app '''
routes = []
for rule in app.url_map.iter_rules():
if 0 >= len(rule.arguments):
url = url_for(rule.endpoint, **(rule.defaults or {}))
routes.append(url)
return routes
def get_build_date():
''' Get the build date of the Docker container we're running in '''
try:
with open('/var/www/jc/.buildinfo.json', encoding='utf8') as build:
build_json = json.load(build)
return build_json['date']
except Exception: # pylint: disable=broad-exception-caught
return '1970-01-01'
@app.route('/sitemap.xml')
def sitemap():
''' Return an XML site map '''
date = get_build_date()
root = ET.Element('urlset', xmlns='http://www.sitemaps.org/schemas/sitemap/0.9')
base_url = match(r'^https?:\/\/.+:?\d*(?=\/)', request.base_url).group()
base_url = base_url.replace('http://', 'https://')
for route in get_routes():
url = ET.SubElement(root, 'url')
ET.SubElement(url, 'loc').text = base_url + route
ET.SubElement(url, 'lastmod').text = date
for article in projects.get_all_posts():
if 'link' in article.metadata:
continue
url = ET.SubElement(root, 'url')
ET.SubElement(url, 'loc').text = f'{base_url}/projects/{article.metadata['id']}'
ET.SubElement(url, 'lastmod').text = article.metadata['date'].strftime('%Y-%m-%d')
return Response(ET.tostring(root, encoding='utf-8'), 200, {'content-type': 'application/xml'})

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

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

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

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

View File

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

View File

@@ -1,171 +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 = 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))
if path.exists(path.join('images', f'{w}-{h}-{image_name}')):
return send_from_directory(md_directory, path.join('images', f'{w}-{h}-{image_name}'))
req_size = [max_width, max_height]
if w > 0:
req_size[0] = w
if h > 0:
req_size[1] = h
resized_img = BytesIO()
the_image.thumbnail(tuple(req_size))
the_image.save(resized_img, format=the_image.format)
the_image.save(path.join(md_directory, 'images', f'{w}-{h}-{image_name}'), the_image.format)
response = make_response(resized_img.getvalue())
response.headers.set('Content-Type', f'image/{the_image.format}')
return response

View File

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

View File

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