Compare commits
98 Commits
projects
...
363497d354
| 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 | |||
| 624cf64c3b | |||
| 3fe4659377 | |||
| 12b14da970 | |||
| 0271538b80 | |||
| 2696e635e2 | |||
| 17e3296774 | |||
| fd07ddf976 | |||
| cb01a25c7d | |||
| 5c5330d93f | |||
| 52eb6a9c85 | |||
| 72e1f9d9bf | |||
| d347310dc4 | |||
| d38bea0cc2 | |||
| baabe90000 | |||
| 9d2f92060f | |||
| 49a291ba5a | |||
| 50de5ebd95 | |||
| 0f295956ca | |||
| 03f2ffb1f2 | |||
| ea9e8825c3 | |||
| 643370f4f8 | |||
| 460c511de7 | |||
| 63a0fa1b22 | |||
| 1d42d1efa6 | |||
| ed4bf51c1c | |||
| f4999f4d19 | |||
| 1805d0531b | |||
| ff3d5ef48d | |||
| da1210d682 | |||
| bd1488c78a | |||
| 89ef938af4 | |||
| ca3825660b | |||
| 50501a3d37 | |||
| 613c906626 | |||
| b91245c8a1 | |||
| 56e771c454 | |||
| faa1c5437b | |||
| 66c8b16018 | |||
| 856528fbc4 | |||
| 781be0bab1 |
1
.gitattributes
vendored
Normal file → Executable file
1
.gitattributes
vendored
Normal file → Executable file
@@ -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
|
||||||
|
|||||||
3
.gitignore
vendored
Executable file
3
.gitignore
vendored
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
__pycache__
|
||||||
|
.vscode
|
||||||
|
.buildinfo.json
|
||||||
5
.pylintrc
Executable file
5
.pylintrc
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
[FORMAT]
|
||||||
|
max-line-length=140
|
||||||
|
|
||||||
|
[MESSAGES CONTROL]
|
||||||
|
disable=import-error
|
||||||
13
Dockerfile
Normal file → Executable file
13
Dockerfile
Normal file → Executable file
@@ -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"]
|
||||||
154
Jenkinsfile
vendored
Executable file
154
Jenkinsfile
vendored
Executable file
@@ -0,0 +1,154 @@
|
|||||||
|
pipeline {
|
||||||
|
agent any
|
||||||
|
|
||||||
|
environment {
|
||||||
|
TS = credentials('jc_turnstile')
|
||||||
|
DISCORD = credentials('jc_discord')
|
||||||
|
DISCORD_ERR_STAGING = credentials('jc_discord_err_staging')
|
||||||
|
DISCORD_ERR_PROD = credentials('jc_discord_err_prod')
|
||||||
|
}
|
||||||
|
|
||||||
|
stages{
|
||||||
|
stage ('Set up parameters') {
|
||||||
|
steps {
|
||||||
|
script{
|
||||||
|
properties ([
|
||||||
|
parameters([
|
||||||
|
booleanParam(defaultValue: true, description: 'Build from source', name: 'Build'),
|
||||||
|
booleanParam(defaultValue: true, description: 'Deploy to servers', name: 'Deploy'),
|
||||||
|
booleanParam(defaultValue: true, description: 'Update posts', name: 'Update'),
|
||||||
|
])
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stage('Build') {
|
||||||
|
when {
|
||||||
|
expression {
|
||||||
|
return params.Build == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
steps {
|
||||||
|
git branch: 'master',
|
||||||
|
credentialsId: 'Git',
|
||||||
|
url: 'git@git.jakecharman.co.uk:jake/jc-ng.git'
|
||||||
|
|
||||||
|
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('Security scan') {
|
||||||
|
when {
|
||||||
|
expression {
|
||||||
|
return params.Build == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
steps {
|
||||||
|
catchError(buildResult: 'SUCCESS', stageResult: 'FAILURE') {
|
||||||
|
sh "docker kill sectest || true"
|
||||||
|
sh "docker rm sectest || true"
|
||||||
|
sh "docker run -d --name sectest git.jakecharman.co.uk/jake/jakecharman.co.uk:$BUILD_NUMBER"
|
||||||
|
sh "docker exec sectest pip3 install pip-audit --break-system-packages"
|
||||||
|
sh "docker exec sectest pip-audit"
|
||||||
|
sh "docker stop sectest"
|
||||||
|
sh "docker rm sectest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Push to local registry') {
|
||||||
|
when {
|
||||||
|
expression {
|
||||||
|
return params.Build == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
steps {
|
||||||
|
sh "docker push git.jakecharman.co.uk/jake/jakecharman.co.uk:$BUILD_NUMBER"
|
||||||
|
sh "docker push git.jakecharman.co.uk/jake/jakecharman.co.uk:latest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Deploy to staging server') {
|
||||||
|
when {
|
||||||
|
expression {
|
||||||
|
return params.Deploy == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
steps{
|
||||||
|
node('web-staging') {
|
||||||
|
sh "docker pull git.jakecharman.co.uk/jake/jakecharman.co.uk:latest"
|
||||||
|
sh "docker stop jake || true"
|
||||||
|
sh "docker rm jake || true"
|
||||||
|
sh "docker run --name jake -e DISCORD_ERR_HOOK=$DISCORD_ERR_STAGING -e DISCORD_WEBHOOK=$DISCORD -e TURNSTILE_SECRET=$TS --restart always --network containers_default -v /opt/containers/jc/projects/:/var/www/jc/projects/ -d git.jakecharman.co.uk/jake/jakecharman.co.uk:latest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Update content on staging server') {
|
||||||
|
when {
|
||||||
|
expression {
|
||||||
|
return params.Update == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
steps {
|
||||||
|
node('web-staging') {
|
||||||
|
git branch: 'master',
|
||||||
|
credentialsId: 'Git',
|
||||||
|
url: 'git@git.jakecharman.co.uk:jake/jc-content.git'
|
||||||
|
sh "rsync -rv --delete ./ /opt/containers/jc/projects/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Wait for confirmation to push to prod') {
|
||||||
|
steps {
|
||||||
|
timeout(time: 30, unit: 'MINUTES') {
|
||||||
|
input "Deploy to production?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Push to GCP registry') {
|
||||||
|
when {
|
||||||
|
expression {
|
||||||
|
return params.Build == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
steps {
|
||||||
|
sh "docker push europe-west2-docker.pkg.dev/jakecharman/web/jakecharman.co.uk:latest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Deploy to production') {
|
||||||
|
when {
|
||||||
|
expression {
|
||||||
|
return params.Deploy == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
steps{
|
||||||
|
sh "gcloud run deploy --project jakecharman --region europe-west1 --image europe-west2-docker.pkg.dev/jakecharman/web/jakecharman.co.uk:latest jakecharman-co-uk"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Update content on production server') {
|
||||||
|
when {
|
||||||
|
expression {
|
||||||
|
return params.Update == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
steps {
|
||||||
|
git branch: 'master',
|
||||||
|
credentialsId: 'Git',
|
||||||
|
url: 'git@git.jakecharman.co.uk:jake/jc-content.git'
|
||||||
|
sh "gsutil rsync -rcd . gs://jakecharman.co.uk"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Clear cache') {
|
||||||
|
steps{
|
||||||
|
sh "/var/lib/jenkins/clearCFCache/clearCache.py a514fb61e1413b88aabbb19df16b8508"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
build.sh
Executable file
20
build.sh
Executable file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/bash --noprofile
|
||||||
|
|
||||||
|
set -x -o pipefail
|
||||||
|
|
||||||
|
tag=$1
|
||||||
|
build=$2
|
||||||
|
|
||||||
|
cat <<EOF >src/.buildinfo.json
|
||||||
|
{
|
||||||
|
"tag": "${tag}:${build}",
|
||||||
|
"date": "$(date -I)",
|
||||||
|
"host": "$(hostname -f)",
|
||||||
|
"user": "${USER}"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
docker build -t ${tag}:latest .
|
||||||
|
if [[ $build != "" ]]; then
|
||||||
|
docker build -t ${tag}:${build} .
|
||||||
|
fi
|
||||||
22
config/httpd.conf
Normal file → Executable file
22
config/httpd.conf
Normal file → Executable file
@@ -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>
|
||||||
|
|||||||
9
debug_local.sh
Executable file
9
debug_local.sh
Executable file
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/bash --noprofile
|
||||||
|
|
||||||
|
run() {
|
||||||
|
python3 src/projects.wsgi || run
|
||||||
|
}
|
||||||
|
|
||||||
|
export DISCORD_ERR_HOOK='dummy'
|
||||||
|
run
|
||||||
|
unset DISCORD_ERR_HOOK
|
||||||
12
run_local.sh
Executable file
12
run_local.sh
Executable file
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/bash --noprofile
|
||||||
|
|
||||||
|
set -x -o pipefail
|
||||||
|
|
||||||
|
./build.sh "jc-ng-localtest"
|
||||||
|
|
||||||
|
content_dir=""
|
||||||
|
if [[ -d "../jc-content" ]]; then
|
||||||
|
content_dir="-v $(realpath ../jc-content):/var/www/jc/projects"
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker run -e DISCORD_ERR_HOOK=dummy $1 -v $(pwd)/src/:/var/www/jc $content_dir jc-ng-localtest
|
||||||
36
src/index.py
36
src/index.py
@@ -1,36 +0,0 @@
|
|||||||
#!/usr/bin/python3
|
|
||||||
|
|
||||||
from flask import Flask, render_template, Response
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
|
||||||
|
|
||||||
import projects
|
|
||||||
|
|
||||||
@app.route('/')
|
|
||||||
def index():
|
|
||||||
return render_template('index.html')
|
|
||||||
|
|
||||||
@app.route('/error/<code>')
|
|
||||||
def error(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.'
|
|
||||||
}
|
|
||||||
|
|
||||||
return render_template('error.html',
|
|
||||||
error=f'{code}: {error_definitions.get(int(code))}',
|
|
||||||
description=error_desc.get(int(code)))
|
|
||||||
217
src/jakecharman/__init__.py
Normal file
217
src/jakecharman/__init__.py
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
import traceback
|
||||||
|
from os import environ, path
|
||||||
|
import threading
|
||||||
|
import logging
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
from re import match
|
||||||
|
import json
|
||||||
|
from io import BytesIO
|
||||||
|
from requests import post
|
||||||
|
from flask import Flask, render_template, Response, url_for, request, send_from_directory, make_response
|
||||||
|
from PIL import Image, UnidentifiedImageError
|
||||||
|
from .content import ContentArea
|
||||||
|
from .contact import ContactForm
|
||||||
|
from .storage import LocalStorage
|
||||||
|
from .links import Links
|
||||||
|
from .comments import *
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
md_path = path.join(path.realpath(path.dirname(__file__)), path.normpath('../projects/'))
|
||||||
|
|
||||||
|
projects = ContentArea(
|
||||||
|
directory=LocalStorage(md_path),
|
||||||
|
name='projects',
|
||||||
|
import_name=__name__)
|
||||||
|
|
||||||
|
app.register_blueprint(projects, url_prefix='/projects')
|
||||||
|
app.register_blueprint(ContactForm('contact', __name__), url_prefix='/contact')
|
||||||
|
app.register_blueprint(Links(path.join(md_path, 'links.json'), 'links', __name__), url_prefix='/links')
|
||||||
|
app.register_blueprint(Approval(path.join(projects.md_directory.uri, 'comments.db'), 'comments', __name__), url_prefix='/comments')
|
||||||
|
|
||||||
|
class DiscordLogger(logging.Handler):
|
||||||
|
''' Simple logging handler to send a message to Discord '''
|
||||||
|
|
||||||
|
level = logging.ERROR
|
||||||
|
|
||||||
|
def __init__(self, webhook):
|
||||||
|
super().__init__()
|
||||||
|
self._webhook = webhook
|
||||||
|
|
||||||
|
def send_to_discord(self, msg: logging.LogRecord):
|
||||||
|
''' Send the message '''
|
||||||
|
if msg.exc_info is not None:
|
||||||
|
message_to_send = f'{msg.msg}\n\n{"".join(traceback.format_exception(*msg.exc_info))}'
|
||||||
|
else:
|
||||||
|
message_to_send = msg.msg
|
||||||
|
if len(message_to_send) > 2000:
|
||||||
|
chars_to_lose = len(message_to_send) - 2000
|
||||||
|
if msg.exc_info is not None:
|
||||||
|
message_to_send = f'{msg.msg}\n\n{"".join(traceback.format_exception(*msg.exc_info))[-chars_to_lose:]}'
|
||||||
|
else:
|
||||||
|
message_to_send = msg.msg[-chars_to_lose:]
|
||||||
|
post(self._webhook, data={'content': message_to_send}, timeout=30)
|
||||||
|
|
||||||
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
|
''' Take in the record and start a new thread to send it to Discord '''
|
||||||
|
app.logger.info('Sending error to Discord')
|
||||||
|
dc_thread = threading.Thread(target=self.send_to_discord, args=[record])
|
||||||
|
dc_thread.start()
|
||||||
|
|
||||||
|
discord_logger = DiscordLogger(environ['DISCORD_ERR_HOOK'])
|
||||||
|
app.logger.addHandler(discord_logger)
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def inject_branding() -> dict:
|
||||||
|
''' Modify branding depending on the URL being used '''
|
||||||
|
req_domain = urlsplit(request.base_url).netloc.lower()
|
||||||
|
match req_domain:
|
||||||
|
case 'jakecharman.co.uk':
|
||||||
|
brand = 'Jake Charman'
|
||||||
|
case _:
|
||||||
|
brand = req_domain
|
||||||
|
|
||||||
|
return {'branding': brand}
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index() -> str:
|
||||||
|
''' Load the homepage '''
|
||||||
|
return render_template('index.html')
|
||||||
|
|
||||||
|
@app.route('/error/<code>')
|
||||||
|
def error(code) -> str:
|
||||||
|
''' Render a nicer error page for a given code '''
|
||||||
|
|
||||||
|
error_definitions = {
|
||||||
|
400: 'Bad Request',
|
||||||
|
403: 'Forbidden',
|
||||||
|
404: 'Page Not Found',
|
||||||
|
418: 'I\'m a Teapot',
|
||||||
|
500: 'Internal Server Error',
|
||||||
|
503: 'Service Temporarily Unavailable',
|
||||||
|
505: 'HTTP Version Not Supported'
|
||||||
|
}
|
||||||
|
error_desc = {
|
||||||
|
400: 'Sorry, I didn\'t understand your request.',
|
||||||
|
403: 'Sorry, you aren\'t allowed to view this page.',
|
||||||
|
404: 'Sorry, that page doesn\'t exist.',
|
||||||
|
418: 'I can\'t brew coffee as I am, in fact, a teapot.',
|
||||||
|
500: 'Something went wrong on my end.',
|
||||||
|
503: 'My website is experiencing some issues and will be back shortly.',
|
||||||
|
505: 'Your browser tried to use a HTTP version I don\'t support. Check it is up to date.'
|
||||||
|
}
|
||||||
|
|
||||||
|
if not code.isdigit():
|
||||||
|
code=400
|
||||||
|
elif int(code) not in error_definitions:
|
||||||
|
return Response(status=code)
|
||||||
|
|
||||||
|
return render_template('error.html',
|
||||||
|
error=f'{code}: {error_definitions.get(int(code))}',
|
||||||
|
description=error_desc.get(int(code)))
|
||||||
|
|
||||||
|
def get_routes() -> list:
|
||||||
|
''' Get a list of all routes that make up the app '''
|
||||||
|
routes = []
|
||||||
|
for rule in app.url_map.iter_rules():
|
||||||
|
if 0 >= len(rule.arguments):
|
||||||
|
url = url_for(rule.endpoint, **(rule.defaults or {}))
|
||||||
|
routes.append(url)
|
||||||
|
return routes
|
||||||
|
|
||||||
|
def get_build_date():
|
||||||
|
''' Get the build date of the Docker container we're running in '''
|
||||||
|
try:
|
||||||
|
with open('/var/www/jc/.buildinfo.json', encoding='utf8') as build:
|
||||||
|
build_json = json.load(build)
|
||||||
|
return build_json['date']
|
||||||
|
except Exception: # pylint: disable=broad-exception-caught
|
||||||
|
return '1970-01-01'
|
||||||
|
|
||||||
|
@app.route('/sitemap.xml')
|
||||||
|
def sitemap():
|
||||||
|
''' Return an XML site map '''
|
||||||
|
date = get_build_date()
|
||||||
|
root = ET.Element('urlset', xmlns='http://www.sitemaps.org/schemas/sitemap/0.9')
|
||||||
|
base_url = match(r'^https?:\/\/.+:?\d*(?=\/)', request.base_url).group()
|
||||||
|
base_url = base_url.replace('http://', 'https://')
|
||||||
|
for route in get_routes():
|
||||||
|
url = ET.SubElement(root, 'url')
|
||||||
|
ET.SubElement(url, 'loc').text = base_url + route
|
||||||
|
ET.SubElement(url, 'lastmod').text = date
|
||||||
|
for article in projects.get_live_posts():
|
||||||
|
if 'link' in article.metadata:
|
||||||
|
continue
|
||||||
|
url = ET.SubElement(root, 'url')
|
||||||
|
ET.SubElement(url, 'loc').text = f'{base_url}/projects/{article.metadata['id']}'
|
||||||
|
ET.SubElement(url, 'lastmod').text = article.metadata['date'].strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
return Response(ET.tostring(root, encoding='utf-8'), 200, {'content-type': 'application/xml'})
|
||||||
|
|
||||||
|
def resize_image(image_name: str, size: tuple):
|
||||||
|
w = size[0]
|
||||||
|
h = size[1]
|
||||||
|
|
||||||
|
md_directory = LocalStorage(md_path)
|
||||||
|
the_image = Image.open(path.join(md_directory.uri, 'images', image_name))
|
||||||
|
max_width, max_height = the_image.size
|
||||||
|
|
||||||
|
if path.exists(path.join(md_directory.uri, 'images', f'{w}-{h}-{image_name}')) or (w >= max_width and h >= max_height):
|
||||||
|
raise FileExistsError()
|
||||||
|
|
||||||
|
req_size = [max_width, max_height]
|
||||||
|
if w > 0:
|
||||||
|
req_size[0] = w
|
||||||
|
if h > 0:
|
||||||
|
req_size[1] = h
|
||||||
|
|
||||||
|
resized_img = BytesIO()
|
||||||
|
the_image.thumbnail(tuple(req_size))
|
||||||
|
the_image.save(resized_img, format=the_image.format)
|
||||||
|
the_image.save(path.join(md_directory.uri, 'images', f'{w}-{h}-{image_name}'), the_image.format)
|
||||||
|
|
||||||
|
@app.route('/image/<image_name>')
|
||||||
|
def image( image_name: str) -> Response:
|
||||||
|
''' Resize and return an image. '''
|
||||||
|
md_directory = LocalStorage(md_path)
|
||||||
|
|
||||||
|
w = int(request.args.get('w', 0))
|
||||||
|
h = int(request.args.get('h', 0))
|
||||||
|
|
||||||
|
if w == 0 and h == 0:
|
||||||
|
return send_from_directory(md_directory.uri, path.join('images', image_name))
|
||||||
|
if path.exists(path.join(md_directory.uri, 'images', f'{w}-{h}-{image_name}')):
|
||||||
|
return send_from_directory(md_directory.uri, path.join('images', f'{w}-{h}-{image_name}'))
|
||||||
|
try:
|
||||||
|
resize_image(image_name, (w, h))
|
||||||
|
except FileNotFoundError:
|
||||||
|
return Response(status=404)
|
||||||
|
except UnidentifiedImageError:
|
||||||
|
return send_from_directory(md_directory.uri, path.join('images', image_name))
|
||||||
|
except FileExistsError:
|
||||||
|
return send_from_directory(md_directory.uri, path.join('images', image_name))
|
||||||
|
|
||||||
|
return send_from_directory(md_directory.uri, path.join('images', f'{w}-{h}-{image_name}'))
|
||||||
|
|
||||||
|
@app.route('/image/thumb/<image_name>')
|
||||||
|
def img_thumb(image_name: str):
|
||||||
|
''' Flask route to load an image '''
|
||||||
|
md_directory = LocalStorage(md_path)
|
||||||
|
w = 400
|
||||||
|
h = 0
|
||||||
|
thumb_file = path.join(md_directory.uri, 'images', f'{w}-{h}-{image_name}')
|
||||||
|
if path.exists(thumb_file):
|
||||||
|
return send_from_directory(md_directory.uri, path.join('images', f'{w}-{h}-{image_name}'))
|
||||||
|
try:
|
||||||
|
resize_image(image_name, (w, h))
|
||||||
|
except FileNotFoundError:
|
||||||
|
return Response(status=404)
|
||||||
|
except UnidentifiedImageError:
|
||||||
|
return send_from_directory(md_directory.uri, path.join('images', image_name))
|
||||||
|
except FileExistsError:
|
||||||
|
return send_from_directory(md_directory.uri, path.join('images', f'{w}-{h}-{image_name}'))
|
||||||
|
|
||||||
|
return send_from_directory(md_directory.uri, path.join('images', f'{w}-{h}-{image_name}'))
|
||||||
60
src/jakecharman/comments.py
Normal file
60
src/jakecharman/comments.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from flask import Blueprint, Response, request
|
||||||
|
from uuid import uuid4
|
||||||
|
from requests import post
|
||||||
|
from os import environ
|
||||||
|
|
||||||
|
|
||||||
|
class PostComments():
|
||||||
|
def __init__(self, post_id: str, db_path: str):
|
||||||
|
self.__db_path = db_path
|
||||||
|
self.__post_id = post_id
|
||||||
|
self._webhook = environ['DISCORD_WEBHOOK']
|
||||||
|
with sqlite3.connect(db_path) as db:
|
||||||
|
cursor = db.cursor()
|
||||||
|
cursor.execute("CREATE TABLE IF NOT EXISTS comments (name TEXT, comment TEXT, date INT, post_id TEXT, approved BOOL, key TEXT)")
|
||||||
|
self.comments = cursor.execute(
|
||||||
|
"SELECT name, comment, date FROM comments WHERE approved = 1 AND post_id = ? ORDER BY date DESC",
|
||||||
|
(post_id,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
def send_to_discord(self, name: str, comment: str, comment_id: int, key: str):
|
||||||
|
''' Send the message '''
|
||||||
|
message_to_send = f'New comment from {name}\n\n{comment}'
|
||||||
|
if len(message_to_send) > 2000:
|
||||||
|
chars_to_lose = len(message_to_send) - 1900
|
||||||
|
message_to_send = message_to_send[-chars_to_lose:]
|
||||||
|
message_to_send += f'\n\n[Approve](https://jakecharman.co.uk/comments/approve/{comment_id}?key={key})'
|
||||||
|
post(self._webhook, data={'content': message_to_send}, timeout=30)
|
||||||
|
|
||||||
|
def make_comment(self, name: str, comment: str):
|
||||||
|
with sqlite3.connect(self.__db_path) as db:
|
||||||
|
key = str(uuid4())
|
||||||
|
cursor = db.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO comments (name, comment, date, post_id, approved, key) VALUES (?, ?, datetime('now'), ?, 0, ?)",
|
||||||
|
(name, comment, self.__post_id, key)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
self.send_to_discord(name, comment, cursor.lastrowid, key)
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
class Approval(Blueprint):
|
||||||
|
def __init__(self, db_path: str, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.__db_path = db_path
|
||||||
|
self.add_url_rule('/approve/<comment_id>', view_func=self.approve)
|
||||||
|
|
||||||
|
def approve(self, comment_id: str):
|
||||||
|
with sqlite3.connect(self.__db_path) as db:
|
||||||
|
cursor = db.cursor()
|
||||||
|
key = cursor.execute("SELECT key FROM comments WHERE rowid = ?", (comment_id,)).fetchone()[0]
|
||||||
|
if request.args.get('key') == key:
|
||||||
|
with sqlite3.connect(self.__db_path) as db:
|
||||||
|
cursor = db.cursor()
|
||||||
|
cursor.execute("UPDATE comments SET approved = 1 WHERE rowid = ?", (comment_id,))
|
||||||
|
db.commit()
|
||||||
|
return Response(status=200)
|
||||||
|
return Response(status=403)
|
||||||
70
src/jakecharman/contact.py
Executable file
70
src/jakecharman/contact.py
Executable file
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
from os import environ
|
||||||
|
from uuid import uuid4
|
||||||
|
from textwrap import dedent
|
||||||
|
from traceback import format_exc
|
||||||
|
from requests import post
|
||||||
|
from flask import request, render_template, Blueprint, current_app
|
||||||
|
|
||||||
|
class ContactForm(Blueprint):
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.add_url_rule('/', view_func=self.contact, methods=['GET', 'POST'])
|
||||||
|
|
||||||
|
def validate_turnstile(self, response: str, ip: str) -> bool:
|
||||||
|
turnstile_secret = environ['TURNSTILE_SECRET']
|
||||||
|
cf_response = post(
|
||||||
|
url='https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
||||||
|
data={
|
||||||
|
'secret': turnstile_secret,
|
||||||
|
'response': response,
|
||||||
|
'remoteip': ip,
|
||||||
|
'idempotency_key': uuid4()
|
||||||
|
},
|
||||||
|
timeout=30
|
||||||
|
).json()
|
||||||
|
|
||||||
|
return cf_response.get('success', False)
|
||||||
|
|
||||||
|
def send_to_discord(self, form: dict) -> bool:
|
||||||
|
try:
|
||||||
|
discord_hook = environ['DISCORD_WEBHOOK']
|
||||||
|
except KeyError:
|
||||||
|
current_app.logger.error(format_exc())
|
||||||
|
return False
|
||||||
|
discord_msg = dedent(
|
||||||
|
f'''
|
||||||
|
__**New Contact Form Response**__
|
||||||
|
|
||||||
|
**From:** {form.get('name')} <{form.get('email')}>
|
||||||
|
|
||||||
|
''')
|
||||||
|
if form.get("message") == '':
|
||||||
|
discord_msg += '*No Message*'
|
||||||
|
else:
|
||||||
|
discord_msg += f'>>> {form.get("message")}'
|
||||||
|
discord_response = post(
|
||||||
|
url=discord_hook,
|
||||||
|
data={
|
||||||
|
'username': form.get('name'),
|
||||||
|
'content': discord_msg
|
||||||
|
},
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
if discord_response.status_code == 204:
|
||||||
|
return True
|
||||||
|
current_app.logger.error(discord_response.status_code, discord_response.text)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def contact(self):
|
||||||
|
if request.method == 'POST':
|
||||||
|
if not self.validate_turnstile(request.form['cf-turnstile-response'], request.remote_addr):
|
||||||
|
return render_template('contact.html', error=True, user_message='You appear to be a robot.')
|
||||||
|
send_result = self.send_to_discord(request.form)
|
||||||
|
if send_result:
|
||||||
|
return render_template('contact.html', user_message='Your message has been sent!')
|
||||||
|
return render_template('contact.html', error=True, user_message='An error occurred.')
|
||||||
|
else:
|
||||||
|
return render_template('contact.html', page_title='Contact - ')
|
||||||
156
src/jakecharman/content.py
Executable file
156
src/jakecharman/content.py
Executable file
@@ -0,0 +1,156 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
from os import path
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
import frontmatter
|
||||||
|
from markdown import markdown
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from flask import render_template, Response, Blueprint, request, redirect
|
||||||
|
from .storage import LocalStorage
|
||||||
|
from .comments import PostComments, Approval
|
||||||
|
|
||||||
|
class ContentArea(Blueprint):
|
||||||
|
def __init__(self, directory: LocalStorage, *args, **kwargs):
|
||||||
|
self.md_directory = directory
|
||||||
|
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.add_app_template_filter(self.category_title, 'category_title')
|
||||||
|
self.add_app_template_filter(self.human_date, 'human_date')
|
||||||
|
self.add_app_template_filter(self.to_html, 'to_html')
|
||||||
|
|
||||||
|
self.context_processor(self.processor)
|
||||||
|
|
||||||
|
self.add_url_rule('/', view_func=self.projects)
|
||||||
|
self.add_url_rule('/category/<category_id>/', view_func=self.category)
|
||||||
|
self.add_url_rule('/<article_id>', view_func=self.article)
|
||||||
|
self.add_url_rule('/<article_id>/comment', view_func=self.comment, methods=['POST'])
|
||||||
|
|
||||||
|
def processor(self) -> dict:
|
||||||
|
''' Jninja processors '''
|
||||||
|
def get_excerpt(post: frontmatter.Post) -> str:
|
||||||
|
html = markdown(post.content)
|
||||||
|
post_soup = BeautifulSoup(html, 'html.parser')
|
||||||
|
all_text = ' '.join([x.get_text() for x in post_soup.findAll('p')])
|
||||||
|
return ' '.join(all_text.split()[:200])
|
||||||
|
return dict(get_excerpt=get_excerpt)
|
||||||
|
|
||||||
|
def category_title(self, category_id: str) -> str:
|
||||||
|
''' Jninja filter to get a category title by its ID '''
|
||||||
|
with self.md_directory.open('categories.json') as categories_file:
|
||||||
|
categories = json.load(categories_file)
|
||||||
|
|
||||||
|
return categories.get(category_id).get('title', '')
|
||||||
|
|
||||||
|
def human_date(self, iso_date: str) -> str:
|
||||||
|
''' Jninja filter to convert an ISO date to human readable. '''
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(str(iso_date)).strftime('%A %d %B %Y')
|
||||||
|
except ValueError:
|
||||||
|
return iso_date
|
||||||
|
|
||||||
|
def to_html(self, content: str) -> str:
|
||||||
|
''' Jninja filter to wrap markdown '''
|
||||||
|
return markdown(content)
|
||||||
|
|
||||||
|
def get_all_posts(self) -> list:
|
||||||
|
''' Get all posts in the posts directory '''
|
||||||
|
abs_paths = [x.path for x in self.md_directory.ls() if x.path.endswith('.md')]
|
||||||
|
posts = []
|
||||||
|
for p in abs_paths:
|
||||||
|
with self.md_directory.open(p) as f:
|
||||||
|
posts.append(frontmatter.load(f))
|
||||||
|
return posts
|
||||||
|
|
||||||
|
def get_live_posts(self) -> list:
|
||||||
|
''' Get all posts in the posts directory excluding ones with a date in the future '''
|
||||||
|
return [x for x in self.get_all_posts() if x.metadata.get('date') <= datetime.now().date()]
|
||||||
|
|
||||||
|
def get_by_meta_key(self, key: str, value: str) -> list:
|
||||||
|
''' Get posts by a metadata key value pair '''
|
||||||
|
return [x for x in self.get_live_posts() if x.get(key) == value or isinstance(x.get(key, []), list) and value in x.get(key, [])]
|
||||||
|
|
||||||
|
def projects(self) -> str:
|
||||||
|
''' Load the projects page '''
|
||||||
|
articles_to_return = sorted(
|
||||||
|
self.get_live_posts(),
|
||||||
|
key=lambda d: d.metadata.get('date'),
|
||||||
|
reverse=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(articles_to_return) < 1:
|
||||||
|
return render_template('error.html',
|
||||||
|
error='There\'s nothing here... yet.',
|
||||||
|
description='I\'m still working on this page. Check back soon for some content.')
|
||||||
|
try:
|
||||||
|
with self.md_directory.open('categories.json') as categories_file:
|
||||||
|
categories = json.load(categories_file)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return render_template('error.html',
|
||||||
|
error='There\'s nothing here... yet.',
|
||||||
|
description='I\'m still working on this page. Check back soon for some content.')
|
||||||
|
|
||||||
|
return render_template('projects.html',
|
||||||
|
articles=articles_to_return,
|
||||||
|
all_categories=categories,
|
||||||
|
title='Projects',
|
||||||
|
page_title='Projects - ',
|
||||||
|
description='A selection of projects I\'ve been involved in')
|
||||||
|
|
||||||
|
def category(self, category_id: str) -> str:
|
||||||
|
''' Load the page for a given category '''
|
||||||
|
try:
|
||||||
|
with self.md_directory.open('categories.json') as categories_file:
|
||||||
|
categories = json.load(categories_file)
|
||||||
|
the_category = categories.get(category_id)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return render_template('error.html',
|
||||||
|
error='There\'s nothing here... yet.',
|
||||||
|
description='I\'m still working on this page. Check back soon for some content.')
|
||||||
|
|
||||||
|
if the_category is None:
|
||||||
|
return Response(status=404)
|
||||||
|
|
||||||
|
articles_to_return = sorted(
|
||||||
|
self.get_by_meta_key(
|
||||||
|
'categories', category_id),
|
||||||
|
key=lambda d: d.metadata.get('date'),
|
||||||
|
reverse=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(articles_to_return) < 1:
|
||||||
|
return render_template('error.html',
|
||||||
|
error='There\'s nothing here... yet.',
|
||||||
|
description='I\'m still working on this page. Check back soon for some content.')
|
||||||
|
|
||||||
|
return render_template('projects.html', articles=articles_to_return,
|
||||||
|
title=the_category['title'],
|
||||||
|
description=the_category['long_description'],
|
||||||
|
page_title=f'{the_category["title"]} - ',
|
||||||
|
all_categories=categories,
|
||||||
|
current_category=category_id)
|
||||||
|
|
||||||
|
def article(self, article_id: str) -> str:
|
||||||
|
''' Load a single article '''
|
||||||
|
articles = self.get_by_meta_key('id', article_id)
|
||||||
|
|
||||||
|
if len(articles) == 0:
|
||||||
|
return Response(status=404)
|
||||||
|
if len(articles) > 1:
|
||||||
|
return Response(status=500)
|
||||||
|
|
||||||
|
the_article = articles[0]
|
||||||
|
|
||||||
|
comments = PostComments(the_article.metadata['id'], path.join(self.md_directory.uri, 'comments.db')).comments
|
||||||
|
return render_template('article.html', post=markdown(the_article.content),
|
||||||
|
metadata=the_article.metadata,
|
||||||
|
comments = comments,
|
||||||
|
page_title=f'{the_article.metadata["title"]} - ')
|
||||||
|
|
||||||
|
def comment(self, article_id: str):
|
||||||
|
PostComments(article_id, path.join(self.md_directory.uri, 'comments.db')).make_comment(
|
||||||
|
request.form['name'],
|
||||||
|
request.form['comment']
|
||||||
|
)
|
||||||
|
return redirect(f'/projects/{article_id}?comment=true#comments')
|
||||||
13
src/jakecharman/links.py
Normal file
13
src/jakecharman/links.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from flask import Blueprint, render_template
|
||||||
|
import json
|
||||||
|
|
||||||
|
class Links(Blueprint):
|
||||||
|
def __init__(self, file, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.add_url_rule('/', view_func=self.links)
|
||||||
|
self.file = file
|
||||||
|
|
||||||
|
def links(self):
|
||||||
|
with open(self.file, encoding='utf8') as file:
|
||||||
|
links = json.load(file)
|
||||||
|
return render_template('links.html', links=links, page_title='Useful Links - ')
|
||||||
28
src/jakecharman/storage.py
Normal file
28
src/jakecharman/storage.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
class File():
|
||||||
|
def __init__(self, storage: LocalStorage, path: str): # pylint: disable=used-before-assignment
|
||||||
|
self.path = path
|
||||||
|
self.storage = storage
|
||||||
|
|
||||||
|
def open(self, *args, **kwargs):
|
||||||
|
''' Open the file using the relevant storage '''
|
||||||
|
return(self.storage.open(self.path, *args, **kwargs))
|
||||||
|
|
||||||
|
class LocalStorage():
|
||||||
|
''' Class to represent a location on a local file system '''
|
||||||
|
def __init__(self, uri: str):
|
||||||
|
self.uri = uri
|
||||||
|
|
||||||
|
def ls(self, path: str = '') -> list[File]:
|
||||||
|
''' Return a listing of a directory '''
|
||||||
|
if path.startswith('/'):
|
||||||
|
path = path[1:]
|
||||||
|
fullpath = os.path.join(self.uri, path)
|
||||||
|
return [File(self, os.path.join(fullpath, x)) for x in os.listdir(fullpath)]
|
||||||
|
|
||||||
|
def open(self, path: str, *args, **kwargs):
|
||||||
|
''' Open a file '''
|
||||||
|
return open(os.path.join(self.uri, path), *args, **kwargs, encoding='utf8')
|
||||||
44
src/jakecharman/templates/article.html
Executable file
44
src/jakecharman/templates/article.html
Executable file
@@ -0,0 +1,44 @@
|
|||||||
|
{% extends 'main.html' %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
{% if metadata.gallery %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded",function(){
|
||||||
|
post_gallery = new Viewer(document.getElementById('article'), {
|
||||||
|
'url': 'realsrc',
|
||||||
|
'title': false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main>
|
||||||
|
<section id="article">
|
||||||
|
<h1>{{ metadata.title}} </h1>
|
||||||
|
<p>{{ metadata.date | human_date }}</p>
|
||||||
|
<hr />
|
||||||
|
{{post|safe}}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<section id="comments">
|
||||||
|
<h2>{{comments | length}} Comment{% if comments | length != 1 %}s{% endif %}</h2>
|
||||||
|
{% for comment in comments %}
|
||||||
|
<div class="comment">
|
||||||
|
<strong>{{ comment[0] }} - {{ comment[2] | human_date }}</strong>
|
||||||
|
<p>{{ comment[1] }}</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<h3>Leave a comment</h3>
|
||||||
|
{% if request.args.get('comment') is none %}
|
||||||
|
<form action="./{{metadata.id}}/comment" method="post">
|
||||||
|
<input type="text", name="name" placeholder="Name" required>
|
||||||
|
<textarea name="comment" placeholder="Comment" required></textarea>
|
||||||
|
<input type="submit" value="Submit">
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p>Thank you! Your comment will appear once it is approved</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
22
src/jakecharman/templates/contact.html
Executable file
22
src/jakecharman/templates/contact.html
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
{% extends 'main.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main id="contact-main">
|
||||||
|
<h2>Contact Me</h2>
|
||||||
|
<p>Got a question or want to talk about something on this site? Drop me a message below:</p>
|
||||||
|
|
||||||
|
<form action="#" method="post">
|
||||||
|
<label for="name">Name:</label>
|
||||||
|
<input type="text" name="name" required>
|
||||||
|
<label for="email">Email Address:</label>
|
||||||
|
<input type="email" name="email" required>
|
||||||
|
<label for="message">Message:</label>
|
||||||
|
<textarea name="message" rows="10"></textarea>
|
||||||
|
<div class="cf-turnstile" data-sitekey="0x4AAAAAAA45FeR26JuvqKy7"></div>
|
||||||
|
<input type="submit" name="submit" value="Submit">
|
||||||
|
</form>
|
||||||
|
{% if user_message is not none %}
|
||||||
|
<p id="{{ 'contact-error' if error else 'contact-message' }}">{{ user_message }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
6
src/templates/error.html → src/jakecharman/templates/error.html
Normal file → Executable file
6
src/templates/error.html → src/jakecharman/templates/error.html
Normal file → Executable file
@@ -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 %}
|
||||||
80
src/jakecharman/templates/index.html
Executable file
80
src/jakecharman/templates/index.html
Executable file
@@ -0,0 +1,80 @@
|
|||||||
|
{% extends 'main.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main>
|
||||||
|
<section id="technology">
|
||||||
|
<div class="gradient gradient-left">
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<div class="text text-left">
|
||||||
|
<div>
|
||||||
|
<h2>Technology</h2>
|
||||||
|
<hr>
|
||||||
|
<p>Technology is my day job. I design and build distributed systems for large companies around the world.</p>
|
||||||
|
<p>
|
||||||
|
I also work with technology in my spare time, building systems for <a href="#motorsport">Nitro Junkie</a>, and for me to use personally. For example, the current iteration of this website is written in Python and deployed via a Jenkins pipeline to Google Cloud Run as a Docker container.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Below are some of the technologies I'm using most at the moment.
|
||||||
|
My personal projects can also be found over on the <a href="/projects/">projects</a> page. Be aware though that if I'm able to write about a project here, it most likely wasn't done professionally so some may be a little rough around the edges.
|
||||||
|
</p>
|
||||||
|
<div id="techlogos">
|
||||||
|
<a href="https://ansible.com"><img src="/static/images/technology/ansible.png" /></a>
|
||||||
|
<a href="https://aws.amazon.com/"><img src="/static/images/technology/aws.png" /></a>
|
||||||
|
<a href="https://azure.microsoft.com/en-gb"><img src="/static/images/technology/azure.png" /></a>
|
||||||
|
<a href="https://dotnet.microsoft.com/en-us/languages/csharp"><img src="/static/images/technology/csharp.png" /></a>
|
||||||
|
<a href="https://www.debian.org/"><img src="/static/images/technology/debian.png" /></a>
|
||||||
|
<a href="https://www.digitalocean.com/"><img src="/static/images/technology/digitalocean.png" /></a>
|
||||||
|
<a href="https://www.docker.com/"><img src="/static/images/technology/docker.png" /></a>
|
||||||
|
<a href="https://www.freeipa.org/"><img src="/static/images/technology/freeipa.png" /></a>
|
||||||
|
<a href="https://cloud.google.com/"><img src="/static/images/technology/gcloud.png" /></a>
|
||||||
|
<a href="https://git-scm.com/"><img src="/static/images/technology/git.png" /></a>
|
||||||
|
<a href="https://www.grafana.com/"><img src="/static/images/technology/grafana.png" /></a>
|
||||||
|
<a href="https://hadoop.apache.org/"><img src="/static/images/technology/hadoop.svg" /></a>
|
||||||
|
<a href="https://hive.apache.org/"><img src="/static/images/technology/hive.svg" /></a>
|
||||||
|
<a href="https://www.java.com/"><img src="/static/images/technology/java.png" /></a>
|
||||||
|
<a href="https://www.jenkins.io/"><img src="/static/images/technology/jenkins.png" /></a>
|
||||||
|
<a href="https://mariadb.org/"><img src="/static/images/technology/mariadb.png" /></a>
|
||||||
|
<a href="https://nginx.org/"><img src="/static/images/technology/nginx.svg" /></a>
|
||||||
|
<a href="https://www.proxmox.com/"><img src="/static/images/technology/proxmox.png" /></a>
|
||||||
|
<a href="https://www.python.org/"><img src="/static/images/technology/python.png" /></a>
|
||||||
|
<a href="https://www.redhat.com/en/technologies/linux-platforms/enterprise-linux"><img src="/static/images/technology/redhat.png" /></a>
|
||||||
|
<a href="https://rockylinux.org/"><img src="/static/images/technology/rocky.png" /></a>
|
||||||
|
<a href="https://subversion.apache.org/"><img src="/static/images/technology/svn.svg" /></a>
|
||||||
|
</div>
|
||||||
|
<div id="certs">
|
||||||
|
<a href="https://catalog-education.oracle.com/ords/certview/sharebadge?id=A6C9B3D3D21627EB9C2504395194FAF772E01412911D4ADC78063133D54579ED"><img src="/static/images/certs/OCIF2023CA.png" /></a>
|
||||||
|
</div>
|
||||||
|
<div class="social">
|
||||||
|
<a class="button" href="https://www.linkedin.com/in/jakecharman/"><i class="fa-brands fa-linkedin-in"></i></a>
|
||||||
|
<a class="button" href="https://github.com/jcharman/"><i class="fa-brands fa-github"></i></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section id="motorsport">
|
||||||
|
<div class="gradient gradient-right">
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<div class="text text-right">
|
||||||
|
<div>
|
||||||
|
<h2>Racing</h2>
|
||||||
|
<hr />
|
||||||
|
<p>When I'm not at work, I can often be found at <a href="https://santapod.com">Santa Pod Raceway</a> with <a href="https://nitrojunkie.uk">Nitro Junkie Racing</a></p>
|
||||||
|
<p>We run a Top Fuel motorcycle purpose built for drag racing. The engine is loosely based on a Kawasaki Z 1000, but supercharged, and nitromethane injected to produce around 1,000 horsepower.</p>
|
||||||
|
<p>I've also had the opportunity to work on a 10,000 horsepower Top Fuel Dragster. I <a href="https://jakecharman.co.uk/projects/topfuel_dragster">wrote about this on the Nitro Junkie website, and later extended that post here.</a></p>
|
||||||
|
<div class="gallery">
|
||||||
|
<iframe class="yt" src="https://www.youtube.com/embed/Wa-V8mQTXFk?si=W-LzfR3Qj_jgAdkY&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 %}
|
||||||
35
src/jakecharman/templates/links.html
Normal file
35
src/jakecharman/templates/links.html
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{% extends 'main.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main id="links-main">
|
||||||
|
<h2>Useful Links</h2>
|
||||||
|
<section id="links">
|
||||||
|
{% for category in links %}
|
||||||
|
<div class="category">
|
||||||
|
<h3>{{category}}</h3>
|
||||||
|
{% for link in links.get(category) %}
|
||||||
|
<div class="link">
|
||||||
|
{% if link.get('img') is not none %}
|
||||||
|
<a href="{{link.src}}" target="_blank"></a>
|
||||||
|
<img class="link-thumb"
|
||||||
|
srcset="
|
||||||
|
{% for i in range(200, 5100, 100) %}
|
||||||
|
/image/{{ link.img }}?w={{i}} {{i}}w{{"," if not loop.last}}
|
||||||
|
{% endfor %}
|
||||||
|
"
|
||||||
|
sizes="
|
||||||
|
(max-width: 999px) 80vw,
|
||||||
|
(min-width: 1000px) 20vw
|
||||||
|
"
|
||||||
|
src="/image/{{ link.img }}">
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{link.src}}" target="_blank"><h3>{{ link.title }}</h3></a>
|
||||||
|
<p>{{ link.description }}</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
57
src/jakecharman/templates/main.html
Normal file
57
src/jakecharman/templates/main.html
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="description" content="Personal website of Jake Charman. A technology professional based in the UK.">
|
||||||
|
<title>{{ page_title }}{{branding}}</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400..900&family=Tourney:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100..900;1,100..900&family=Orbitron:wght@400..900&family=Tourney:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/static/style/mobile.css" />
|
||||||
|
<link rel="stylesheet" href="/static/style/desktop.css" />
|
||||||
|
<link rel="stylesheet" href="/static/fonts/fontawesome/css/all.min.css" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link rel="icon" type="image/x-icon" href="/static/images/jc.ico">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/viewerjs/1.11.7/viewer.css" integrity="sha512-9NawOLzuLE2GD22PJ6IPWXEjPalb/FpdH1qMpgXdaDM+0OfxEV75ZCle6KhZi0vM6ZWvMrNnIZv6YnsL+keVmA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||||
|
<script src="/static/js/filter_projects.js"></script>
|
||||||
|
<script src="/static/js/update_copyright.js"></script>
|
||||||
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||||
|
<!-- Privacy-friendly analytics by Plausible -->
|
||||||
|
<script async src="https://analytics.jakecharman.co.uk/js/pa-BBRBzeeo-AC_Nvfm4VWc9.js"></script>
|
||||||
|
<script>
|
||||||
|
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
|
||||||
|
plausible.init()
|
||||||
|
</script>
|
||||||
|
<script async src="https://www.googletagmanager.com/gtag/js?id=G-6WMXXY0RL0"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/viewerjs/1.11.7/viewer.min.js" integrity="sha512-lZD0JiwhtP4UkFD1mc96NiTZ14L7MjyX5Khk8PMxJszXMLvu7kjq1sp4bb0tcL6MY+/4sIuiUxubOqoueHrW4w==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||||
|
<script>
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag(){dataLayer.push(arguments);}
|
||||||
|
gtag('js', new Date());
|
||||||
|
|
||||||
|
gtag('config', 'G-6WMXXY0RL0');
|
||||||
|
</script>
|
||||||
|
{% block head %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<nav id="top-nav">
|
||||||
|
<a href="/">About</a>
|
||||||
|
<a href="/projects/">Projects</a>
|
||||||
|
<a href="/contact/">Contact</a>
|
||||||
|
<a href="/links/">Links</a>
|
||||||
|
</nav>
|
||||||
|
<div id="logo-container">
|
||||||
|
<div id="logo">
|
||||||
|
<a href='/'><h1>{{branding|upper}}</h1></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>© <span id="cr-year"></span> {{branding}}. This site uses cookies.</p>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
33
src/templates/projects.html → src/jakecharman/templates/projects.html
Normal file → Executable file
33
src/templates/projects.html → src/jakecharman/templates/projects.html
Normal file → Executable file
@@ -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>
|
||||||
@@ -12,20 +14,35 @@
|
|||||||
</nav>
|
</nav>
|
||||||
<h2>{{ title }}</h2>
|
<h2>{{ title }}</h2>
|
||||||
<p>{{ description }}</p>
|
<p>{{ description }}</p>
|
||||||
|
<p style="text-align: center; font-weight: bold; font-style: italic;">This page is relatively new. I'm adding projects as fast as I can but feel free to <a href="/contact">contact me</a> if you want to know about something in particular.</p>
|
||||||
<section id="projects">
|
<section id="projects">
|
||||||
{% for row in articles %}
|
{% for row in articles %}
|
||||||
<div class="project">
|
<div class="project">
|
||||||
<img class="project-thumb" src="/projects/image/{{ row.image }}">
|
{% if row.get('link') is not none %}
|
||||||
|
<a href="{{ row.link }}">
|
||||||
|
{% else %}
|
||||||
|
<a href="/projects/{{ row.id }}">
|
||||||
|
{% endif %}
|
||||||
|
<img class="project-thumb"
|
||||||
|
srcset="
|
||||||
|
{% for i in range(200, 5100, 100) %}
|
||||||
|
/image/{{ row.image }}?w={{i}} {{i}}w{{"," if not loop.last}}
|
||||||
|
{% endfor %}
|
||||||
|
"
|
||||||
|
sizes="
|
||||||
|
(max-width: 999px) 80vw,
|
||||||
|
(min-width: 1000px) 20vw
|
||||||
|
"
|
||||||
|
src="/image/{{ row.image }}">
|
||||||
|
</a>
|
||||||
<div class="project-text">
|
<div class="project-text">
|
||||||
{% if row.get('link') is not none %}
|
{% if row.get('link') is not none %}
|
||||||
<a href="{{ row.link }}">
|
<a href="{{ row.link }}">
|
||||||
<h3>{{ row.title }}</h3>
|
|
||||||
</a>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="/projects/{{ row.id }}">
|
<a href="/projects/{{ row.id }}">
|
||||||
|
{% endif %}
|
||||||
<h3>{{ row.title }}</h3>
|
<h3>{{ row.title }}</h3>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
|
||||||
<p class="article-description">{{ row.description }}</p>
|
<p class="article-description">{{ row.description }}</p>
|
||||||
<p class="article-date">{{ row.date | human_date }}</p>
|
<p class="article-date">{{ row.date | human_date }}</p>
|
||||||
{% for category in row.categories %}
|
{% for category in row.categories %}
|
||||||
@@ -36,4 +53,4 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
{% include 'footer.html' %}
|
{% endblock %}
|
||||||
125
src/projects.py
125
src/projects.py
@@ -1,125 +0,0 @@
|
|||||||
#!/usr/bin/python3
|
|
||||||
|
|
||||||
from os import path
|
|
||||||
import json
|
|
||||||
from flask import Flask, render_template, Response, send_from_directory
|
|
||||||
from markdown import markdown
|
|
||||||
import frontmatter
|
|
||||||
from glob import glob
|
|
||||||
from datetime import datetime
|
|
||||||
from index import app
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
|
|
||||||
md_directory = path.join(path.realpath(path.dirname(__file__)), path.normpath('projects/'))
|
|
||||||
|
|
||||||
@app.context_processor
|
|
||||||
def processor():
|
|
||||||
def get_excerpt(post):
|
|
||||||
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:
|
|
||||||
with open(path.join(md_directory, 'categories.json')) 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:
|
|
||||||
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):
|
|
||||||
return markdown(content)
|
|
||||||
|
|
||||||
def get_all_posts(directory: str) -> list:
|
|
||||||
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):
|
|
||||||
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():
|
|
||||||
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')) 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',
|
|
||||||
description='A selection of projects I\'ve been involved in')
|
|
||||||
|
|
||||||
@app.route('/projects/category/<category>/')
|
|
||||||
def category(category):
|
|
||||||
try:
|
|
||||||
with open(path.join(md_directory, 'categories.json')) as categories_file:
|
|
||||||
categories = json.load(categories_file)
|
|
||||||
the_category = categories.get(category)
|
|
||||||
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),
|
|
||||||
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)
|
|
||||||
|
|
||||||
@app.route('/projects/<article>')
|
|
||||||
def article(article):
|
|
||||||
articles = get_by_meta_key(md_directory, 'id', article)
|
|
||||||
|
|
||||||
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>')
|
|
||||||
def image(image):
|
|
||||||
return send_from_directory(path.join(md_directory, 'images'), image)
|
|
||||||
5
src/projects.wsgi
Normal file → Executable file
5
src/projects.wsgi
Normal file → Executable file
@@ -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)
|
||||||
|
|||||||
4
src/requirements.txt
Normal file → Executable file
4
src/requirements.txt
Normal file → Executable file
@@ -1,5 +1,9 @@
|
|||||||
|
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
|
||||||
beautifulsoup4>=4.11.1
|
beautifulsoup4>=4.11.1
|
||||||
python-frontmatter>=1.1.0
|
python-frontmatter>=1.1.0
|
||||||
|
requests>=2.32.3
|
||||||
|
pillow>=11.0.0
|
||||||
|
mod_wsgi>=5.0.2
|
||||||
|
|||||||
0
src/static/fonts/fontawesome/css/all.min.css
vendored
Normal file → Executable file
0
src/static/fonts/fontawesome/css/all.min.css
vendored
Normal file → Executable file
0
src/static/fonts/fontawesome/webfonts/fa-brands-400.ttf
Normal file → Executable file
0
src/static/fonts/fontawesome/webfonts/fa-brands-400.ttf
Normal file → Executable file
0
src/static/fonts/fontawesome/webfonts/fa-brands-400.woff2
Normal file → Executable file
0
src/static/fonts/fontawesome/webfonts/fa-brands-400.woff2
Normal file → Executable file
0
src/static/fonts/fontawesome/webfonts/fa-regular-400.ttf
Normal file → Executable file
0
src/static/fonts/fontawesome/webfonts/fa-regular-400.ttf
Normal file → Executable file
0
src/static/fonts/fontawesome/webfonts/fa-regular-400.woff2
Normal file → Executable file
0
src/static/fonts/fontawesome/webfonts/fa-regular-400.woff2
Normal file → Executable file
0
src/static/fonts/fontawesome/webfonts/fa-solid-900.ttf
Normal file → Executable file
0
src/static/fonts/fontawesome/webfonts/fa-solid-900.ttf
Normal file → Executable file
0
src/static/fonts/fontawesome/webfonts/fa-solid-900.woff2
Normal file → Executable file
0
src/static/fonts/fontawesome/webfonts/fa-solid-900.woff2
Normal file → Executable file
0
src/static/fonts/fontawesome/webfonts/fa-v4compatibility.ttf
Normal file → Executable file
0
src/static/fonts/fontawesome/webfonts/fa-v4compatibility.ttf
Normal file → Executable file
0
src/static/fonts/fontawesome/webfonts/fa-v4compatibility.woff2
Normal file → Executable file
0
src/static/fonts/fontawesome/webfonts/fa-v4compatibility.woff2
Normal file → Executable file
BIN
src/static/images/certs/OCIF2023CA.png
(Stored with Git LFS)
Executable file
BIN
src/static/images/certs/OCIF2023CA.png
(Stored with Git LFS)
Executable file
Binary file not shown.
BIN
src/static/images/jake_tf_1.jpg
(Stored with Git LFS)
Executable file
BIN
src/static/images/jake_tf_1.jpg
(Stored with Git LFS)
Executable file
Binary file not shown.
BIN
src/static/images/jc.ico
Executable file
BIN
src/static/images/jc.ico
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
0
src/static/images/njr-code.png
Normal file → Executable file
0
src/static/images/njr-code.png
Normal file → Executable file
BIN
src/static/images/technology/ansible.png
(Stored with Git LFS)
Executable file
BIN
src/static/images/technology/ansible.png
(Stored with Git LFS)
Executable file
Binary file not shown.
BIN
src/static/images/technology/aws.png
(Stored with Git LFS)
Executable file
BIN
src/static/images/technology/aws.png
(Stored with Git LFS)
Executable file
Binary file not shown.
BIN
src/static/images/technology/azure.png
(Stored with Git LFS)
Executable file
BIN
src/static/images/technology/azure.png
(Stored with Git LFS)
Executable file
Binary file not shown.
BIN
src/static/images/technology/csharp.png
(Stored with Git LFS)
Executable file
BIN
src/static/images/technology/csharp.png
(Stored with Git LFS)
Executable file
Binary file not shown.
BIN
src/static/images/technology/debian.png
(Stored with Git LFS)
Executable file
BIN
src/static/images/technology/debian.png
(Stored with Git LFS)
Executable file
Binary file not shown.
BIN
src/static/images/technology/digitalocean.png
(Stored with Git LFS)
Executable file
BIN
src/static/images/technology/digitalocean.png
(Stored with Git LFS)
Executable file
Binary file not shown.
BIN
src/static/images/technology/docker.png
(Stored with Git LFS)
Executable file
BIN
src/static/images/technology/docker.png
(Stored with Git LFS)
Executable file
Binary file not shown.
BIN
src/static/images/technology/freeipa.png
(Stored with Git LFS)
Executable file
BIN
src/static/images/technology/freeipa.png
(Stored with Git LFS)
Executable file
Binary file not shown.
BIN
src/static/images/technology/gcloud.png
(Stored with Git LFS)
Executable file
BIN
src/static/images/technology/gcloud.png
(Stored with Git LFS)
Executable file
Binary file not shown.
BIN
src/static/images/technology/git.png
(Stored with Git LFS)
Executable file
BIN
src/static/images/technology/git.png
(Stored with Git LFS)
Executable file
Binary file not shown.
BIN
src/static/images/technology/grafana.png
(Stored with Git LFS)
Executable file
BIN
src/static/images/technology/grafana.png
(Stored with Git LFS)
Executable file
Binary file not shown.
BIN
src/static/images/technology/hadoop.svg
(Stored with Git LFS)
Executable file
BIN
src/static/images/technology/hadoop.svg
(Stored with Git LFS)
Executable file
Binary file not shown.
BIN
src/static/images/technology/hive.svg
(Stored with Git LFS)
Executable file
BIN
src/static/images/technology/hive.svg
(Stored with Git LFS)
Executable file
Binary file not shown.
BIN
src/static/images/technology/java.png
(Stored with Git LFS)
Executable file
BIN
src/static/images/technology/java.png
(Stored with Git LFS)
Executable file
Binary file not shown.
BIN
src/static/images/technology/jenkins.png
(Stored with Git LFS)
Executable file
BIN
src/static/images/technology/jenkins.png
(Stored with Git LFS)
Executable file
Binary file not shown.
BIN
src/static/images/technology/mariadb.png
(Stored with Git LFS)
Executable file
BIN
src/static/images/technology/mariadb.png
(Stored with Git LFS)
Executable file
Binary file not shown.
BIN
src/static/images/technology/nginx.svg
(Stored with Git LFS)
Executable file
BIN
src/static/images/technology/nginx.svg
(Stored with Git LFS)
Executable file
Binary file not shown.
BIN
src/static/images/technology/proxmox.png
(Stored with Git LFS)
Executable file
BIN
src/static/images/technology/proxmox.png
(Stored with Git LFS)
Executable file
Binary file not shown.
BIN
src/static/images/technology/python.png
(Stored with Git LFS)
Executable file
BIN
src/static/images/technology/python.png
(Stored with Git LFS)
Executable file
Binary file not shown.
BIN
src/static/images/technology/redhat.png
(Stored with Git LFS)
Executable file
BIN
src/static/images/technology/redhat.png
(Stored with Git LFS)
Executable file
Binary file not shown.
BIN
src/static/images/technology/rocky.png
(Stored with Git LFS)
Executable file
BIN
src/static/images/technology/rocky.png
(Stored with Git LFS)
Executable file
Binary file not shown.
BIN
src/static/images/technology/svn.svg
(Stored with Git LFS)
Executable file
BIN
src/static/images/technology/svn.svg
(Stored with Git LFS)
Executable file
Binary file not shown.
0
src/static/images/topfuel_startline.jpg.jpeg
Normal file → Executable file
0
src/static/images/topfuel_startline.jpg.jpeg
Normal file → Executable file
0
src/static/js/filter_projects.js
Normal file → Executable file
0
src/static/js/filter_projects.js
Normal file → Executable file
0
src/static/js/gallery.js
Normal file
0
src/static/js/gallery.js
Normal file
0
src/static/js/update_copyright.js
Normal file → Executable file
0
src/static/js/update_copyright.js
Normal file → Executable file
4
src/static/robots.txt
Executable file
4
src/static/robots.txt
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://jakecharman.co.uk/sitemap.xml
|
||||||
82
src/static/style/desktop.css
Normal file → Executable file
82
src/static/style/desktop.css
Normal file → Executable file
@@ -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,20 +48,84 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#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;
|
||||||
|
margin: 0 auto 0 auto;
|
||||||
|
width: 80vw;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project{
|
.project{
|
||||||
width: 20vw;
|
width: 20vw;
|
||||||
height: 25vw;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#top-nav{
|
#top-nav{
|
||||||
float: right;
|
float: right;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
padding-right: 20px;
|
padding-right: 20px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
#logo-container{
|
||||||
|
position: absolute;
|
||||||
|
height: 25vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
#article>p>img{
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
#article>video{
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
#certs>a {
|
||||||
|
max-width: 15%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#techlogos {
|
||||||
|
max-height: 40%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#techlogos>a{
|
||||||
|
max-width: 5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt {
|
||||||
|
width: calc(66% - 40px);
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery {
|
||||||
|
display: flex;
|
||||||
|
height: fit-content;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery * {
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery>img {
|
||||||
|
width: calc(33% - 40px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#links {
|
||||||
|
padding-left: 20px;
|
||||||
|
padding-right: 20px;
|
||||||
|
}
|
||||||
|
.link>a>h3 {
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-gallery>img {
|
||||||
|
width: 20vw;
|
||||||
|
padding: 10px;
|
||||||
|
height: 14vw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
194
src/static/style/mobile.css
Normal file → Executable file
194
src/static/style/mobile.css
Normal file → Executable file
@@ -13,23 +13,29 @@ 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{
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
#logo{
|
#logo{
|
||||||
@@ -47,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{
|
||||||
@@ -117,7 +122,9 @@ a{
|
|||||||
|
|
||||||
.project-thumb{
|
.project-thumb{
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 100%;
|
max-height: 50%;
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
#filter {
|
#filter {
|
||||||
@@ -168,3 +175,168 @@ a{
|
|||||||
.article-category:hover{
|
.article-category:hover{
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#contact-main{
|
||||||
|
padding: 20px 10px 0 10px;
|
||||||
|
min-height: 65vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
#contact-main>h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, textarea{
|
||||||
|
display: block;
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
background-color: #4c4c4c;
|
||||||
|
border-radius: 10px;
|
||||||
|
border:none;
|
||||||
|
color: white;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
label{
|
||||||
|
line-height: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-turnstile{
|
||||||
|
width: fit-content;
|
||||||
|
padding: 10px 0 10px 0;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#contact-error{
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
#article{
|
||||||
|
padding: 0 10px 0 10px;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#article>p>img{
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto 0 auto;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 50vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
#article>video{
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto 0 auto;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 50vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre{
|
||||||
|
overflow-x: auto;
|
||||||
|
background-color: #36332f;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#certs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#certs>a {
|
||||||
|
max-width: 25%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#certs>a>img {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#techlogos {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
max-height:30%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#techlogos>a {
|
||||||
|
max-width: 10%;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#techlogos>a>img {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
height: 30vh;
|
||||||
|
width: 100%;
|
||||||
|
background-image: linear-gradient(to top, rgba(23, 22, 20, 1) 10%, rgba(23, 22, 20, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt {
|
||||||
|
width: 100%;
|
||||||
|
height: 22vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery * {
|
||||||
|
padding: 10px;
|
||||||
|
max-width: 80vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
#links-main{
|
||||||
|
padding: 20px 10px 20px 10px;
|
||||||
|
min-height: 65vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
border-top: 1px solid #e5e5e5;
|
||||||
|
clear: both;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link>img {
|
||||||
|
float: left;
|
||||||
|
padding-right: 20px;
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link>a>h3 {
|
||||||
|
padding-top: 10px;
|
||||||
|
margin: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
.category {
|
||||||
|
padding-top: 10px;
|
||||||
|
border-bottom: 1px solid #e5e5e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category>h3 {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-gallery {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-gallery>img {
|
||||||
|
width: 45vw;
|
||||||
|
padding: 5px;
|
||||||
|
height: 31vw;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
#comments {
|
||||||
|
border-top: 1px solid #e5e5e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment {
|
||||||
|
background-color: #4c4c4c;
|
||||||
|
margin: 10px;
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{% include 'header.html' %}
|
|
||||||
<main>
|
|
||||||
<section id="tech-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,27 +0,0 @@
|
|||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<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=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/desktop.css" />
|
|
||||||
<link rel="stylesheet" href="/static/fonts/fontawesome/css/all.min.css" />
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<script src="/static/js/filter_projects.js"></script>
|
|
||||||
<script src="/static/js/update_copyright.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<nav id="top-nav">
|
|
||||||
<a href="/">About</a>
|
|
||||||
<a href="/projects/">Projects</a>
|
|
||||||
</nav>
|
|
||||||
<div id="logo-container">
|
|
||||||
<div id="logo">
|
|
||||||
<a href='/'><h1>Jake Charman</h1></a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
@@ -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