Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 50bf391450 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,2 @@
|
|||||||
__pycache__
|
__pycache__
|
||||||
.vscode
|
.vscode
|
||||||
.buildinfo.json
|
|
||||||
6
src/.buildinfo.json
Normal file
6
src/.buildinfo.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"tag": "jc-ng-localtest:",
|
||||||
|
"date": "2025-11-02",
|
||||||
|
"host": "jake-e580",
|
||||||
|
"user": "jake"
|
||||||
|
}
|
||||||
30
src/comments.py
Normal file
30
src/comments.py
Normal 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
65
src/contact.py
Executable 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
88
src/index.py
Executable 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()
|
||||||
@@ -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}'))
|
|
||||||
@@ -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)
|
|
||||||
@@ -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 - ')
|
|
||||||
@@ -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')
|
|
||||||
@@ -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 - ')
|
|
||||||
@@ -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')
|
|
||||||
@@ -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 %}
|
|
||||||
@@ -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
167
src/projects.py
Executable 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
|
||||||
@@ -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
43
src/sitemap.py
Executable 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'})
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{
|
||||||
@@ -282,61 +281,4 @@ pre{
|
|||||||
.gallery * {
|
.gallery * {
|
||||||
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
26
src/templates/article.html
Executable 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' %}
|
||||||
@@ -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' %}
|
||||||
@@ -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
5
src/templates/footer.html
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
<footer>
|
||||||
|
<p>© <span id="cr-year"></span> Jake Charman. This site uses cookies.</p>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
16
src/jakecharman/templates/main.html → src/templates/header.html
Normal file → Executable file
16
src/jakecharman/templates/main.html → src/templates/header.html
Normal file → Executable 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>© <span id="cr-year"></span> {{branding}}. This site uses cookies.</p>
|
|
||||||
</footer>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -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&clip=UgkxV9lBj4pP1cvnR5seb82RQpWeE7RdnOXB&clipt=EOrWtgsYyqu6Cw&autoplay=1&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&clip=UgkxV9lBj4pP1cvnR5seb82RQpWeE7RdnOXB&clipt=EOrWtgsYyqu6Cw&autoplay=1&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' %}
|
||||||
@@ -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' %}
|
||||||
Reference in New Issue
Block a user