Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
363497d354 | ||
|
|
b97c9ea3ee | ||
|
|
82def98e7e | ||
|
|
a68033df6e | ||
|
|
c5ab8f281c | ||
|
|
49c7d3eddf | ||
|
|
26c09eadd2 | ||
|
|
e6ee67a46f | ||
|
|
46605e5f75 | ||
|
|
9b2b15c570 | ||
|
|
3bdf66cb98 | ||
|
|
dba64a0051 | ||
|
|
8ad638f496 | ||
|
|
6838f603a1 | ||
|
|
32b1c4a3b4 | ||
|
|
dff93fb807 | ||
|
|
a5a3da62eb | ||
|
|
60c4e01778 | ||
|
|
d64e7f4fa5 | ||
|
|
bd735cb1e5 | ||
|
|
5a09d74670 | ||
|
|
c9927f8301 | ||
|
|
7286199411 | ||
|
|
c35e2b3ef0 | ||
|
|
6b338ce9ff | ||
|
|
587b321205 | ||
|
|
fe06477bec | ||
|
|
ffaea83e09 | ||
|
|
52e85b806c | ||
|
|
a78a2df86f | ||
|
|
0b0be06a4d | ||
|
|
d10f4ceb69 | ||
|
|
805a60db72 | ||
|
|
ab18085b8c | ||
|
|
1e4a188acb | ||
|
|
61b3f2da7b | ||
|
|
09bb22ea1a | ||
|
|
da013ff6ce | ||
|
|
edf9665502 | ||
|
|
8b2c19b7c6 | ||
|
|
cf4827c09c | ||
|
|
c424f0b32a | ||
|
|
33c27c1cba | ||
|
|
ac5c83506d | ||
|
|
0f3b0ccafa | ||
|
|
7e3bd6f665 | ||
|
|
4e860d14e6 | ||
|
|
3b96c59bcf | ||
|
|
d933723fce | ||
|
|
b7f9afb3f0 | ||
|
|
9b25a0b93a | ||
|
|
c9861ddbfc | ||
|
|
4a386dd4dc | ||
|
|
c1e9013353 | ||
|
|
bd510b5e89 | ||
|
|
4c1fcc60ed | ||
|
|
9cf9067a64 | ||
|
|
9f34c2d544 |
@@ -1,3 +1,4 @@
|
|||||||
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
||||||
*.jpg filter=lfs diff=lfs merge=lfs -text
|
*.jpg filter=lfs diff=lfs merge=lfs -text
|
||||||
*.png filter=lfs diff=lfs merge=lfs -text
|
*.png filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.svg filter=lfs diff=lfs merge=lfs -text
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
__pycache__
|
__pycache__
|
||||||
.vscode
|
.vscode
|
||||||
|
.buildinfo.json
|
||||||
+8
-5
@@ -1,8 +1,11 @@
|
|||||||
FROM httpd:2.4
|
FROM python:3.14-bookworm
|
||||||
RUN apt-get update
|
RUN apt-get update
|
||||||
RUN apt-get -y install libapache2-mod-wsgi-py3 python3 python3-pip
|
RUN apt-get -y install apache2 apache2-dev
|
||||||
COPY src/requirements.txt /var/www/jc/requirements.txt
|
COPY src/requirements.txt /var/www/jc/requirements.txt
|
||||||
RUN pip3 install -r /var/www/jc/requirements.txt || pip3 install --break-system-packages -r /var/www/jc/requirements.txt
|
RUN /usr/local/bin/pip3 install --upgrade pip
|
||||||
COPY --chown=www-data:www-data config/httpd.conf /usr/local/apache2/conf/httpd.conf
|
RUN /usr/local/bin/pip3 install -r /var/www/jc/requirements.txt
|
||||||
|
COPY --chown=www-data:www-data config/httpd.conf /etc/apache2/apache2.conf
|
||||||
COPY --chown=www-data:www-data src/ /var/www/jc
|
COPY --chown=www-data:www-data src/ /var/www/jc
|
||||||
RUN httpd -t
|
RUN apache2 -t
|
||||||
|
EXPOSE 80
|
||||||
|
ENTRYPOINT ["apache2", "-D", "FOREGROUND"]
|
||||||
Vendored
+46
-17
@@ -33,19 +33,39 @@ pipeline {
|
|||||||
credentialsId: 'Git',
|
credentialsId: 'Git',
|
||||||
url: '[email protected]:jake/jc-ng.git'
|
url: '[email protected]:jake/jc-ng.git'
|
||||||
|
|
||||||
sh "./build.sh registry.jakecharman.co.uk/jakecharman.co.uk $BUILD_NUMBER"
|
sh "./build.sh git.jakecharman.co.uk/jake/jakecharman.co.uk $BUILD_NUMBER"
|
||||||
|
sh "./build.sh europe-west2-docker.pkg.dev/jakecharman/web/jakecharman.co.uk $BUILD_NUMBER"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage('Push to registry') {
|
stage('Security scan') {
|
||||||
when {
|
when {
|
||||||
expression {
|
expression {
|
||||||
return params.Build == true
|
return params.Build == true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
steps {
|
steps {
|
||||||
sh "docker push registry.jakecharman.co.uk/jakecharman.co.uk:$BUILD_NUMBER"
|
catchError(buildResult: 'SUCCESS', stageResult: 'FAILURE') {
|
||||||
sh "docker push registry.jakecharman.co.uk/jakecharman.co.uk:latest"
|
sh "docker kill sectest || true"
|
||||||
|
sh "docker rm sectest || true"
|
||||||
|
sh "docker run -d --name sectest git.jakecharman.co.uk/jake/jakecharman.co.uk:$BUILD_NUMBER"
|
||||||
|
sh "docker exec sectest pip3 install pip-audit --break-system-packages"
|
||||||
|
sh "docker exec sectest pip-audit"
|
||||||
|
sh "docker stop sectest"
|
||||||
|
sh "docker rm sectest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Push to local registry') {
|
||||||
|
when {
|
||||||
|
expression {
|
||||||
|
return params.Build == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
steps {
|
||||||
|
sh "docker push git.jakecharman.co.uk/jake/jakecharman.co.uk:$BUILD_NUMBER"
|
||||||
|
sh "docker push git.jakecharman.co.uk/jake/jakecharman.co.uk:latest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,10 +77,10 @@ pipeline {
|
|||||||
}
|
}
|
||||||
steps{
|
steps{
|
||||||
node('web-staging') {
|
node('web-staging') {
|
||||||
sh "docker pull registry.jakecharman.co.uk/jakecharman.co.uk:latest"
|
sh "docker pull git.jakecharman.co.uk/jake/jakecharman.co.uk:latest"
|
||||||
sh "docker stop jake || true"
|
sh "docker stop jake || true"
|
||||||
sh "docker rm jake || true"
|
sh "docker rm jake || true"
|
||||||
sh "docker run --name jake -e DISCORD_ERR_HOOK=$DISCORD_ERR_STAGING -e DISCORD_WEBHOOK=$DISCORD -e TURNSTILE_SECRET=$TS --restart always --network containers_default -v /opt/containers/jc/projects/:/var/www/jc/projects/ -d registry.jakecharman.co.uk/jakecharman.co.uk:latest"
|
sh "docker run --name jake -e DISCORD_ERR_HOOK=$DISCORD_ERR_STAGING -e DISCORD_WEBHOOK=$DISCORD -e TURNSTILE_SECRET=$TS --restart always --network containers_default -v /opt/containers/jc/projects/:/var/www/jc/projects/ -d git.jakecharman.co.uk/jake/jakecharman.co.uk:latest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,20 +109,25 @@ pipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage('Deploy to production server') {
|
stage('Push to GCP registry') {
|
||||||
|
when {
|
||||||
|
expression {
|
||||||
|
return params.Build == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
steps {
|
||||||
|
sh "docker push europe-west2-docker.pkg.dev/jakecharman/web/jakecharman.co.uk:latest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Deploy to production') {
|
||||||
when {
|
when {
|
||||||
expression {
|
expression {
|
||||||
return params.Deploy == true
|
return params.Deploy == true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
steps{
|
steps{
|
||||||
node('web-server') {
|
sh "gcloud run deploy --project jakecharman --region europe-west1 --image europe-west2-docker.pkg.dev/jakecharman/web/jakecharman.co.uk:latest jakecharman-co-uk"
|
||||||
sh "docker pull registry.jakecharman.co.uk/jakecharman.co.uk:latest"
|
|
||||||
sh "docker stop jake || true"
|
|
||||||
sh "docker rm jake || true"
|
|
||||||
sh "docker run --name jake -e DISCORD_ERR_HOOK=$DISCORD_ERR_PROD -e DISCORD_WEBHOOK=$DISCORD -e TURNSTILE_SECRET=$TS --restart always --network containers_default -v /opt/containers/jc/projects/:/var/www/jc/projects/ -d registry.jakecharman.co.uk/jakecharman.co.uk:latest"
|
|
||||||
sh "/home/jenkins/clearCFCache/clearCache.py a514fb61e1413b88aabbb19df16b8508"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,12 +138,16 @@ pipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
steps {
|
steps {
|
||||||
node('web-server') {
|
|
||||||
git branch: 'master',
|
git branch: 'master',
|
||||||
credentialsId: 'Git',
|
credentialsId: 'Git',
|
||||||
url: '[email protected]:jake/jc-content.git'
|
url: '[email protected]:jake/jc-content.git'
|
||||||
sh "rsync -rv --delete ./ /opt/containers/jc/projects/"
|
sh "gsutil rsync -rcd . gs://jakecharman.co.uk"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Clear cache') {
|
||||||
|
steps{
|
||||||
|
sh "/var/lib/jenkins/clearCFCache/clearCache.py a514fb61e1413b88aabbb19df16b8508"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ set -x -o pipefail
|
|||||||
tag=$1
|
tag=$1
|
||||||
build=$2
|
build=$2
|
||||||
|
|
||||||
|
cat <<EOF >src/.buildinfo.json
|
||||||
|
{
|
||||||
|
"tag": "${tag}:${build}",
|
||||||
|
"date": "$(date -I)",
|
||||||
|
"host": "$(hostname -f)",
|
||||||
|
"user": "${USER}"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
docker build -t ${tag}:latest .
|
docker build -t ${tag}:latest .
|
||||||
if [[ $build != "" ]]; then
|
if [[ $build != "" ]]; then
|
||||||
docker build -t ${tag}:${build} .
|
docker build -t ${tag}:${build} .
|
||||||
|
|||||||
+14
-8
@@ -28,7 +28,7 @@
|
|||||||
# same ServerRoot for multiple httpd daemons, you will need to change at
|
# same ServerRoot for multiple httpd daemons, you will need to change at
|
||||||
# least PidFile.
|
# least PidFile.
|
||||||
#
|
#
|
||||||
ServerRoot "/usr/local/apache2"
|
ServerRoot "/usr/lib/apache2"
|
||||||
|
|
||||||
#
|
#
|
||||||
# Mutex: Allows you to set the mutex mechanism and mutex file directory
|
# Mutex: Allows you to set the mutex mechanism and mutex file directory
|
||||||
@@ -123,7 +123,7 @@ LoadModule deflate_module modules/mod_deflate.so
|
|||||||
#LoadModule brotli_module modules/mod_brotli.so
|
#LoadModule brotli_module modules/mod_brotli.so
|
||||||
LoadModule mime_module modules/mod_mime.so
|
LoadModule mime_module modules/mod_mime.so
|
||||||
#LoadModule ldap_module modules/mod_ldap.so
|
#LoadModule ldap_module modules/mod_ldap.so
|
||||||
LoadModule log_config_module modules/mod_log_config.so
|
#LoadModule log_config_module modules/mod_log_config.so
|
||||||
#LoadModule log_debug_module modules/mod_log_debug.so
|
#LoadModule log_debug_module modules/mod_log_debug.so
|
||||||
#LoadModule log_forensic_module modules/mod_log_forensic.so
|
#LoadModule log_forensic_module modules/mod_log_forensic.so
|
||||||
#LoadModule logio_module modules/mod_logio.so
|
#LoadModule logio_module modules/mod_logio.so
|
||||||
@@ -137,7 +137,7 @@ LoadModule headers_module modules/mod_headers.so
|
|||||||
#LoadModule usertrack_module modules/mod_usertrack.so
|
#LoadModule usertrack_module modules/mod_usertrack.so
|
||||||
#LoadModule unique_id_module modules/mod_unique_id.so
|
#LoadModule unique_id_module modules/mod_unique_id.so
|
||||||
LoadModule setenvif_module modules/mod_setenvif.so
|
LoadModule setenvif_module modules/mod_setenvif.so
|
||||||
LoadModule version_module modules/mod_version.so
|
#LoadModule version_module modules/mod_version.so
|
||||||
#LoadModule remoteip_module modules/mod_remoteip.so
|
#LoadModule remoteip_module modules/mod_remoteip.so
|
||||||
#LoadModule proxy_module modules/mod_proxy.so
|
#LoadModule proxy_module modules/mod_proxy.so
|
||||||
#LoadModule proxy_connect_module modules/mod_proxy_connect.so
|
#LoadModule proxy_connect_module modules/mod_proxy_connect.so
|
||||||
@@ -171,7 +171,7 @@ LoadModule version_module modules/mod_version.so
|
|||||||
#LoadModule lbmethod_bytraffic_module modules/mod_lbmethod_bytraffic.so
|
#LoadModule lbmethod_bytraffic_module modules/mod_lbmethod_bytraffic.so
|
||||||
#LoadModule lbmethod_bybusyness_module modules/mod_lbmethod_bybusyness.so
|
#LoadModule lbmethod_bybusyness_module modules/mod_lbmethod_bybusyness.so
|
||||||
#LoadModule lbmethod_heartbeat_module modules/mod_lbmethod_heartbeat.so
|
#LoadModule lbmethod_heartbeat_module modules/mod_lbmethod_heartbeat.so
|
||||||
LoadModule unixd_module modules/mod_unixd.so
|
#LoadModule unixd_module modules/mod_unixd.so
|
||||||
#LoadModule heartbeat_module modules/mod_heartbeat.so
|
#LoadModule heartbeat_module modules/mod_heartbeat.so
|
||||||
#LoadModule heartmonitor_module modules/mod_heartmonitor.so
|
#LoadModule heartmonitor_module modules/mod_heartmonitor.so
|
||||||
#LoadModule dav_module modules/mod_dav.so
|
#LoadModule dav_module modules/mod_dav.so
|
||||||
@@ -198,8 +198,7 @@ LoadModule dir_module modules/mod_dir.so
|
|||||||
LoadModule alias_module modules/mod_alias.so
|
LoadModule alias_module modules/mod_alias.so
|
||||||
#LoadModule rewrite_module modules/mod_rewrite.somod_wsgi-express start-server
|
#LoadModule rewrite_module modules/mod_rewrite.somod_wsgi-express start-server
|
||||||
|
|
||||||
LoadModule wsgi_module /usr/lib/apache2/modules/mod_wsgi.so
|
LoadModule wsgi_module /usr/local/lib/python3.14/site-packages/mod_wsgi/server/mod_wsgi-py314.cpython-314-x86_64-linux-gnu.so
|
||||||
|
|
||||||
|
|
||||||
<IfModule unixd_module>
|
<IfModule unixd_module>
|
||||||
#
|
#
|
||||||
@@ -368,7 +367,7 @@ LogLevel warn
|
|||||||
# TypesConfig points to the file containing the list of mappings from
|
# TypesConfig points to the file containing the list of mappings from
|
||||||
# filename extension to MIME-type.
|
# filename extension to MIME-type.
|
||||||
#
|
#
|
||||||
TypesConfig conf/mime.types
|
TypesConfig /etc/mime.types
|
||||||
|
|
||||||
#
|
#
|
||||||
# AddType allows you to add to or override the MIME configuration
|
# AddType allows you to add to or override the MIME configuration
|
||||||
@@ -423,7 +422,6 @@ ErrorDocument 503 /error/503
|
|||||||
ErrorDocument 505 /error/505
|
ErrorDocument 505 /error/505
|
||||||
|
|
||||||
WSGISocketPrefix /var/run/wsgi
|
WSGISocketPrefix /var/run/wsgi
|
||||||
|
|
||||||
WSGIDaemonProcess jc-wsgi user=www-data group=www-data threads=5
|
WSGIDaemonProcess jc-wsgi user=www-data group=www-data threads=5
|
||||||
WSGIProcessGroup jc-wsgi
|
WSGIProcessGroup jc-wsgi
|
||||||
WSGIScriptAlias / /var/www/jc/projects.wsgi
|
WSGIScriptAlias / /var/www/jc/projects.wsgi
|
||||||
@@ -434,6 +432,14 @@ WSGIScriptAlias / /var/www/jc/projects.wsgi
|
|||||||
Require all granted
|
Require all granted
|
||||||
</Directory>
|
</Directory>
|
||||||
|
|
||||||
|
Alias "/static" "/var/www/jc/static/"
|
||||||
|
<Directory /var/www/jc/static>
|
||||||
|
Order allow,Deny
|
||||||
|
Allow from all
|
||||||
|
</Directory>
|
||||||
|
|
||||||
|
Alias "/robots.txt" "/var/www/jc/static/robots.txt"
|
||||||
|
|
||||||
<Files jc.wsgi>
|
<Files jc.wsgi>
|
||||||
Require all granted
|
Require all granted
|
||||||
</Files>
|
</Files>
|
||||||
|
|||||||
Executable
+9
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/bash --noprofile
|
||||||
|
|
||||||
|
run() {
|
||||||
|
python3 src/projects.wsgi || run
|
||||||
|
}
|
||||||
|
|
||||||
|
export DISCORD_ERR_HOOK='dummy'
|
||||||
|
run
|
||||||
|
unset DISCORD_ERR_HOOK
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
#!/usr/bin/python3
|
|
||||||
|
|
||||||
from index import app
|
|
||||||
from os import environ
|
|
||||||
from flask import request, render_template
|
|
||||||
from requests import post, get
|
|
||||||
from uuid import uuid4
|
|
||||||
from textwrap import dedent
|
|
||||||
|
|
||||||
def validate_turnstile(response: str, ip: str) -> bool:
|
|
||||||
turnstile_secret = environ['TURNSTILE_SECRET']
|
|
||||||
cf_response = post(
|
|
||||||
url='https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
|
||||||
data={
|
|
||||||
'secret': turnstile_secret,
|
|
||||||
'response': response,
|
|
||||||
'remoteip': ip,
|
|
||||||
'idempotency_key': uuid4()
|
|
||||||
}
|
|
||||||
).json()
|
|
||||||
|
|
||||||
return cf_response.get('success', False)
|
|
||||||
|
|
||||||
def send_to_discord(form: dict) -> bool:
|
|
||||||
try:
|
|
||||||
discord_hook = environ['DISCORD_WEBHOOK']
|
|
||||||
except:
|
|
||||||
return False
|
|
||||||
discord_msg = dedent(
|
|
||||||
f'''
|
|
||||||
__**New Contact Form Response**__
|
|
||||||
|
|
||||||
**From:** {form.get('name')} <{form.get('email')}>
|
|
||||||
|
|
||||||
''')
|
|
||||||
if form.get("message") == '':
|
|
||||||
discord_msg += '*No Message*'
|
|
||||||
else:
|
|
||||||
discord_msg += f'>>> {form.get("message")}'
|
|
||||||
discord_response = post(
|
|
||||||
url=discord_hook,
|
|
||||||
data={
|
|
||||||
'username': form.get('name'),
|
|
||||||
'content': discord_msg
|
|
||||||
}
|
|
||||||
).status_code
|
|
||||||
|
|
||||||
if discord_response == 204:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
@app.route('/contact/', methods=('GET', 'POST'))
|
|
||||||
def contact():
|
|
||||||
if request.method == 'POST':
|
|
||||||
if not validate_turnstile(request.form['cf-turnstile-response'], request.remote_addr):
|
|
||||||
return render_template('contact.html', error=True, user_message='You appear to be a robot.')
|
|
||||||
send_result = send_to_discord(request.form)
|
|
||||||
if send_result:
|
|
||||||
return render_template('contact.html', user_message='Your message has been sent!')
|
|
||||||
return render_template('contact.html', error=True, user_message='An error occurred.')
|
|
||||||
else:
|
|
||||||
return render_template('contact.html', page_title='Contact - ')
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
#!/usr/bin/python3
|
|
||||||
|
|
||||||
import traceback
|
|
||||||
from os import environ
|
|
||||||
import threading
|
|
||||||
import logging
|
|
||||||
from requests import post
|
|
||||||
from flask import Flask, render_template, Response
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
|
||||||
|
|
||||||
# These imports need to come after our app is defined as they add routes to it.
|
|
||||||
import projects # pylint: disable=wrong-import-position,unused-import
|
|
||||||
import contact # pylint: disable=wrong-import-position,unused-import
|
|
||||||
|
|
||||||
class DiscordLogger(logging.Handler):
|
|
||||||
''' Simple logging handler to send a message to Discord '''
|
|
||||||
|
|
||||||
level = logging.ERROR
|
|
||||||
|
|
||||||
def __init__(self, webhook):
|
|
||||||
super().__init__()
|
|
||||||
self._webhook = webhook
|
|
||||||
|
|
||||||
def send_to_discord(self, msg: logging.LogRecord):
|
|
||||||
''' Send the message '''
|
|
||||||
if msg.exc_info is not None:
|
|
||||||
message_to_send = f'{msg.msg}\n\n{"".join(traceback.format_exception(*msg.exc_info))}'
|
|
||||||
else:
|
|
||||||
message_to_send = msg.msg
|
|
||||||
if len(message_to_send) > 2000:
|
|
||||||
chars_to_lose = len(message_to_send) - 2000
|
|
||||||
if msg.exc_info is not None:
|
|
||||||
message_to_send = f'{msg.msg}\n\n{"".join(traceback.format_exception(*msg.exc_info))[-chars_to_lose:]}'
|
|
||||||
else:
|
|
||||||
message_to_send = msg.msg[-chars_to_lose:]
|
|
||||||
post(self._webhook, data={'content': message_to_send}, timeout=30)
|
|
||||||
|
|
||||||
def emit(self, record: logging.LogRecord) -> None:
|
|
||||||
''' Take in the record and start a new thread to send it to Discord '''
|
|
||||||
app.logger.info('Sending error to Discord')
|
|
||||||
dc_thread = threading.Thread(target=self.send_to_discord, args=[record])
|
|
||||||
dc_thread.start()
|
|
||||||
|
|
||||||
discord_logger = DiscordLogger(environ['DISCORD_ERR_HOOK'])
|
|
||||||
app.logger.addHandler(discord_logger)
|
|
||||||
|
|
||||||
@app.route('/')
|
|
||||||
def index() -> str:
|
|
||||||
''' Load the homepage '''
|
|
||||||
return render_template('index.html')
|
|
||||||
|
|
||||||
@app.route('/error/<code>')
|
|
||||||
def error(code) -> str:
|
|
||||||
''' Render a nicer error page for a given code '''
|
|
||||||
|
|
||||||
error_definitions = {
|
|
||||||
400: 'Bad Request',
|
|
||||||
403: 'Forbidden',
|
|
||||||
404: 'Page Not Found',
|
|
||||||
418: 'I\'m a Teapot',
|
|
||||||
500: 'Internal Server Error',
|
|
||||||
503: 'Service Temporarily Unavailable',
|
|
||||||
505: 'HTTP Version Not Supported'
|
|
||||||
}
|
|
||||||
error_desc = {
|
|
||||||
400: 'Sorry, I didn\'t understand your request.',
|
|
||||||
403: 'Sorry, you aren\'t allowed to view this page.',
|
|
||||||
404: 'Sorry, that page doesn\'t exist.',
|
|
||||||
418: 'I can\'t brew coffee as I am, in fact, a teapot.',
|
|
||||||
500: 'Something went wrong on my end.',
|
|
||||||
503: 'My website is experiencing some issues and will be back shortly.',
|
|
||||||
505: 'Your browser tried to use a HTTP version I don\'t support. Check it is up to date.'
|
|
||||||
}
|
|
||||||
|
|
||||||
if not code.isdigit():
|
|
||||||
code=400
|
|
||||||
elif int(code) not in error_definitions:
|
|
||||||
return Response(status=code)
|
|
||||||
|
|
||||||
return render_template('error.html',
|
|
||||||
error=f'{code}: {error_definitions.get(int(code))}',
|
|
||||||
description=error_desc.get(int(code)))
|
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
import traceback
|
||||||
|
from os import environ, path
|
||||||
|
import threading
|
||||||
|
import logging
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
from re import match
|
||||||
|
import json
|
||||||
|
from io import BytesIO
|
||||||
|
from requests import post
|
||||||
|
from flask import Flask, render_template, Response, url_for, request, send_from_directory, make_response
|
||||||
|
from PIL import Image, UnidentifiedImageError
|
||||||
|
from .content import ContentArea
|
||||||
|
from .contact import ContactForm
|
||||||
|
from .storage import LocalStorage
|
||||||
|
from .links import Links
|
||||||
|
from .comments import *
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
md_path = path.join(path.realpath(path.dirname(__file__)), path.normpath('../projects/'))
|
||||||
|
|
||||||
|
projects = ContentArea(
|
||||||
|
directory=LocalStorage(md_path),
|
||||||
|
name='projects',
|
||||||
|
import_name=__name__)
|
||||||
|
|
||||||
|
app.register_blueprint(projects, url_prefix='/projects')
|
||||||
|
app.register_blueprint(ContactForm('contact', __name__), url_prefix='/contact')
|
||||||
|
app.register_blueprint(Links(path.join(md_path, 'links.json'), 'links', __name__), url_prefix='/links')
|
||||||
|
app.register_blueprint(Approval(path.join(projects.md_directory.uri, 'comments.db'), 'comments', __name__), url_prefix='/comments')
|
||||||
|
|
||||||
|
class DiscordLogger(logging.Handler):
|
||||||
|
''' Simple logging handler to send a message to Discord '''
|
||||||
|
|
||||||
|
level = logging.ERROR
|
||||||
|
|
||||||
|
def __init__(self, webhook):
|
||||||
|
super().__init__()
|
||||||
|
self._webhook = webhook
|
||||||
|
|
||||||
|
def send_to_discord(self, msg: logging.LogRecord):
|
||||||
|
''' Send the message '''
|
||||||
|
if msg.exc_info is not None:
|
||||||
|
message_to_send = f'{msg.msg}\n\n{"".join(traceback.format_exception(*msg.exc_info))}'
|
||||||
|
else:
|
||||||
|
message_to_send = msg.msg
|
||||||
|
if len(message_to_send) > 2000:
|
||||||
|
chars_to_lose = len(message_to_send) - 2000
|
||||||
|
if msg.exc_info is not None:
|
||||||
|
message_to_send = f'{msg.msg}\n\n{"".join(traceback.format_exception(*msg.exc_info))[-chars_to_lose:]}'
|
||||||
|
else:
|
||||||
|
message_to_send = msg.msg[-chars_to_lose:]
|
||||||
|
post(self._webhook, data={'content': message_to_send}, timeout=30)
|
||||||
|
|
||||||
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
|
''' Take in the record and start a new thread to send it to Discord '''
|
||||||
|
app.logger.info('Sending error to Discord')
|
||||||
|
dc_thread = threading.Thread(target=self.send_to_discord, args=[record])
|
||||||
|
dc_thread.start()
|
||||||
|
|
||||||
|
discord_logger = DiscordLogger(environ['DISCORD_ERR_HOOK'])
|
||||||
|
app.logger.addHandler(discord_logger)
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def inject_branding() -> dict:
|
||||||
|
''' Modify branding depending on the URL being used '''
|
||||||
|
req_domain = urlsplit(request.base_url).netloc.lower()
|
||||||
|
match req_domain:
|
||||||
|
case 'jakecharman.co.uk':
|
||||||
|
brand = 'Jake Charman'
|
||||||
|
case _:
|
||||||
|
brand = req_domain
|
||||||
|
|
||||||
|
return {'branding': brand}
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index() -> str:
|
||||||
|
''' Load the homepage '''
|
||||||
|
return render_template('index.html')
|
||||||
|
|
||||||
|
@app.route('/error/<code>')
|
||||||
|
def error(code) -> str:
|
||||||
|
''' Render a nicer error page for a given code '''
|
||||||
|
|
||||||
|
error_definitions = {
|
||||||
|
400: 'Bad Request',
|
||||||
|
403: 'Forbidden',
|
||||||
|
404: 'Page Not Found',
|
||||||
|
418: 'I\'m a Teapot',
|
||||||
|
500: 'Internal Server Error',
|
||||||
|
503: 'Service Temporarily Unavailable',
|
||||||
|
505: 'HTTP Version Not Supported'
|
||||||
|
}
|
||||||
|
error_desc = {
|
||||||
|
400: 'Sorry, I didn\'t understand your request.',
|
||||||
|
403: 'Sorry, you aren\'t allowed to view this page.',
|
||||||
|
404: 'Sorry, that page doesn\'t exist.',
|
||||||
|
418: 'I can\'t brew coffee as I am, in fact, a teapot.',
|
||||||
|
500: 'Something went wrong on my end.',
|
||||||
|
503: 'My website is experiencing some issues and will be back shortly.',
|
||||||
|
505: 'Your browser tried to use a HTTP version I don\'t support. Check it is up to date.'
|
||||||
|
}
|
||||||
|
|
||||||
|
if not code.isdigit():
|
||||||
|
code=400
|
||||||
|
elif int(code) not in error_definitions:
|
||||||
|
return Response(status=code)
|
||||||
|
|
||||||
|
return render_template('error.html',
|
||||||
|
error=f'{code}: {error_definitions.get(int(code))}',
|
||||||
|
description=error_desc.get(int(code)))
|
||||||
|
|
||||||
|
def get_routes() -> list:
|
||||||
|
''' Get a list of all routes that make up the app '''
|
||||||
|
routes = []
|
||||||
|
for rule in app.url_map.iter_rules():
|
||||||
|
if 0 >= len(rule.arguments):
|
||||||
|
url = url_for(rule.endpoint, **(rule.defaults or {}))
|
||||||
|
routes.append(url)
|
||||||
|
return routes
|
||||||
|
|
||||||
|
def get_build_date():
|
||||||
|
''' Get the build date of the Docker container we're running in '''
|
||||||
|
try:
|
||||||
|
with open('/var/www/jc/.buildinfo.json', encoding='utf8') as build:
|
||||||
|
build_json = json.load(build)
|
||||||
|
return build_json['date']
|
||||||
|
except Exception: # pylint: disable=broad-exception-caught
|
||||||
|
return '1970-01-01'
|
||||||
|
|
||||||
|
@app.route('/sitemap.xml')
|
||||||
|
def sitemap():
|
||||||
|
''' Return an XML site map '''
|
||||||
|
date = get_build_date()
|
||||||
|
root = ET.Element('urlset', xmlns='http://www.sitemaps.org/schemas/sitemap/0.9')
|
||||||
|
base_url = match(r'^https?:\/\/.+:?\d*(?=\/)', request.base_url).group()
|
||||||
|
base_url = base_url.replace('http://', 'https://')
|
||||||
|
for route in get_routes():
|
||||||
|
url = ET.SubElement(root, 'url')
|
||||||
|
ET.SubElement(url, 'loc').text = base_url + route
|
||||||
|
ET.SubElement(url, 'lastmod').text = date
|
||||||
|
for article in projects.get_live_posts():
|
||||||
|
if 'link' in article.metadata:
|
||||||
|
continue
|
||||||
|
url = ET.SubElement(root, 'url')
|
||||||
|
ET.SubElement(url, 'loc').text = f'{base_url}/projects/{article.metadata['id']}'
|
||||||
|
ET.SubElement(url, 'lastmod').text = article.metadata['date'].strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
return Response(ET.tostring(root, encoding='utf-8'), 200, {'content-type': 'application/xml'})
|
||||||
|
|
||||||
|
def resize_image(image_name: str, size: tuple):
|
||||||
|
w = size[0]
|
||||||
|
h = size[1]
|
||||||
|
|
||||||
|
md_directory = LocalStorage(md_path)
|
||||||
|
the_image = Image.open(path.join(md_directory.uri, 'images', image_name))
|
||||||
|
max_width, max_height = the_image.size
|
||||||
|
|
||||||
|
if path.exists(path.join(md_directory.uri, 'images', f'{w}-{h}-{image_name}')) or (w >= max_width and h >= max_height):
|
||||||
|
raise FileExistsError()
|
||||||
|
|
||||||
|
req_size = [max_width, max_height]
|
||||||
|
if w > 0:
|
||||||
|
req_size[0] = w
|
||||||
|
if h > 0:
|
||||||
|
req_size[1] = h
|
||||||
|
|
||||||
|
resized_img = BytesIO()
|
||||||
|
the_image.thumbnail(tuple(req_size))
|
||||||
|
the_image.save(resized_img, format=the_image.format)
|
||||||
|
the_image.save(path.join(md_directory.uri, 'images', f'{w}-{h}-{image_name}'), the_image.format)
|
||||||
|
|
||||||
|
@app.route('/image/<image_name>')
|
||||||
|
def image( image_name: str) -> Response:
|
||||||
|
''' Resize and return an image. '''
|
||||||
|
md_directory = LocalStorage(md_path)
|
||||||
|
|
||||||
|
w = int(request.args.get('w', 0))
|
||||||
|
h = int(request.args.get('h', 0))
|
||||||
|
|
||||||
|
if w == 0 and h == 0:
|
||||||
|
return send_from_directory(md_directory.uri, path.join('images', image_name))
|
||||||
|
if path.exists(path.join(md_directory.uri, 'images', f'{w}-{h}-{image_name}')):
|
||||||
|
return send_from_directory(md_directory.uri, path.join('images', f'{w}-{h}-{image_name}'))
|
||||||
|
try:
|
||||||
|
resize_image(image_name, (w, h))
|
||||||
|
except FileNotFoundError:
|
||||||
|
return Response(status=404)
|
||||||
|
except UnidentifiedImageError:
|
||||||
|
return send_from_directory(md_directory.uri, path.join('images', image_name))
|
||||||
|
except FileExistsError:
|
||||||
|
return send_from_directory(md_directory.uri, path.join('images', image_name))
|
||||||
|
|
||||||
|
return send_from_directory(md_directory.uri, path.join('images', f'{w}-{h}-{image_name}'))
|
||||||
|
|
||||||
|
@app.route('/image/thumb/<image_name>')
|
||||||
|
def img_thumb(image_name: str):
|
||||||
|
''' Flask route to load an image '''
|
||||||
|
md_directory = LocalStorage(md_path)
|
||||||
|
w = 400
|
||||||
|
h = 0
|
||||||
|
thumb_file = path.join(md_directory.uri, 'images', f'{w}-{h}-{image_name}')
|
||||||
|
if path.exists(thumb_file):
|
||||||
|
return send_from_directory(md_directory.uri, path.join('images', f'{w}-{h}-{image_name}'))
|
||||||
|
try:
|
||||||
|
resize_image(image_name, (w, h))
|
||||||
|
except FileNotFoundError:
|
||||||
|
return Response(status=404)
|
||||||
|
except UnidentifiedImageError:
|
||||||
|
return send_from_directory(md_directory.uri, path.join('images', image_name))
|
||||||
|
except FileExistsError:
|
||||||
|
return send_from_directory(md_directory.uri, path.join('images', f'{w}-{h}-{image_name}'))
|
||||||
|
|
||||||
|
return send_from_directory(md_directory.uri, path.join('images', f'{w}-{h}-{image_name}'))
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from flask import Blueprint, Response, request
|
||||||
|
from uuid import uuid4
|
||||||
|
from requests import post
|
||||||
|
from os import environ
|
||||||
|
|
||||||
|
|
||||||
|
class PostComments():
|
||||||
|
def __init__(self, post_id: str, db_path: str):
|
||||||
|
self.__db_path = db_path
|
||||||
|
self.__post_id = post_id
|
||||||
|
self._webhook = environ['DISCORD_WEBHOOK']
|
||||||
|
with sqlite3.connect(db_path) as db:
|
||||||
|
cursor = db.cursor()
|
||||||
|
cursor.execute("CREATE TABLE IF NOT EXISTS comments (name TEXT, comment TEXT, date INT, post_id TEXT, approved BOOL, key TEXT)")
|
||||||
|
self.comments = cursor.execute(
|
||||||
|
"SELECT name, comment, date FROM comments WHERE approved = 1 AND post_id = ? ORDER BY date DESC",
|
||||||
|
(post_id,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
def send_to_discord(self, name: str, comment: str, comment_id: int, key: str):
|
||||||
|
''' Send the message '''
|
||||||
|
message_to_send = f'New comment from {name}\n\n{comment}'
|
||||||
|
if len(message_to_send) > 2000:
|
||||||
|
chars_to_lose = len(message_to_send) - 1900
|
||||||
|
message_to_send = message_to_send[-chars_to_lose:]
|
||||||
|
message_to_send += f'\n\n[Approve](https://jakecharman.co.uk/comments/approve/{comment_id}?key={key})'
|
||||||
|
post(self._webhook, data={'content': message_to_send}, timeout=30)
|
||||||
|
|
||||||
|
def make_comment(self, name: str, comment: str):
|
||||||
|
with sqlite3.connect(self.__db_path) as db:
|
||||||
|
key = str(uuid4())
|
||||||
|
cursor = db.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO comments (name, comment, date, post_id, approved, key) VALUES (?, ?, datetime('now'), ?, 0, ?)",
|
||||||
|
(name, comment, self.__post_id, key)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
self.send_to_discord(name, comment, cursor.lastrowid, key)
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
class Approval(Blueprint):
|
||||||
|
def __init__(self, db_path: str, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.__db_path = db_path
|
||||||
|
self.add_url_rule('/approve/<comment_id>', view_func=self.approve)
|
||||||
|
|
||||||
|
def approve(self, comment_id: str):
|
||||||
|
with sqlite3.connect(self.__db_path) as db:
|
||||||
|
cursor = db.cursor()
|
||||||
|
key = cursor.execute("SELECT key FROM comments WHERE rowid = ?", (comment_id,)).fetchone()[0]
|
||||||
|
if request.args.get('key') == key:
|
||||||
|
with sqlite3.connect(self.__db_path) as db:
|
||||||
|
cursor = db.cursor()
|
||||||
|
cursor.execute("UPDATE comments SET approved = 1 WHERE rowid = ?", (comment_id,))
|
||||||
|
db.commit()
|
||||||
|
return Response(status=200)
|
||||||
|
return Response(status=403)
|
||||||
Executable
+70
@@ -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 - ')
|
||||||
Executable
+156
@@ -0,0 +1,156 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
from os import path
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
import frontmatter
|
||||||
|
from markdown import markdown
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from flask import render_template, Response, Blueprint, request, redirect
|
||||||
|
from .storage import LocalStorage
|
||||||
|
from .comments import PostComments, Approval
|
||||||
|
|
||||||
|
class ContentArea(Blueprint):
|
||||||
|
def __init__(self, directory: LocalStorage, *args, **kwargs):
|
||||||
|
self.md_directory = directory
|
||||||
|
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.add_app_template_filter(self.category_title, 'category_title')
|
||||||
|
self.add_app_template_filter(self.human_date, 'human_date')
|
||||||
|
self.add_app_template_filter(self.to_html, 'to_html')
|
||||||
|
|
||||||
|
self.context_processor(self.processor)
|
||||||
|
|
||||||
|
self.add_url_rule('/', view_func=self.projects)
|
||||||
|
self.add_url_rule('/category/<category_id>/', view_func=self.category)
|
||||||
|
self.add_url_rule('/<article_id>', view_func=self.article)
|
||||||
|
self.add_url_rule('/<article_id>/comment', view_func=self.comment, methods=['POST'])
|
||||||
|
|
||||||
|
def processor(self) -> dict:
|
||||||
|
''' Jninja processors '''
|
||||||
|
def get_excerpt(post: frontmatter.Post) -> str:
|
||||||
|
html = markdown(post.content)
|
||||||
|
post_soup = BeautifulSoup(html, 'html.parser')
|
||||||
|
all_text = ' '.join([x.get_text() for x in post_soup.findAll('p')])
|
||||||
|
return ' '.join(all_text.split()[:200])
|
||||||
|
return dict(get_excerpt=get_excerpt)
|
||||||
|
|
||||||
|
def category_title(self, category_id: str) -> str:
|
||||||
|
''' Jninja filter to get a category title by its ID '''
|
||||||
|
with self.md_directory.open('categories.json') as categories_file:
|
||||||
|
categories = json.load(categories_file)
|
||||||
|
|
||||||
|
return categories.get(category_id).get('title', '')
|
||||||
|
|
||||||
|
def human_date(self, iso_date: str) -> str:
|
||||||
|
''' Jninja filter to convert an ISO date to human readable. '''
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(str(iso_date)).strftime('%A %d %B %Y')
|
||||||
|
except ValueError:
|
||||||
|
return iso_date
|
||||||
|
|
||||||
|
def to_html(self, content: str) -> str:
|
||||||
|
''' Jninja filter to wrap markdown '''
|
||||||
|
return markdown(content)
|
||||||
|
|
||||||
|
def get_all_posts(self) -> list:
|
||||||
|
''' Get all posts in the posts directory '''
|
||||||
|
abs_paths = [x.path for x in self.md_directory.ls() if x.path.endswith('.md')]
|
||||||
|
posts = []
|
||||||
|
for p in abs_paths:
|
||||||
|
with self.md_directory.open(p) as f:
|
||||||
|
posts.append(frontmatter.load(f))
|
||||||
|
return posts
|
||||||
|
|
||||||
|
def get_live_posts(self) -> list:
|
||||||
|
''' Get all posts in the posts directory excluding ones with a date in the future '''
|
||||||
|
return [x for x in self.get_all_posts() if x.metadata.get('date') <= datetime.now().date()]
|
||||||
|
|
||||||
|
def get_by_meta_key(self, key: str, value: str) -> list:
|
||||||
|
''' Get posts by a metadata key value pair '''
|
||||||
|
return [x for x in self.get_live_posts() if x.get(key) == value or isinstance(x.get(key, []), list) and value in x.get(key, [])]
|
||||||
|
|
||||||
|
def projects(self) -> str:
|
||||||
|
''' Load the projects page '''
|
||||||
|
articles_to_return = sorted(
|
||||||
|
self.get_live_posts(),
|
||||||
|
key=lambda d: d.metadata.get('date'),
|
||||||
|
reverse=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(articles_to_return) < 1:
|
||||||
|
return render_template('error.html',
|
||||||
|
error='There\'s nothing here... yet.',
|
||||||
|
description='I\'m still working on this page. Check back soon for some content.')
|
||||||
|
try:
|
||||||
|
with self.md_directory.open('categories.json') as categories_file:
|
||||||
|
categories = json.load(categories_file)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return render_template('error.html',
|
||||||
|
error='There\'s nothing here... yet.',
|
||||||
|
description='I\'m still working on this page. Check back soon for some content.')
|
||||||
|
|
||||||
|
return render_template('projects.html',
|
||||||
|
articles=articles_to_return,
|
||||||
|
all_categories=categories,
|
||||||
|
title='Projects',
|
||||||
|
page_title='Projects - ',
|
||||||
|
description='A selection of projects I\'ve been involved in')
|
||||||
|
|
||||||
|
def category(self, category_id: str) -> str:
|
||||||
|
''' Load the page for a given category '''
|
||||||
|
try:
|
||||||
|
with self.md_directory.open('categories.json') as categories_file:
|
||||||
|
categories = json.load(categories_file)
|
||||||
|
the_category = categories.get(category_id)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return render_template('error.html',
|
||||||
|
error='There\'s nothing here... yet.',
|
||||||
|
description='I\'m still working on this page. Check back soon for some content.')
|
||||||
|
|
||||||
|
if the_category is None:
|
||||||
|
return Response(status=404)
|
||||||
|
|
||||||
|
articles_to_return = sorted(
|
||||||
|
self.get_by_meta_key(
|
||||||
|
'categories', category_id),
|
||||||
|
key=lambda d: d.metadata.get('date'),
|
||||||
|
reverse=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(articles_to_return) < 1:
|
||||||
|
return render_template('error.html',
|
||||||
|
error='There\'s nothing here... yet.',
|
||||||
|
description='I\'m still working on this page. Check back soon for some content.')
|
||||||
|
|
||||||
|
return render_template('projects.html', articles=articles_to_return,
|
||||||
|
title=the_category['title'],
|
||||||
|
description=the_category['long_description'],
|
||||||
|
page_title=f'{the_category["title"]} - ',
|
||||||
|
all_categories=categories,
|
||||||
|
current_category=category_id)
|
||||||
|
|
||||||
|
def article(self, article_id: str) -> str:
|
||||||
|
''' Load a single article '''
|
||||||
|
articles = self.get_by_meta_key('id', article_id)
|
||||||
|
|
||||||
|
if len(articles) == 0:
|
||||||
|
return Response(status=404)
|
||||||
|
if len(articles) > 1:
|
||||||
|
return Response(status=500)
|
||||||
|
|
||||||
|
the_article = articles[0]
|
||||||
|
|
||||||
|
comments = PostComments(the_article.metadata['id'], path.join(self.md_directory.uri, 'comments.db')).comments
|
||||||
|
return render_template('article.html', post=markdown(the_article.content),
|
||||||
|
metadata=the_article.metadata,
|
||||||
|
comments = comments,
|
||||||
|
page_title=f'{the_article.metadata["title"]} - ')
|
||||||
|
|
||||||
|
def comment(self, article_id: str):
|
||||||
|
PostComments(article_id, path.join(self.md_directory.uri, 'comments.db')).make_comment(
|
||||||
|
request.form['name'],
|
||||||
|
request.form['comment']
|
||||||
|
)
|
||||||
|
return redirect(f'/projects/{article_id}?comment=true#comments')
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from flask import Blueprint, render_template
|
||||||
|
import json
|
||||||
|
|
||||||
|
class Links(Blueprint):
|
||||||
|
def __init__(self, file, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.add_url_rule('/', view_func=self.links)
|
||||||
|
self.file = file
|
||||||
|
|
||||||
|
def links(self):
|
||||||
|
with open(self.file, encoding='utf8') as file:
|
||||||
|
links = json.load(file)
|
||||||
|
return render_template('links.html', links=links, page_title='Useful Links - ')
|
||||||
@@ -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')
|
||||||
Executable
+44
@@ -0,0 +1,44 @@
|
|||||||
|
{% extends 'main.html' %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
{% if metadata.gallery %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded",function(){
|
||||||
|
post_gallery = new Viewer(document.getElementById('article'), {
|
||||||
|
'url': 'realsrc',
|
||||||
|
'title': false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main>
|
||||||
|
<section id="article">
|
||||||
|
<h1>{{ metadata.title}} </h1>
|
||||||
|
<p>{{ metadata.date | human_date }}</p>
|
||||||
|
<hr />
|
||||||
|
{{post|safe}}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<section id="comments">
|
||||||
|
<h2>{{comments | length}} Comment{% if comments | length != 1 %}s{% endif %}</h2>
|
||||||
|
{% for comment in comments %}
|
||||||
|
<div class="comment">
|
||||||
|
<strong>{{ comment[0] }} - {{ comment[2] | human_date }}</strong>
|
||||||
|
<p>{{ comment[1] }}</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<h3>Leave a comment</h3>
|
||||||
|
{% if request.args.get('comment') is none %}
|
||||||
|
<form action="./{{metadata.id}}/comment" method="post">
|
||||||
|
<input type="text", name="name" placeholder="Name" required>
|
||||||
|
<textarea name="comment" placeholder="Comment" required></textarea>
|
||||||
|
<input type="submit" value="Submit">
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p>Thank you! Your comment will appear once it is approved</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
{% include 'header.html' %}
|
{% extends 'main.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>
|
||||||
@@ -17,4 +19,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>
|
||||||
{% include 'footer.html' %}
|
{% endblock %}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
{% include 'header.html' %}
|
{% extends 'main.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
<main>
|
<main>
|
||||||
<div id="error-container">
|
<div id="error-container">
|
||||||
<div id='error'>
|
<div id='error'>
|
||||||
@@ -8,4 +10,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
{% include 'footer.html' %}
|
{% endblock %}
|
||||||
Executable
+80
@@ -0,0 +1,80 @@
|
|||||||
|
{% extends 'main.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main>
|
||||||
|
<section id="technology">
|
||||||
|
<div class="gradient gradient-left">
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<div class="text text-left">
|
||||||
|
<div>
|
||||||
|
<h2>Technology</h2>
|
||||||
|
<hr>
|
||||||
|
<p>Technology is my day job. I design and build distributed systems for large companies around the world.</p>
|
||||||
|
<p>
|
||||||
|
I also work with technology in my spare time, building systems for <a href="#motorsport">Nitro Junkie</a>, and for me to use personally. For example, the current iteration of this website is written in Python and deployed via a Jenkins pipeline to Google Cloud Run as a Docker container.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Below are some of the technologies I'm using most at the moment.
|
||||||
|
My personal projects can also be found over on the <a href="/projects/">projects</a> page. Be aware though that if I'm able to write about a project here, it most likely wasn't done professionally so some may be a little rough around the edges.
|
||||||
|
</p>
|
||||||
|
<div id="techlogos">
|
||||||
|
<a href="https://ansible.com"><img src="/static/images/technology/ansible.png" /></a>
|
||||||
|
<a href="https://aws.amazon.com/"><img src="/static/images/technology/aws.png" /></a>
|
||||||
|
<a href="https://azure.microsoft.com/en-gb"><img src="/static/images/technology/azure.png" /></a>
|
||||||
|
<a href="https://dotnet.microsoft.com/en-us/languages/csharp"><img src="/static/images/technology/csharp.png" /></a>
|
||||||
|
<a href="https://www.debian.org/"><img src="/static/images/technology/debian.png" /></a>
|
||||||
|
<a href="https://www.digitalocean.com/"><img src="/static/images/technology/digitalocean.png" /></a>
|
||||||
|
<a href="https://www.docker.com/"><img src="/static/images/technology/docker.png" /></a>
|
||||||
|
<a href="https://www.freeipa.org/"><img src="/static/images/technology/freeipa.png" /></a>
|
||||||
|
<a href="https://cloud.google.com/"><img src="/static/images/technology/gcloud.png" /></a>
|
||||||
|
<a href="https://git-scm.com/"><img src="/static/images/technology/git.png" /></a>
|
||||||
|
<a href="https://www.grafana.com/"><img src="/static/images/technology/grafana.png" /></a>
|
||||||
|
<a href="https://hadoop.apache.org/"><img src="/static/images/technology/hadoop.svg" /></a>
|
||||||
|
<a href="https://hive.apache.org/"><img src="/static/images/technology/hive.svg" /></a>
|
||||||
|
<a href="https://www.java.com/"><img src="/static/images/technology/java.png" /></a>
|
||||||
|
<a href="https://www.jenkins.io/"><img src="/static/images/technology/jenkins.png" /></a>
|
||||||
|
<a href="https://mariadb.org/"><img src="/static/images/technology/mariadb.png" /></a>
|
||||||
|
<a href="https://nginx.org/"><img src="/static/images/technology/nginx.svg" /></a>
|
||||||
|
<a href="https://www.proxmox.com/"><img src="/static/images/technology/proxmox.png" /></a>
|
||||||
|
<a href="https://www.python.org/"><img src="/static/images/technology/python.png" /></a>
|
||||||
|
<a href="https://www.redhat.com/en/technologies/linux-platforms/enterprise-linux"><img src="/static/images/technology/redhat.png" /></a>
|
||||||
|
<a href="https://rockylinux.org/"><img src="/static/images/technology/rocky.png" /></a>
|
||||||
|
<a href="https://subversion.apache.org/"><img src="/static/images/technology/svn.svg" /></a>
|
||||||
|
</div>
|
||||||
|
<div id="certs">
|
||||||
|
<a href="https://catalog-education.oracle.com/ords/certview/sharebadge?id=A6C9B3D3D21627EB9C2504395194FAF772E01412911D4ADC78063133D54579ED"><img src="/static/images/certs/OCIF2023CA.png" /></a>
|
||||||
|
</div>
|
||||||
|
<div class="social">
|
||||||
|
<a class="button" href="https://www.linkedin.com/in/jakecharman/"><i class="fa-brands fa-linkedin-in"></i></a>
|
||||||
|
<a class="button" href="https://github.com/jcharman/"><i class="fa-brands fa-github"></i></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section id="motorsport">
|
||||||
|
<div class="gradient gradient-right">
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<div class="text text-right">
|
||||||
|
<div>
|
||||||
|
<h2>Racing</h2>
|
||||||
|
<hr />
|
||||||
|
<p>When I'm not at work, I can often be found at <a href="https://santapod.com">Santa Pod Raceway</a> with <a href="https://nitrojunkie.uk">Nitro Junkie Racing</a></p>
|
||||||
|
<p>We run a Top Fuel motorcycle purpose built for drag racing. The engine is loosely based on a Kawasaki Z 1000, but supercharged, and nitromethane injected to produce around 1,000 horsepower.</p>
|
||||||
|
<p>I've also had the opportunity to work on a 10,000 horsepower Top Fuel Dragster. I <a href="https://jakecharman.co.uk/projects/topfuel_dragster">wrote about this on the Nitro Junkie website, and later extended that post here.</a></p>
|
||||||
|
<div class="gallery">
|
||||||
|
<iframe class="yt" src="https://www.youtube.com/embed/Wa-V8mQTXFk?si=W-LzfR3Qj_jgAdkY&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">
|
||||||
|
</div>
|
||||||
|
<div class="social">
|
||||||
|
<a class="button" href="https://nitrojunkie.uk"><i class="fa-solid fa-globe"></i></a>
|
||||||
|
<a class="button" href="https://www.youtube.com/@NitroJunkieUK"><i class="fa-brands fa-youtube"></i></a>
|
||||||
|
<a class="button" href="https://www.facebook.com/nitrojunkie.uk"><i class="fa-brands fa-facebook"></i></a>
|
||||||
|
<a class="button" href="https://instagr.am/nitrojunkieuk"><i class="fa-brands fa-instagram"></i></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{% extends 'main.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main id="links-main">
|
||||||
|
<h2>Useful Links</h2>
|
||||||
|
<section id="links">
|
||||||
|
{% for category in links %}
|
||||||
|
<div class="category">
|
||||||
|
<h3>{{category}}</h3>
|
||||||
|
{% for link in links.get(category) %}
|
||||||
|
<div class="link">
|
||||||
|
{% if link.get('img') is not none %}
|
||||||
|
<a href="{{link.src}}" target="_blank"></a>
|
||||||
|
<img class="link-thumb"
|
||||||
|
srcset="
|
||||||
|
{% for i in range(200, 5100, 100) %}
|
||||||
|
/image/{{ link.img }}?w={{i}} {{i}}w{{"," if not loop.last}}
|
||||||
|
{% endfor %}
|
||||||
|
"
|
||||||
|
sizes="
|
||||||
|
(max-width: 999px) 80vw,
|
||||||
|
(min-width: 1000px) 20vw
|
||||||
|
"
|
||||||
|
src="/image/{{ link.img }}">
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{link.src}}" target="_blank"><h3>{{ link.title }}</h3></a>
|
||||||
|
<p>{{ link.description }}</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
Executable → Regular
+20
-2
@@ -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 }}Jake Charman</title>
|
<title>{{ page_title }}{{branding}}</title>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Orbitron:[email protected]&family=Tourney:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Orbitron:[email protected]&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:[email protected]&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:[email protected]&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,10 +12,18 @@
|
|||||||
<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>
|
||||||
|
<!-- Privacy-friendly analytics by Plausible -->
|
||||||
|
<script async src="https://analytics.jakecharman.co.uk/js/pa-BBRBzeeo-AC_Nvfm4VWc9.js"></script>
|
||||||
|
<script>
|
||||||
|
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
|
||||||
|
plausible.init()
|
||||||
|
</script>
|
||||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-6WMXXY0RL0"></script>
|
<script 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);}
|
||||||
@@ -23,6 +31,7 @@
|
|||||||
|
|
||||||
gtag('config', 'G-6WMXXY0RL0');
|
gtag('config', 'G-6WMXXY0RL0');
|
||||||
</script>
|
</script>
|
||||||
|
{% block head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
@@ -30,10 +39,19 @@
|
|||||||
<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>Jake Charman</h1></a>
|
<a href='/'><h1>{{branding|upper}}</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,4 +1,6 @@
|
|||||||
{% include 'header.html' %}
|
{% extends 'main.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>
|
||||||
@@ -24,14 +26,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) %}
|
||||||
/projects/image/{{ row.image }}?w={{i}} {{i}}w{{"," if not loop.last}}
|
/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="/projects/image/{{ row.image }}">
|
src="/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 %}
|
||||||
@@ -51,4 +53,4 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
{% include 'footer.html' %}
|
{% endblock %}
|
||||||
-167
@@ -1,167 +0,0 @@
|
|||||||
#!/usr/bin/python3
|
|
||||||
|
|
||||||
from os import path
|
|
||||||
import json
|
|
||||||
from datetime import datetime
|
|
||||||
from io import BytesIO
|
|
||||||
from glob import glob
|
|
||||||
from PIL import Image, UnidentifiedImageError
|
|
||||||
import frontmatter
|
|
||||||
from markdown import markdown
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
from flask import render_template, Response, send_from_directory, request, make_response
|
|
||||||
from index import app
|
|
||||||
|
|
||||||
md_directory = path.join(path.realpath(path.dirname(__file__)), path.normpath('projects/'))
|
|
||||||
|
|
||||||
@app.context_processor
|
|
||||||
def processor() -> dict:
|
|
||||||
''' Jninja processors '''
|
|
||||||
def get_excerpt(post: frontmatter.Post) -> str:
|
|
||||||
html = markdown(post.content)
|
|
||||||
post_soup = BeautifulSoup(html, 'html.parser')
|
|
||||||
all_text = ' '.join([x.get_text() for x in post_soup.findAll('p')])
|
|
||||||
return ' '.join(all_text.split()[:200])
|
|
||||||
return dict(get_excerpt=get_excerpt)
|
|
||||||
|
|
||||||
@app.template_filter('category_title')
|
|
||||||
def category_title(category_id: str) -> str:
|
|
||||||
''' Jninja filter to get a category title by its ID '''
|
|
||||||
with open(path.join(md_directory, 'categories.json'), encoding='utf8') as categories_file:
|
|
||||||
categories = json.load(categories_file)
|
|
||||||
|
|
||||||
return categories.get(category_id).get('title', '')
|
|
||||||
|
|
||||||
@app.template_filter('human_date')
|
|
||||||
def human_date(iso_date: str) -> str:
|
|
||||||
''' Jninja filter to convert an ISO date to human readable. '''
|
|
||||||
try:
|
|
||||||
return datetime.fromisoformat(str(iso_date)).strftime('%A %d %B %Y')
|
|
||||||
except ValueError:
|
|
||||||
return iso_date
|
|
||||||
|
|
||||||
@app.template_filter('to_html')
|
|
||||||
def to_html(content: str) -> str:
|
|
||||||
''' Jninja filter to wrap markdown '''
|
|
||||||
return markdown(content)
|
|
||||||
|
|
||||||
def get_all_posts(directory: str) -> list:
|
|
||||||
''' Get all posts in the posts directory '''
|
|
||||||
abs_paths = [path.join(directory, x) for x in glob(f'{directory}/*.md')]
|
|
||||||
return [frontmatter.load(x) for x in abs_paths]
|
|
||||||
|
|
||||||
def get_by_meta_key(directory: str, key: str, value: str) -> list:
|
|
||||||
''' Get posts by a metadata key value pair '''
|
|
||||||
return [x for x in get_all_posts(directory) if x.get(key) == value or type(x.get(key, [])) is list and value in x.get(key, [])]
|
|
||||||
|
|
||||||
@app.route('/projects/')
|
|
||||||
def projects() -> str:
|
|
||||||
''' Load the projects page '''
|
|
||||||
articles_to_return = sorted(
|
|
||||||
get_all_posts(
|
|
||||||
md_directory),
|
|
||||||
key=lambda d: d.metadata.get('date'),
|
|
||||||
reverse=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(articles_to_return) < 1:
|
|
||||||
return render_template('error.html',
|
|
||||||
error='There\'s nothing here... yet.',
|
|
||||||
description='I\'m still working on this page. Check back soon for some content.')
|
|
||||||
try:
|
|
||||||
with open(path.join(md_directory, 'categories.json'), encoding='utf8') as categories_file:
|
|
||||||
categories = json.load(categories_file)
|
|
||||||
except FileNotFoundError:
|
|
||||||
return render_template('error.html',
|
|
||||||
error='There\'s nothing here... yet.',
|
|
||||||
description='I\'m still working on this page. Check back soon for some content.')
|
|
||||||
|
|
||||||
return render_template('projects.html',
|
|
||||||
articles=articles_to_return,
|
|
||||||
all_categories=categories,
|
|
||||||
title='Projects',
|
|
||||||
page_title='Projects - ',
|
|
||||||
description='A selection of projects I\'ve been involved in')
|
|
||||||
|
|
||||||
@app.route('/projects/category/<category_id>/')
|
|
||||||
def category(category_id: str) -> str:
|
|
||||||
''' Load the page for a given category '''
|
|
||||||
try:
|
|
||||||
with open(path.join(md_directory, 'categories.json'), encoding='utf8') as categories_file:
|
|
||||||
categories = json.load(categories_file)
|
|
||||||
the_category = categories.get(category_id)
|
|
||||||
except FileNotFoundError:
|
|
||||||
return render_template('error.html',
|
|
||||||
error='There\'s nothing here... yet.',
|
|
||||||
description='I\'m still working on this page. Check back soon for some content.')
|
|
||||||
|
|
||||||
if the_category is None:
|
|
||||||
return Response(status=404)
|
|
||||||
|
|
||||||
articles_to_return = sorted(
|
|
||||||
get_by_meta_key(
|
|
||||||
md_directory, 'categories', category_id),
|
|
||||||
key=lambda d: d.metadata.get('date'),
|
|
||||||
reverse=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(articles_to_return) < 1:
|
|
||||||
return render_template('error.html',
|
|
||||||
error='There\'s nothing here... yet.',
|
|
||||||
description='I\'m still working on this page. Check back soon for some content.')
|
|
||||||
|
|
||||||
return render_template('projects.html', articles=articles_to_return,
|
|
||||||
title=the_category['title'],
|
|
||||||
description=the_category['long_description'],
|
|
||||||
page_title=f'{the_category["title"]} - ',
|
|
||||||
all_categories=categories,
|
|
||||||
current_category=category_id)
|
|
||||||
|
|
||||||
@app.route('/projects/<article_id>')
|
|
||||||
def article(article_id: str) -> str:
|
|
||||||
''' Load a single article '''
|
|
||||||
articles = get_by_meta_key(md_directory, 'id', article_id)
|
|
||||||
|
|
||||||
if len(articles) == 0:
|
|
||||||
return Response(status=404)
|
|
||||||
if len(articles) > 1:
|
|
||||||
return Response(status=500)
|
|
||||||
|
|
||||||
the_article = articles[0]
|
|
||||||
return render_template('article.html', post=markdown(the_article.content),
|
|
||||||
metadata=the_article.metadata,
|
|
||||||
page_title=f'{the_article.metadata["title"]} - ')
|
|
||||||
|
|
||||||
@app.route('/projects/image/<image_name>')
|
|
||||||
def image(image_name: str) -> Response:
|
|
||||||
''' Resize and return an image. '''
|
|
||||||
w = int(request.args.get('w', 0))
|
|
||||||
h = int(request.args.get('h', 0))
|
|
||||||
|
|
||||||
if w == 0 and h == 0:
|
|
||||||
return send_from_directory(md_directory, path.join('images', image_name))
|
|
||||||
try:
|
|
||||||
the_image = Image.open(path.join(md_directory, 'images', image_name))
|
|
||||||
except FileNotFoundError:
|
|
||||||
return Response(status=404)
|
|
||||||
except UnidentifiedImageError:
|
|
||||||
return send_from_directory(md_directory, path.join('images', image_name))
|
|
||||||
|
|
||||||
max_width, max_height = the_image.size
|
|
||||||
|
|
||||||
if (w >= max_width and h >= max_height):
|
|
||||||
return send_from_directory(md_directory, path.join('images', image_name))
|
|
||||||
|
|
||||||
req_size = [max_width, max_height]
|
|
||||||
if w > 0:
|
|
||||||
req_size[0] = w
|
|
||||||
if h > 0:
|
|
||||||
req_size[1] = h
|
|
||||||
|
|
||||||
resized_img = BytesIO()
|
|
||||||
the_image.thumbnail(tuple(req_size))
|
|
||||||
the_image.save(resized_img, format=the_image.format)
|
|
||||||
|
|
||||||
response = make_response(resized_img.getvalue())
|
|
||||||
response.headers.set('Content-Type', f'image/{the_image.format}')
|
|
||||||
return response
|
|
||||||
+4
-1
@@ -3,4 +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__':
|
||||||
|
application.run(debug=True)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
setuptools>=78.1.1
|
||||||
flask>=2.2.3
|
flask>=2.2.3
|
||||||
flask-markdown>=0.3
|
flask-markdown>=0.3
|
||||||
markdown>=3.4.1
|
markdown>=3.4.1
|
||||||
@@ -5,3 +6,4 @@ beautifulsoup4>=4.11.1
|
|||||||
python-frontmatter>=1.1.0
|
python-frontmatter>=1.1.0
|
||||||
requests>=2.32.3
|
requests>=2.32.3
|
||||||
pillow>=11.0.0
|
pillow>=11.0.0
|
||||||
|
mod_wsgi>=5.0.2
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://jakecharman.co.uk/sitemap.xml
|
||||||
@@ -1,22 +1,25 @@
|
|||||||
@media (min-width: 1000px) {
|
@media (min-width: 1000px) {
|
||||||
#technology{
|
#technology{
|
||||||
background-position-x: 35vw;
|
background-position-x: 35vw;
|
||||||
height: 75vh;
|
height: fit-content;
|
||||||
min-height: 75vh;
|
min-height: 75vh;
|
||||||
|
background-position-y: 780px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#motorsport{
|
#motorsport{
|
||||||
background-position-y: -180px;
|
background-position-y: -180px;
|
||||||
background-position-x: -200px;
|
background-position-x: -200px;
|
||||||
background-size: auto;
|
background-size: auto;
|
||||||
|
height: fit-content;
|
||||||
min-height: 75vh;
|
min-height: 75vh;
|
||||||
height: 75vh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.text{
|
.text{
|
||||||
width: 50%;
|
width: 50%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
height: fit-content;
|
||||||
|
min-height: 75vh
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-right{
|
.text-right{
|
||||||
@@ -29,6 +32,13 @@
|
|||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gradient {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
background-image: linear-gradient(to top, rgba(23, 22, 20, 1) 70%, rgba(23, 22, 20, 0));
|
||||||
|
min-height: 75vh;
|
||||||
|
}
|
||||||
|
|
||||||
.gradient-left{
|
.gradient-left{
|
||||||
background-image: linear-gradient(to right, rgba(23, 22, 20, 1), rgba(23, 22, 20, 1), rgba(23, 22, 20, 0));
|
background-image: linear-gradient(to right, rgba(23, 22, 20, 1), rgba(23, 22, 20, 1), rgba(23, 22, 20, 0));
|
||||||
}
|
}
|
||||||
@@ -38,7 +48,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#projects{
|
#projects{
|
||||||
align-items: center;
|
align-items: stretch;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -48,7 +58,6 @@
|
|||||||
|
|
||||||
.project{
|
.project{
|
||||||
width: 20vw;
|
width: 20vw;
|
||||||
height: 25vw;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#top-nav{
|
#top-nav{
|
||||||
@@ -67,4 +76,56 @@
|
|||||||
#article>p>img{
|
#article>p>img{
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
#article>video{
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
#certs>a {
|
||||||
|
max-width: 15%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#techlogos {
|
||||||
|
max-height: 40%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#techlogos>a{
|
||||||
|
max-width: 5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt {
|
||||||
|
width: calc(66% - 40px);
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery {
|
||||||
|
display: flex;
|
||||||
|
height: fit-content;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery * {
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery>img {
|
||||||
|
width: calc(33% - 40px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#links {
|
||||||
|
padding-left: 20px;
|
||||||
|
padding-right: 20px;
|
||||||
|
}
|
||||||
|
.link>a>h3 {
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-gallery>img {
|
||||||
|
width: 20vw;
|
||||||
|
padding: 10px;
|
||||||
|
height: 14vw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+134
-10
@@ -13,17 +13,22 @@ h1, h2, h3{
|
|||||||
}
|
}
|
||||||
|
|
||||||
header{
|
header{
|
||||||
background-color: 4c4c4c;
|
background-color: #4c4c4c;
|
||||||
height: 25vh;
|
height: 25vh;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
main{
|
||||||
|
min-height: 65vh;
|
||||||
|
}
|
||||||
|
|
||||||
footer{
|
footer{
|
||||||
background-color: 4c4c4c;
|
background-color: #4c4c4c;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 10vh;
|
height: 10vh;
|
||||||
|
clear: both;
|
||||||
}
|
}
|
||||||
|
|
||||||
#logo-container{
|
#logo-container{
|
||||||
@@ -48,33 +53,32 @@ footer h2, section h2{
|
|||||||
|
|
||||||
#technology{
|
#technology{
|
||||||
background-image: url(../images/njr-code.png);
|
background-image: url(../images/njr-code.png);
|
||||||
height: 100vh;
|
height: fit-content;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
background-position-x: -400px;
|
background-position-x: -400px;
|
||||||
|
background-position-y: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#motorsport{
|
#motorsport{
|
||||||
background-image: url(../images/topfuel_startline.jpg.jpeg);
|
background-image: url(../images/topfuel_startline.jpg.jpeg);
|
||||||
height: 100vh;
|
height: fit-content;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
background-position-x: -65px;
|
background-position-x: -65px;
|
||||||
background-position-y: -90px;
|
background-position-y: -100px;
|
||||||
background-size: 200%;
|
background-size: 200%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gradient{
|
.gradient{
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: fit-content;
|
||||||
background-image: linear-gradient(to top, rgba(23, 22, 20, 1) 70%, rgba(23, 22, 20, 0));
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.text{
|
.text{
|
||||||
height: 70%;
|
height: fit-content;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
background-color: rgba(23, 22, 20, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.text>div{
|
.text>div{
|
||||||
@@ -209,10 +213,130 @@ label{
|
|||||||
|
|
||||||
#article{
|
#article{
|
||||||
padding: 0 10px 0 10px;
|
padding: 0 10px 0 10px;
|
||||||
|
margin-bottom: 25px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#article>p>img{
|
#article>p>img{
|
||||||
display: block;
|
display: block;
|
||||||
margin: 0 auto 0 auto;
|
margin: 0 auto 0 auto;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
max-height: 50vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
#article>video{
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto 0 auto;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 50vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre{
|
||||||
|
overflow-x: auto;
|
||||||
|
background-color: #36332f;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#certs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#certs>a {
|
||||||
|
max-width: 25%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#certs>a>img {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#techlogos {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
max-height:30%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#techlogos>a {
|
||||||
|
max-width: 10%;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#techlogos>a>img {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
height: 30vh;
|
||||||
|
width: 100%;
|
||||||
|
background-image: linear-gradient(to top, rgba(23, 22, 20, 1) 10%, rgba(23, 22, 20, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt {
|
||||||
|
width: 100%;
|
||||||
|
height: 22vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery * {
|
||||||
|
padding: 10px;
|
||||||
|
max-width: 80vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
#links-main{
|
||||||
|
padding: 20px 10px 20px 10px;
|
||||||
|
min-height: 65vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
border-top: 1px solid #e5e5e5;
|
||||||
|
clear: both;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link>img {
|
||||||
|
float: left;
|
||||||
|
padding-right: 20px;
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link>a>h3 {
|
||||||
|
padding-top: 10px;
|
||||||
|
margin: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
.category {
|
||||||
|
padding-top: 10px;
|
||||||
|
border-bottom: 1px solid #e5e5e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category>h3 {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-gallery {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-gallery>img {
|
||||||
|
width: 45vw;
|
||||||
|
padding: 5px;
|
||||||
|
height: 31vw;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
#comments {
|
||||||
|
border-top: 1px solid #e5e5e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment {
|
||||||
|
background-color: #4c4c4c;
|
||||||
|
margin: 10px;
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{% include 'header.html' %}
|
|
||||||
<main>
|
|
||||||
<section id="article">
|
|
||||||
<h1>{{ metadata.title}} </h1>
|
|
||||||
<p>{{ metadata.date | human_date }}</p>
|
|
||||||
<hr />
|
|
||||||
{{post|safe}}
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
{% include 'footer.html' %}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<footer>
|
|
||||||
<p>© <span id="cr-year"></span> Jake Charman. This site uses cookies.</p>
|
|
||||||
</footer>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
{% include 'header.html' %}
|
|
||||||
<main>
|
|
||||||
<section id="technology">
|
|
||||||
<div class="gradient gradient-left">
|
|
||||||
<div class="text text-left">
|
|
||||||
<div>
|
|
||||||
<h2>Technology</h2>
|
|
||||||
<hr>
|
|
||||||
<p>Working with technology is my day job, I currently specialise in:</p>
|
|
||||||
<ul>
|
|
||||||
<li>Linux (primarily RHEL & Debian based)</li>
|
|
||||||
<li>SCM with Git and Subversion</li>
|
|
||||||
<li>Big Data (Hadoop & Cloud storage)</li>
|
|
||||||
<li>Programming (Python, Bash & C#)</li>
|
|
||||||
</ul>
|
|
||||||
<p>I also run some services for personal use and occasionally write software. I may write about some of the services I run in the future. For now, my code can be found on <a href="https://github.com/jcharman">GitHub</a>.</p>
|
|
||||||
<div class="social">
|
|
||||||
<a class="button" href="https://www.linkedin.com/in/jakecharman/"><i class="fa-brands fa-linkedin-in"></i></a>
|
|
||||||
<a class="button" href="https://github.com/jcharman/"><i class="fa-brands fa-github"></i></a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section id="motorsport">
|
|
||||||
<div class="gradient gradient-right">
|
|
||||||
<div class="text text-right">
|
|
||||||
<div>
|
|
||||||
<h2>Racing</h2>
|
|
||||||
<hr />
|
|
||||||
<p>When not working on tech, I can usually be found at <a href="https://santapod.com">Santa Pod Raceway</a> working on my Dad's <a href="https://nitrojunkie.uk">NitroJunkie.UK</a> "All In" Top Fuel Bike. The bike is purpose built from the ground up with a supercharged, nitromethane injected engine capable of producing approximately 1000 horsepower. </p>
|
|
||||||
<p>The photo for this section was taken by <a href="https://www.facebook.com/BlackettPhotography">Blackett Photography</a></p>
|
|
||||||
<p>You can read more about our racing at <a href="https://nitrojunkie.uk">nitrojunkie.uk</a></p>
|
|
||||||
<div class="social">
|
|
||||||
<a class="button" href="https://nitrojunkie.uk"><i class="fa-solid fa-globe"></i></a>
|
|
||||||
<a class="button" href="https://www.youtube.com/@NitroJunkieUK"><i class="fa-brands fa-youtube"></i></a>
|
|
||||||
<a class="button" href="https://www.facebook.com/nitrojunkie.uk"><i class="fa-brands fa-facebook"></i></a>
|
|
||||||
<a class="button" href="https://instagr.am/nitrojunkieuk"><i class="fa-brands fa-instagram"></i></a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
{% include 'footer.html' %}
|
|
||||||
Reference in New Issue
Block a user