Compare commits
102
Commits
projects
...
2472aadbee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2472aadbee | ||
|
|
f712d78f27 | ||
|
|
295d243c16 | ||
|
|
c78c25dd68 | ||
|
|
70318b96a2 | ||
|
|
f893f0a31c | ||
|
|
0c28fe932f | ||
|
|
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 |
Regular → Executable
+1
@@ -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
|
||||||
|
|||||||
Executable
+3
@@ -0,0 +1,3 @@
|
|||||||
|
__pycache__
|
||||||
|
.vscode
|
||||||
|
.buildinfo.json
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[FORMAT]
|
||||||
|
max-line-length=140
|
||||||
|
|
||||||
|
[MESSAGES CONTROL]
|
||||||
|
disable=import-error
|
||||||
Regular → Executable
+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"]
|
||||||
+155
@@ -0,0 +1,155 @@
|
|||||||
|
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')
|
||||||
|
NEWRELIC_KEY = credentials('newrelic')
|
||||||
|
}
|
||||||
|
|
||||||
|
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: '[email protected]:jake/jc-ng.git'
|
||||||
|
sh "sed -i s/dummy/$NEWRELIC_KEY/ src/newrelic.ini"
|
||||||
|
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: '[email protected]: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: '[email protected]:jake/jc-content.git'
|
||||||
|
sh "gsutil rsync -rcd . gs://jakecharman.co.uk"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Clear cache') {
|
||||||
|
steps{
|
||||||
|
sh "/var/lib/jenkins/clearCFCache/clearCache.py a514fb61e1413b88aabbb19df16b8508"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Regular → Executable
+16
-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,16 @@ 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"
|
||||||
|
Alias "/.well-known/security.txt" "/var/www/jc/static/security.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
|
||||||
Executable
+12
@@ -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
|
||||||
@@ -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)))
|
|
||||||
@@ -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 newrelic import agent
|
||||||
|
from PIL import Image, UnidentifiedImageError
|
||||||
|
from .content import ContentArea
|
||||||
|
from .contact import ContactForm
|
||||||
|
from .storage import LocalStorage
|
||||||
|
from .links import Links
|
||||||
|
|
||||||
|
agent.initialize('/var/www/jc/newrelic.ini')
|
||||||
|
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')
|
||||||
|
|
||||||
|
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_all_posts():
|
||||||
|
if 'link' in article.metadata:
|
||||||
|
continue
|
||||||
|
url = ET.SubElement(root, 'url')
|
||||||
|
ET.SubElement(url, 'loc').text = f'{base_url}/projects/{article.metadata['id']}'
|
||||||
|
ET.SubElement(url, 'lastmod').text = article.metadata['date'].strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
return Response(ET.tostring(root, encoding='utf-8'), 200, {'content-type': 'application/xml'})
|
||||||
|
|
||||||
|
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}'))
|
||||||
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
+145
@@ -0,0 +1,145 @@
|
|||||||
|
#!/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
|
||||||
|
from .storage import LocalStorage
|
||||||
|
|
||||||
|
class ContentArea(Blueprint):
|
||||||
|
def __init__(self, directory: LocalStorage, *args, **kwargs):
|
||||||
|
self.md_directory = directory
|
||||||
|
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.add_app_template_filter(self.category_title, 'category_title')
|
||||||
|
self.add_app_template_filter(self.human_date, 'human_date')
|
||||||
|
self.add_app_template_filter(self.to_html, 'to_html')
|
||||||
|
|
||||||
|
self.context_processor(self.processor)
|
||||||
|
|
||||||
|
self.add_url_rule('/', view_func=self.projects)
|
||||||
|
self.add_url_rule('/category/<category_id>/', view_func=self.category)
|
||||||
|
self.add_url_rule('/<article_id>', view_func=self.article)
|
||||||
|
#self.add_url_rule('/image/<image_name>', view_func=self.image)
|
||||||
|
|
||||||
|
def processor(self) -> dict:
|
||||||
|
''' Jninja processors '''
|
||||||
|
def get_excerpt(post: frontmatter.Post) -> str:
|
||||||
|
html = markdown(post.content)
|
||||||
|
post_soup = BeautifulSoup(html, 'html.parser')
|
||||||
|
all_text = ' '.join([x.get_text() for x in post_soup.findAll('p')])
|
||||||
|
return ' '.join(all_text.split()[:200])
|
||||||
|
return dict(get_excerpt=get_excerpt)
|
||||||
|
|
||||||
|
def category_title(self, category_id: str) -> str:
|
||||||
|
''' Jninja filter to get a category title by its ID '''
|
||||||
|
with self.md_directory.open('categories.json') as categories_file:
|
||||||
|
categories = json.load(categories_file)
|
||||||
|
|
||||||
|
return categories.get(category_id).get('title', '')
|
||||||
|
|
||||||
|
def human_date(self, iso_date: str) -> str:
|
||||||
|
''' Jninja filter to convert an ISO date to human readable. '''
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(str(iso_date)).strftime('%A %d %B %Y')
|
||||||
|
except ValueError:
|
||||||
|
return iso_date
|
||||||
|
|
||||||
|
def to_html(self, content: str) -> str:
|
||||||
|
''' Jninja filter to wrap markdown '''
|
||||||
|
return markdown(content)
|
||||||
|
|
||||||
|
def get_all_posts(self) -> list:
|
||||||
|
''' Get all posts in the posts directory '''
|
||||||
|
abs_paths = [x.path for x in self.md_directory.ls() if x.path.endswith('.md')]
|
||||||
|
posts = []
|
||||||
|
for p in abs_paths:
|
||||||
|
with self.md_directory.open(p) as f:
|
||||||
|
posts.append(frontmatter.load(f))
|
||||||
|
return posts
|
||||||
|
|
||||||
|
def get_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]
|
||||||
|
return render_template('article.html', post=markdown(the_article.content),
|
||||||
|
metadata=the_article.metadata,
|
||||||
|
page_title=f'{the_article.metadata["title"]} - ')
|
||||||
@@ -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
+25
@@ -0,0 +1,25 @@
|
|||||||
|
{% 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>
|
||||||
|
{% endblock %}
|
||||||
Executable
+22
@@ -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 %}
|
||||||
Regular → Executable
+4
-2
@@ -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 %}
|
||||||
@@ -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:[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/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>
|
||||||
Regular → Executable
+25
-8
@@ -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 %}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#
|
||||||
|
# This file configures the New Relic Python Agent.
|
||||||
|
#
|
||||||
|
# The path to the configuration file should be supplied to the function
|
||||||
|
# newrelic.agent.initialize() when the agent is being initialized.
|
||||||
|
#
|
||||||
|
# The configuration file follows a structure similar to what you would
|
||||||
|
# find for Microsoft Windows INI files. For further information on the
|
||||||
|
# configuration file format see the Python ConfigParser documentation at:
|
||||||
|
#
|
||||||
|
# http://docs.python.org/library/configparser.html
|
||||||
|
#
|
||||||
|
# For further discussion on the behaviour of the Python agent that can
|
||||||
|
# be configured via this configuration file see:
|
||||||
|
#
|
||||||
|
# https://docs.newrelic.com/docs/apm/agents/python-agent/configuration/python-agent-configuration/
|
||||||
|
#
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Here are the settings that are common to all environments.
|
||||||
|
|
||||||
|
[newrelic]
|
||||||
|
|
||||||
|
# You must specify the license key associated with your New
|
||||||
|
# Relic account. This may also be set using the NEW_RELIC_LICENSE_KEY
|
||||||
|
# environment variable. This key binds the Python Agent's data to
|
||||||
|
# your account in the New Relic service. For more information on
|
||||||
|
# storing and generating license keys, see
|
||||||
|
# https://docs.newrelic.com/docs/apis/intro-apis/new-relic-api-keys/#ingest-license-key
|
||||||
|
license_key = dummy
|
||||||
|
|
||||||
|
# The application name. Set this to be the name of your
|
||||||
|
# application as you would like it to show up in New Relic UI.
|
||||||
|
# You may also set this using the NEW_RELIC_APP_NAME environment variable.
|
||||||
|
# The UI will then auto-map instances of your application into a
|
||||||
|
# entry on your home dashboard page. You can also specify multiple
|
||||||
|
# app names to group your aggregated data. For further details,
|
||||||
|
# please see:
|
||||||
|
# https://docs.newrelic.com/docs/apm/agents/manage-apm-agents/app-naming/use-multiple-names-app/
|
||||||
|
app_name = jakecharman.co.uk
|
||||||
|
|
||||||
|
# When "true", the agent collects performance data about your
|
||||||
|
# application and reports this data to the New Relic UI at
|
||||||
|
# newrelic.com. This global switch is normally overridden for
|
||||||
|
# each environment below. It may also be set using the
|
||||||
|
# NEW_RELIC_MONITOR_MODE environment variable.
|
||||||
|
monitor_mode = true
|
||||||
|
|
||||||
|
# Sets the name of a file to log agent messages to. Whatever you
|
||||||
|
# set this to, you must ensure that the permissions for the
|
||||||
|
# containing directory and the file itself are correct, and
|
||||||
|
# that the user that your web application runs as can write out
|
||||||
|
# to the file. If not able to out a log file, it is also
|
||||||
|
# possible to say "stderr" and output to standard error output.
|
||||||
|
# This would normally result in output appearing in your web
|
||||||
|
# server log. It can also be set using the NEW_RELIC_LOG
|
||||||
|
# environment variable.
|
||||||
|
log_file = stdout
|
||||||
|
|
||||||
|
# Sets the level of detail of messages sent to the log file, if
|
||||||
|
# a log file location has been provided. Possible values, in
|
||||||
|
# increasing order of detail, are: "critical", "error", "warning",
|
||||||
|
# "info" and "debug". When reporting any agent issues to New
|
||||||
|
# Relic technical support, the most useful setting for the
|
||||||
|
# support engineers is "debug". However, this can generate a lot
|
||||||
|
# of information very quickly, so it is best not to keep the
|
||||||
|
# agent at this level for longer than it takes to reproduce the
|
||||||
|
# problem you are experiencing. This may also be set using the
|
||||||
|
# NEW_RELIC_LOG_LEVEL environment variable.
|
||||||
|
log_level = info
|
||||||
|
|
||||||
|
# High Security Mode enforces certain security settings, and prevents
|
||||||
|
# them from being overridden, so that no sensitive data is sent to New
|
||||||
|
# Relic. Enabling High Security Mode means that request parameters are
|
||||||
|
# not collected and SQL can not be sent to New Relic in its raw form.
|
||||||
|
# To activate High Security Mode, it must be set to 'true' in this
|
||||||
|
# local .ini configuration file AND be set to 'true' in the
|
||||||
|
# server-side configuration in the New Relic user interface. It can
|
||||||
|
# also be set using the NEW_RELIC_HIGH_SECURITY environment variable.
|
||||||
|
# For details, see
|
||||||
|
# https://docs.newrelic.com/docs/subscriptions/high-security
|
||||||
|
high_security = false
|
||||||
|
|
||||||
|
# The Python Agent will attempt to connect directly to the New
|
||||||
|
# Relic service. If there is an intermediate firewall between
|
||||||
|
# your host and the New Relic service that requires you to use a
|
||||||
|
# HTTP proxy, then you should set both the "proxy_host" and
|
||||||
|
# "proxy_port" settings to the required values for the HTTP
|
||||||
|
# proxy. The "proxy_user" and "proxy_pass" settings should
|
||||||
|
# additionally be set if proxy authentication is implemented by
|
||||||
|
# the HTTP proxy. The "proxy_scheme" setting dictates what
|
||||||
|
# protocol scheme is used in talking to the HTTP proxy. This
|
||||||
|
# would normally always be set as "http" which will result in the
|
||||||
|
# agent then using a SSL tunnel through the HTTP proxy for end to
|
||||||
|
# end encryption.
|
||||||
|
# See https://docs.newrelic.com/docs/apm/agents/python-agent/configuration/python-agent-configuration/#proxy
|
||||||
|
# for information on proxy configuration via environment variables.
|
||||||
|
# proxy_scheme = http
|
||||||
|
# proxy_host = hostname
|
||||||
|
# proxy_port = 8080
|
||||||
|
# proxy_user =
|
||||||
|
# proxy_pass =
|
||||||
|
|
||||||
|
# Capturing request parameters is off by default. To enable the
|
||||||
|
# capturing of request parameters, first ensure that the setting
|
||||||
|
# "attributes.enabled" is set to "true" (the default value), and
|
||||||
|
# then add "request.parameters.*" to the "attributes.include"
|
||||||
|
# setting. For details about attributes configuration, please
|
||||||
|
# consult the documentation.
|
||||||
|
# attributes.include = request.parameters.*
|
||||||
|
|
||||||
|
# The transaction tracer captures deep information about slow
|
||||||
|
# transactions and sends this to the UI on a periodic basis. The
|
||||||
|
# transaction tracer is enabled by default. Set this to "false"
|
||||||
|
# to turn it off.
|
||||||
|
transaction_tracer.enabled = true
|
||||||
|
|
||||||
|
# Threshold in seconds for when to collect a transaction trace.
|
||||||
|
# When the response time of a controller action exceeds this
|
||||||
|
# threshold, a transaction trace will be recorded and sent to
|
||||||
|
# the UI. Valid values are any positive float value, or (default)
|
||||||
|
# "apdex_f", which will use the threshold for a dissatisfying
|
||||||
|
# Apdex controller action - four times the Apdex T value.
|
||||||
|
transaction_tracer.transaction_threshold = apdex_f
|
||||||
|
|
||||||
|
# When the transaction tracer is on, SQL statements can
|
||||||
|
# optionally be recorded. The recorder has three modes, "off"
|
||||||
|
# which sends no SQL, "raw" which sends the SQL statement in its
|
||||||
|
# original form, and "obfuscated", which strips out numeric and
|
||||||
|
# string literals.
|
||||||
|
transaction_tracer.record_sql = obfuscated
|
||||||
|
|
||||||
|
# Threshold in seconds for when to collect stack trace for a SQL
|
||||||
|
# call. In other words, when SQL statements exceed this
|
||||||
|
# threshold, then capture and send to the UI the current stack
|
||||||
|
# trace. This is helpful for pinpointing where long SQL calls
|
||||||
|
# originate from in an application.
|
||||||
|
transaction_tracer.stack_trace_threshold = 0.5
|
||||||
|
|
||||||
|
# Determines whether the agent will capture query plans for slow
|
||||||
|
# SQL queries. Only supported in MySQL and PostgreSQL. Set this
|
||||||
|
# to "false" to turn it off.
|
||||||
|
transaction_tracer.explain_enabled = true
|
||||||
|
|
||||||
|
# Threshold for query execution time below which query plans
|
||||||
|
# will not not be captured. Relevant only when "explain_enabled"
|
||||||
|
# is true.
|
||||||
|
transaction_tracer.explain_threshold = 0.5
|
||||||
|
|
||||||
|
# Space separated list of function or method names in form
|
||||||
|
# 'module:function' or 'module:class.function' for which
|
||||||
|
# additional function timing instrumentation will be added.
|
||||||
|
transaction_tracer.function_trace =
|
||||||
|
|
||||||
|
# The error collector captures information about uncaught
|
||||||
|
# exceptions or logged exceptions and sends them to UI for
|
||||||
|
# viewing. The error collector is enabled by default. Set this
|
||||||
|
# to "false" to turn it off. For more details on errors, see
|
||||||
|
# https://docs.newrelic.com/docs/apm/agents/manage-apm-agents/agent-data/manage-errors-apm-collect-ignore-or-mark-expected/
|
||||||
|
error_collector.enabled = true
|
||||||
|
|
||||||
|
# To stop specific errors from reporting to the UI, set this to
|
||||||
|
# a space separated list of the Python exception type names to
|
||||||
|
# ignore. The exception name should be of the form 'module:class'.
|
||||||
|
error_collector.ignore_classes =
|
||||||
|
|
||||||
|
# Expected errors are reported to the UI but will not affect the
|
||||||
|
# Apdex or error rate. To mark specific errors as expected, set this
|
||||||
|
# to a space separated list of the Python exception type names to
|
||||||
|
# expected. The exception name should be of the form 'module:class'.
|
||||||
|
error_collector.expected_classes =
|
||||||
|
|
||||||
|
# Browser monitoring is the Real User Monitoring feature of the UI.
|
||||||
|
# For those Python web frameworks that are supported, this
|
||||||
|
# setting enables the auto-insertion of the browser monitoring
|
||||||
|
# JavaScript fragments.
|
||||||
|
browser_monitoring.auto_instrument = true
|
||||||
|
|
||||||
|
# A thread profiling session can be scheduled via the UI when
|
||||||
|
# this option is enabled. The thread profiler will periodically
|
||||||
|
# capture a snapshot of the call stack for each active thread in
|
||||||
|
# the application to construct a statistically representative
|
||||||
|
# call tree. For more details on the thread profiler tool, see
|
||||||
|
# https://docs.newrelic.com/docs/apm/apm-ui-pages/events/thread-profiler-tool/
|
||||||
|
thread_profiler.enabled = true
|
||||||
|
|
||||||
|
# Your application deployments can be recorded through the
|
||||||
|
# New Relic REST API. To use this feature provide your API key
|
||||||
|
# below then use the `newrelic-admin record-deploy` command.
|
||||||
|
# This can also be set using the NEW_RELIC_API_KEY
|
||||||
|
# environment variable.
|
||||||
|
# api_key =
|
||||||
|
|
||||||
|
# Distributed tracing lets you see the path that a request takes
|
||||||
|
# through your distributed system. For more information, please
|
||||||
|
# consult our distributed tracing planning guide.
|
||||||
|
# https://docs.newrelic.com/docs/transition-guide-distributed-tracing
|
||||||
|
distributed_tracing.enabled = true
|
||||||
|
|
||||||
|
# This setting enables log decoration, the forwarding of log events,
|
||||||
|
# and the collection of logging metrics if these sub-feature
|
||||||
|
# configurations are also enabled. If this setting is false, no
|
||||||
|
# logging instrumentation features are enabled. This can also be
|
||||||
|
# set using the NEW_RELIC_APPLICATION_LOGGING_ENABLED environment
|
||||||
|
# variable.
|
||||||
|
# application_logging.enabled = true
|
||||||
|
|
||||||
|
# If true, the agent captures log records emitted by your application
|
||||||
|
# and forwards them to New Relic. `application_logging.enabled` must
|
||||||
|
# also be true for this setting to take effect. You can also set
|
||||||
|
# this using the NEW_RELIC_APPLICATION_LOGGING_FORWARDING_ENABLED
|
||||||
|
# environment variable.
|
||||||
|
# application_logging.forwarding.enabled = true
|
||||||
|
|
||||||
|
# If true, the agent decorates logs with metadata to link to entities,
|
||||||
|
# hosts, traces, and spans. `application_logging.enabled` must also
|
||||||
|
# be true for this setting to take effect. This can also be set
|
||||||
|
# using the NEW_RELIC_APPLICATION_LOGGING_LOCAL_DECORATING_ENABLED
|
||||||
|
# environment variable.
|
||||||
|
# application_logging.local_decorating.enabled = true
|
||||||
|
|
||||||
|
# If true, the agent captures metrics related to the log lines
|
||||||
|
# being sent up by your application. This can also be set
|
||||||
|
# using the NEW_RELIC_APPLICATION_LOGGING_METRICS_ENABLED
|
||||||
|
# environment variable.
|
||||||
|
# application_logging.metrics.enabled = true
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#
|
||||||
|
# The application environments. These are specific settings which
|
||||||
|
# override the common environment settings. The settings related to a
|
||||||
|
# specific environment will be used when the environment argument to the
|
||||||
|
# newrelic.agent.initialize() function has been defined to be either
|
||||||
|
# "development", "test", "staging" or "production".
|
||||||
|
#
|
||||||
|
|
||||||
|
[newrelic:development]
|
||||||
|
monitor_mode = false
|
||||||
|
|
||||||
|
[newrelic:test]
|
||||||
|
monitor_mode = false
|
||||||
|
|
||||||
|
[newrelic:staging]
|
||||||
|
app_name = jakecharman.co.uk (Staging)
|
||||||
|
monitor_mode = true
|
||||||
|
|
||||||
|
[newrelic:production]
|
||||||
|
monitor_mode = true
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
-125
@@ -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)
|
|
||||||
Regular → Executable
+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)
|
||||||
|
|||||||
Regular → Executable
+5
@@ -1,5 +1,10 @@
|
|||||||
|
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
|
||||||
|
newrelic
|
||||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
BIN
Binary file not shown.
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
Regular → Executable
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.
Regular → Executable
Regular → Executable
Regular → Executable
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://jakecharman.co.uk/sitemap.xml
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
Contact: mailto:[email protected]
|
||||||
|
Expires: 2036-01-01T00:00:00.000Z
|
||||||
|
Preferred-Languages: en
|
||||||
Regular → Executable
+78
-4
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Regular → Executable
+172
-11
@@ -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,157 @@ 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;
|
||||||
|
}
|
||||||
@@ -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:[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/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