Compare commits
95
Commits
contact
...
new-comments
| 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 |
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"]
|
||||||
+154
@@ -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: '[email protected]: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: '[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
+14
-8
@@ -28,7 +28,7 @@
|
|||||||
# same ServerRoot for multiple httpd daemons, you will need to change at
|
# same ServerRoot for multiple httpd daemons, you will need to change at
|
||||||
# least PidFile.
|
# least PidFile.
|
||||||
#
|
#
|
||||||
ServerRoot "/usr/local/apache2"
|
ServerRoot "/usr/lib/apache2"
|
||||||
|
|
||||||
#
|
#
|
||||||
# Mutex: Allows you to set the mutex mechanism and mutex file directory
|
# Mutex: Allows you to set the mutex mechanism and mutex file directory
|
||||||
@@ -123,7 +123,7 @@ LoadModule deflate_module modules/mod_deflate.so
|
|||||||
#LoadModule brotli_module modules/mod_brotli.so
|
#LoadModule brotli_module modules/mod_brotli.so
|
||||||
LoadModule mime_module modules/mod_mime.so
|
LoadModule mime_module modules/mod_mime.so
|
||||||
#LoadModule ldap_module modules/mod_ldap.so
|
#LoadModule ldap_module modules/mod_ldap.so
|
||||||
LoadModule log_config_module modules/mod_log_config.so
|
#LoadModule log_config_module modules/mod_log_config.so
|
||||||
#LoadModule log_debug_module modules/mod_log_debug.so
|
#LoadModule log_debug_module modules/mod_log_debug.so
|
||||||
#LoadModule log_forensic_module modules/mod_log_forensic.so
|
#LoadModule log_forensic_module modules/mod_log_forensic.so
|
||||||
#LoadModule logio_module modules/mod_logio.so
|
#LoadModule logio_module modules/mod_logio.so
|
||||||
@@ -137,7 +137,7 @@ LoadModule headers_module modules/mod_headers.so
|
|||||||
#LoadModule usertrack_module modules/mod_usertrack.so
|
#LoadModule usertrack_module modules/mod_usertrack.so
|
||||||
#LoadModule unique_id_module modules/mod_unique_id.so
|
#LoadModule unique_id_module modules/mod_unique_id.so
|
||||||
LoadModule setenvif_module modules/mod_setenvif.so
|
LoadModule setenvif_module modules/mod_setenvif.so
|
||||||
LoadModule version_module modules/mod_version.so
|
#LoadModule version_module modules/mod_version.so
|
||||||
#LoadModule remoteip_module modules/mod_remoteip.so
|
#LoadModule remoteip_module modules/mod_remoteip.so
|
||||||
#LoadModule proxy_module modules/mod_proxy.so
|
#LoadModule proxy_module modules/mod_proxy.so
|
||||||
#LoadModule proxy_connect_module modules/mod_proxy_connect.so
|
#LoadModule proxy_connect_module modules/mod_proxy_connect.so
|
||||||
@@ -171,7 +171,7 @@ LoadModule version_module modules/mod_version.so
|
|||||||
#LoadModule lbmethod_bytraffic_module modules/mod_lbmethod_bytraffic.so
|
#LoadModule lbmethod_bytraffic_module modules/mod_lbmethod_bytraffic.so
|
||||||
#LoadModule lbmethod_bybusyness_module modules/mod_lbmethod_bybusyness.so
|
#LoadModule lbmethod_bybusyness_module modules/mod_lbmethod_bybusyness.so
|
||||||
#LoadModule lbmethod_heartbeat_module modules/mod_lbmethod_heartbeat.so
|
#LoadModule lbmethod_heartbeat_module modules/mod_lbmethod_heartbeat.so
|
||||||
LoadModule unixd_module modules/mod_unixd.so
|
#LoadModule unixd_module modules/mod_unixd.so
|
||||||
#LoadModule heartbeat_module modules/mod_heartbeat.so
|
#LoadModule heartbeat_module modules/mod_heartbeat.so
|
||||||
#LoadModule heartmonitor_module modules/mod_heartmonitor.so
|
#LoadModule heartmonitor_module modules/mod_heartmonitor.so
|
||||||
#LoadModule dav_module modules/mod_dav.so
|
#LoadModule dav_module modules/mod_dav.so
|
||||||
@@ -198,8 +198,7 @@ LoadModule dir_module modules/mod_dir.so
|
|||||||
LoadModule alias_module modules/mod_alias.so
|
LoadModule alias_module modules/mod_alias.so
|
||||||
#LoadModule rewrite_module modules/mod_rewrite.somod_wsgi-express start-server
|
#LoadModule rewrite_module modules/mod_rewrite.somod_wsgi-express start-server
|
||||||
|
|
||||||
LoadModule wsgi_module /usr/lib/apache2/modules/mod_wsgi.so
|
LoadModule wsgi_module /usr/local/lib/python3.14/site-packages/mod_wsgi/server/mod_wsgi-py314.cpython-314-x86_64-linux-gnu.so
|
||||||
|
|
||||||
|
|
||||||
<IfModule unixd_module>
|
<IfModule unixd_module>
|
||||||
#
|
#
|
||||||
@@ -368,7 +367,7 @@ LogLevel warn
|
|||||||
# TypesConfig points to the file containing the list of mappings from
|
# TypesConfig points to the file containing the list of mappings from
|
||||||
# filename extension to MIME-type.
|
# filename extension to MIME-type.
|
||||||
#
|
#
|
||||||
TypesConfig conf/mime.types
|
TypesConfig /etc/mime.types
|
||||||
|
|
||||||
#
|
#
|
||||||
# AddType allows you to add to or override the MIME configuration
|
# AddType allows you to add to or override the MIME configuration
|
||||||
@@ -423,7 +422,6 @@ ErrorDocument 503 /error/503
|
|||||||
ErrorDocument 505 /error/505
|
ErrorDocument 505 /error/505
|
||||||
|
|
||||||
WSGISocketPrefix /var/run/wsgi
|
WSGISocketPrefix /var/run/wsgi
|
||||||
|
|
||||||
WSGIDaemonProcess jc-wsgi user=www-data group=www-data threads=5
|
WSGIDaemonProcess jc-wsgi user=www-data group=www-data threads=5
|
||||||
WSGIProcessGroup jc-wsgi
|
WSGIProcessGroup jc-wsgi
|
||||||
WSGIScriptAlias / /var/www/jc/projects.wsgi
|
WSGIScriptAlias / /var/www/jc/projects.wsgi
|
||||||
@@ -434,6 +432,14 @@ WSGIScriptAlias / /var/www/jc/projects.wsgi
|
|||||||
Require all granted
|
Require all granted
|
||||||
</Directory>
|
</Directory>
|
||||||
|
|
||||||
|
Alias "/static" "/var/www/jc/static/"
|
||||||
|
<Directory /var/www/jc/static>
|
||||||
|
Order allow,Deny
|
||||||
|
Allow from all
|
||||||
|
</Directory>
|
||||||
|
|
||||||
|
Alias "/robots.txt" "/var/www/jc/static/robots.txt"
|
||||||
|
|
||||||
<Files jc.wsgi>
|
<Files jc.wsgi>
|
||||||
Require all granted
|
Require all granted
|
||||||
</Files>
|
</Files>
|
||||||
|
|||||||
Executable
+9
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/bash --noprofile
|
||||||
|
|
||||||
|
run() {
|
||||||
|
python3 src/projects.wsgi || run
|
||||||
|
}
|
||||||
|
|
||||||
|
export DISCORD_ERR_HOOK='dummy'
|
||||||
|
run
|
||||||
|
unset DISCORD_ERR_HOOK
|
||||||
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,62 +0,0 @@
|
|||||||
#!/usr/bin/python3
|
|
||||||
|
|
||||||
from index import app
|
|
||||||
from os import environ
|
|
||||||
from flask import request, render_template
|
|
||||||
from requests import post, get
|
|
||||||
from uuid import uuid4
|
|
||||||
from textwrap import dedent
|
|
||||||
|
|
||||||
def validate_turnstile(response: str, ip: str) -> bool:
|
|
||||||
turnstile_secret = environ['TURNSTILE_SECRET']
|
|
||||||
cf_response = post(
|
|
||||||
url='https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
|
||||||
data={
|
|
||||||
'secret': turnstile_secret,
|
|
||||||
'response': response,
|
|
||||||
'remoteip': ip,
|
|
||||||
'idempotency_key': uuid4()
|
|
||||||
}
|
|
||||||
).json()
|
|
||||||
|
|
||||||
return cf_response.get('success', False)
|
|
||||||
|
|
||||||
def send_to_discord(form: dict) -> bool:
|
|
||||||
try:
|
|
||||||
discord_hook = environ['DISCORD_WEBHOOK']
|
|
||||||
except:
|
|
||||||
return False
|
|
||||||
discord_msg = dedent(
|
|
||||||
f'''
|
|
||||||
__**New Contact Form Response**__
|
|
||||||
|
|
||||||
**From:** {form.get('name')} <{form.get('email')}>
|
|
||||||
|
|
||||||
''')
|
|
||||||
if form.get("message") == '':
|
|
||||||
discord_msg += '*No Message*'
|
|
||||||
else:
|
|
||||||
discord_msg += f'>>> {form.get("message")}'
|
|
||||||
discord_response = post(
|
|
||||||
url=discord_hook,
|
|
||||||
data={
|
|
||||||
'username': form.get('name'),
|
|
||||||
'content': discord_msg
|
|
||||||
}
|
|
||||||
).status_code
|
|
||||||
|
|
||||||
if discord_response == 204:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
@app.route('/contact/', methods=('GET', 'POST'))
|
|
||||||
def contact():
|
|
||||||
if request.method == 'POST':
|
|
||||||
if not validate_turnstile(request.form['cf-turnstile-response'], request.remote_addr):
|
|
||||||
return render_template('contact.html', error=True, user_message='You appear to be a robot.')
|
|
||||||
send_result = send_to_discord(request.form)
|
|
||||||
if send_result:
|
|
||||||
return render_template('contact.html', user_message='Your message has been sent!')
|
|
||||||
return render_template('contact.html', error=True, user_message='An error occurred.')
|
|
||||||
else:
|
|
||||||
return render_template('contact.html')
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
#!/usr/bin/python3
|
|
||||||
|
|
||||||
from flask import Flask, render_template, Response
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
|
||||||
|
|
||||||
import projects
|
|
||||||
import contact
|
|
||||||
|
|
||||||
@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 PIL import Image, UnidentifiedImageError
|
||||||
|
from .content import ContentArea
|
||||||
|
from .contact import ContactForm
|
||||||
|
from .storage import LocalStorage
|
||||||
|
from .links import Links
|
||||||
|
from .comments import *
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
md_path = path.join(path.realpath(path.dirname(__file__)), path.normpath('../projects/'))
|
||||||
|
|
||||||
|
projects = ContentArea(
|
||||||
|
directory=LocalStorage(md_path),
|
||||||
|
name='projects',
|
||||||
|
import_name=__name__)
|
||||||
|
|
||||||
|
app.register_blueprint(projects, url_prefix='/projects')
|
||||||
|
app.register_blueprint(ContactForm('contact', __name__), url_prefix='/contact')
|
||||||
|
app.register_blueprint(Links(path.join(md_path, 'links.json'), 'links', __name__), url_prefix='/links')
|
||||||
|
app.register_blueprint(Approval(path.join(projects.md_directory.uri, 'comments.db'), 'comments', __name__), url_prefix='/comments')
|
||||||
|
|
||||||
|
class DiscordLogger(logging.Handler):
|
||||||
|
''' Simple logging handler to send a message to Discord '''
|
||||||
|
|
||||||
|
level = logging.ERROR
|
||||||
|
|
||||||
|
def __init__(self, webhook):
|
||||||
|
super().__init__()
|
||||||
|
self._webhook = webhook
|
||||||
|
|
||||||
|
def send_to_discord(self, msg: logging.LogRecord):
|
||||||
|
''' Send the message '''
|
||||||
|
if msg.exc_info is not None:
|
||||||
|
message_to_send = f'{msg.msg}\n\n{"".join(traceback.format_exception(*msg.exc_info))}'
|
||||||
|
else:
|
||||||
|
message_to_send = msg.msg
|
||||||
|
if len(message_to_send) > 2000:
|
||||||
|
chars_to_lose = len(message_to_send) - 2000
|
||||||
|
if msg.exc_info is not None:
|
||||||
|
message_to_send = f'{msg.msg}\n\n{"".join(traceback.format_exception(*msg.exc_info))[-chars_to_lose:]}'
|
||||||
|
else:
|
||||||
|
message_to_send = msg.msg[-chars_to_lose:]
|
||||||
|
post(self._webhook, data={'content': message_to_send}, timeout=30)
|
||||||
|
|
||||||
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
|
''' Take in the record and start a new thread to send it to Discord '''
|
||||||
|
app.logger.info('Sending error to Discord')
|
||||||
|
dc_thread = threading.Thread(target=self.send_to_discord, args=[record])
|
||||||
|
dc_thread.start()
|
||||||
|
|
||||||
|
discord_logger = DiscordLogger(environ['DISCORD_ERR_HOOK'])
|
||||||
|
app.logger.addHandler(discord_logger)
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def inject_branding() -> dict:
|
||||||
|
''' Modify branding depending on the URL being used '''
|
||||||
|
req_domain = urlsplit(request.base_url).netloc.lower()
|
||||||
|
match req_domain:
|
||||||
|
case 'jakecharman.co.uk':
|
||||||
|
brand = 'Jake Charman'
|
||||||
|
case _:
|
||||||
|
brand = req_domain
|
||||||
|
|
||||||
|
return {'branding': brand}
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index() -> str:
|
||||||
|
''' Load the homepage '''
|
||||||
|
return render_template('index.html')
|
||||||
|
|
||||||
|
@app.route('/error/<code>')
|
||||||
|
def error(code) -> str:
|
||||||
|
''' Render a nicer error page for a given code '''
|
||||||
|
|
||||||
|
error_definitions = {
|
||||||
|
400: 'Bad Request',
|
||||||
|
403: 'Forbidden',
|
||||||
|
404: 'Page Not Found',
|
||||||
|
418: 'I\'m a Teapot',
|
||||||
|
500: 'Internal Server Error',
|
||||||
|
503: 'Service Temporarily Unavailable',
|
||||||
|
505: 'HTTP Version Not Supported'
|
||||||
|
}
|
||||||
|
error_desc = {
|
||||||
|
400: 'Sorry, I didn\'t understand your request.',
|
||||||
|
403: 'Sorry, you aren\'t allowed to view this page.',
|
||||||
|
404: 'Sorry, that page doesn\'t exist.',
|
||||||
|
418: 'I can\'t brew coffee as I am, in fact, a teapot.',
|
||||||
|
500: 'Something went wrong on my end.',
|
||||||
|
503: 'My website is experiencing some issues and will be back shortly.',
|
||||||
|
505: 'Your browser tried to use a HTTP version I don\'t support. Check it is up to date.'
|
||||||
|
}
|
||||||
|
|
||||||
|
if not code.isdigit():
|
||||||
|
code=400
|
||||||
|
elif int(code) not in error_definitions:
|
||||||
|
return Response(status=code)
|
||||||
|
|
||||||
|
return render_template('error.html',
|
||||||
|
error=f'{code}: {error_definitions.get(int(code))}',
|
||||||
|
description=error_desc.get(int(code)))
|
||||||
|
|
||||||
|
def get_routes() -> list:
|
||||||
|
''' Get a list of all routes that make up the app '''
|
||||||
|
routes = []
|
||||||
|
for rule in app.url_map.iter_rules():
|
||||||
|
if 0 >= len(rule.arguments):
|
||||||
|
url = url_for(rule.endpoint, **(rule.defaults or {}))
|
||||||
|
routes.append(url)
|
||||||
|
return routes
|
||||||
|
|
||||||
|
def get_build_date():
|
||||||
|
''' Get the build date of the Docker container we're running in '''
|
||||||
|
try:
|
||||||
|
with open('/var/www/jc/.buildinfo.json', encoding='utf8') as build:
|
||||||
|
build_json = json.load(build)
|
||||||
|
return build_json['date']
|
||||||
|
except Exception: # pylint: disable=broad-exception-caught
|
||||||
|
return '1970-01-01'
|
||||||
|
|
||||||
|
@app.route('/sitemap.xml')
|
||||||
|
def sitemap():
|
||||||
|
''' Return an XML site map '''
|
||||||
|
date = get_build_date()
|
||||||
|
root = ET.Element('urlset', xmlns='http://www.sitemaps.org/schemas/sitemap/0.9')
|
||||||
|
base_url = match(r'^https?:\/\/.+:?\d*(?=\/)', request.base_url).group()
|
||||||
|
base_url = base_url.replace('http://', 'https://')
|
||||||
|
for route in get_routes():
|
||||||
|
url = ET.SubElement(root, 'url')
|
||||||
|
ET.SubElement(url, 'loc').text = base_url + route
|
||||||
|
ET.SubElement(url, 'lastmod').text = date
|
||||||
|
for article in projects.get_live_posts():
|
||||||
|
if 'link' in article.metadata:
|
||||||
|
continue
|
||||||
|
url = ET.SubElement(root, 'url')
|
||||||
|
ET.SubElement(url, 'loc').text = f'{base_url}/projects/{article.metadata['id']}'
|
||||||
|
ET.SubElement(url, 'lastmod').text = article.metadata['date'].strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
return Response(ET.tostring(root, encoding='utf-8'), 200, {'content-type': 'application/xml'})
|
||||||
|
|
||||||
|
def resize_image(image_name: str, size: tuple):
|
||||||
|
w = size[0]
|
||||||
|
h = size[1]
|
||||||
|
|
||||||
|
md_directory = LocalStorage(md_path)
|
||||||
|
the_image = Image.open(path.join(md_directory.uri, 'images', image_name))
|
||||||
|
max_width, max_height = the_image.size
|
||||||
|
|
||||||
|
if path.exists(path.join(md_directory.uri, 'images', f'{w}-{h}-{image_name}')) or (w >= max_width and h >= max_height):
|
||||||
|
raise FileExistsError()
|
||||||
|
|
||||||
|
req_size = [max_width, max_height]
|
||||||
|
if w > 0:
|
||||||
|
req_size[0] = w
|
||||||
|
if h > 0:
|
||||||
|
req_size[1] = h
|
||||||
|
|
||||||
|
resized_img = BytesIO()
|
||||||
|
the_image.thumbnail(tuple(req_size))
|
||||||
|
the_image.save(resized_img, format=the_image.format)
|
||||||
|
the_image.save(path.join(md_directory.uri, 'images', f'{w}-{h}-{image_name}'), the_image.format)
|
||||||
|
|
||||||
|
@app.route('/image/<image_name>')
|
||||||
|
def image( image_name: str) -> Response:
|
||||||
|
''' Resize and return an image. '''
|
||||||
|
md_directory = LocalStorage(md_path)
|
||||||
|
|
||||||
|
w = int(request.args.get('w', 0))
|
||||||
|
h = int(request.args.get('h', 0))
|
||||||
|
|
||||||
|
if w == 0 and h == 0:
|
||||||
|
return send_from_directory(md_directory.uri, path.join('images', image_name))
|
||||||
|
if path.exists(path.join(md_directory.uri, 'images', f'{w}-{h}-{image_name}')):
|
||||||
|
return send_from_directory(md_directory.uri, path.join('images', f'{w}-{h}-{image_name}'))
|
||||||
|
try:
|
||||||
|
resize_image(image_name, (w, h))
|
||||||
|
except FileNotFoundError:
|
||||||
|
return Response(status=404)
|
||||||
|
except UnidentifiedImageError:
|
||||||
|
return send_from_directory(md_directory.uri, path.join('images', image_name))
|
||||||
|
except FileExistsError:
|
||||||
|
return send_from_directory(md_directory.uri, path.join('images', image_name))
|
||||||
|
|
||||||
|
return send_from_directory(md_directory.uri, path.join('images', f'{w}-{h}-{image_name}'))
|
||||||
|
|
||||||
|
@app.route('/image/thumb/<image_name>')
|
||||||
|
def img_thumb(image_name: str):
|
||||||
|
''' Flask route to load an image '''
|
||||||
|
md_directory = LocalStorage(md_path)
|
||||||
|
w = 400
|
||||||
|
h = 0
|
||||||
|
thumb_file = path.join(md_directory.uri, 'images', f'{w}-{h}-{image_name}')
|
||||||
|
if path.exists(thumb_file):
|
||||||
|
return send_from_directory(md_directory.uri, path.join('images', f'{w}-{h}-{image_name}'))
|
||||||
|
try:
|
||||||
|
resize_image(image_name, (w, h))
|
||||||
|
except FileNotFoundError:
|
||||||
|
return Response(status=404)
|
||||||
|
except UnidentifiedImageError:
|
||||||
|
return send_from_directory(md_directory.uri, path.join('images', image_name))
|
||||||
|
except FileExistsError:
|
||||||
|
return send_from_directory(md_directory.uri, path.join('images', f'{w}-{h}-{image_name}'))
|
||||||
|
|
||||||
|
return send_from_directory(md_directory.uri, path.join('images', f'{w}-{h}-{image_name}'))
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from flask import Blueprint, Response, request
|
||||||
|
from uuid import uuid4
|
||||||
|
from requests import post
|
||||||
|
from os import environ
|
||||||
|
|
||||||
|
|
||||||
|
class PostComments():
|
||||||
|
def __init__(self, post_id: str, db_path: str):
|
||||||
|
self.__db_path = db_path
|
||||||
|
self.__post_id = post_id
|
||||||
|
self._webhook = environ['DISCORD_WEBHOOK']
|
||||||
|
with sqlite3.connect(db_path) as db:
|
||||||
|
cursor = db.cursor()
|
||||||
|
cursor.execute("CREATE TABLE IF NOT EXISTS comments (name TEXT, comment TEXT, date INT, post_id TEXT, approved BOOL, key TEXT)")
|
||||||
|
self.comments = cursor.execute(
|
||||||
|
"SELECT name, comment, date FROM comments WHERE approved = 1 AND post_id = ? ORDER BY date DESC",
|
||||||
|
(post_id,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
def send_to_discord(self, name: str, comment: str, comment_id: int, key: str):
|
||||||
|
''' Send the message '''
|
||||||
|
message_to_send = f'New comment from {name}\n\n{comment}'
|
||||||
|
if len(message_to_send) > 2000:
|
||||||
|
chars_to_lose = len(message_to_send) - 1900
|
||||||
|
message_to_send = message_to_send[-chars_to_lose:]
|
||||||
|
message_to_send += f'\n\n[Approve](https://jakecharman.co.uk/comments/approve/{comment_id}?key={key})'
|
||||||
|
post(self._webhook, data={'content': message_to_send}, timeout=30)
|
||||||
|
|
||||||
|
def make_comment(self, name: str, comment: str):
|
||||||
|
with sqlite3.connect(self.__db_path) as db:
|
||||||
|
key = str(uuid4())
|
||||||
|
cursor = db.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO comments (name, comment, date, post_id, approved, key) VALUES (?, ?, datetime('now'), ?, 0, ?)",
|
||||||
|
(name, comment, self.__post_id, key)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
self.send_to_discord(name, comment, cursor.lastrowid, key)
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
class Approval(Blueprint):
|
||||||
|
def __init__(self, db_path: str, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.__db_path = db_path
|
||||||
|
self.add_url_rule('/approve/<comment_id>', view_func=self.approve)
|
||||||
|
|
||||||
|
def approve(self, comment_id: str):
|
||||||
|
with sqlite3.connect(self.__db_path) as db:
|
||||||
|
cursor = db.cursor()
|
||||||
|
key = cursor.execute("SELECT key FROM comments WHERE rowid = ?", (comment_id,)).fetchone()[0]
|
||||||
|
if request.args.get('key') == key:
|
||||||
|
with sqlite3.connect(self.__db_path) as db:
|
||||||
|
cursor = db.cursor()
|
||||||
|
cursor.execute("UPDATE comments SET approved = 1 WHERE rowid = ?", (comment_id,))
|
||||||
|
db.commit()
|
||||||
|
return Response(status=200)
|
||||||
|
return Response(status=403)
|
||||||
Executable
+70
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
from os import environ
|
||||||
|
from uuid import uuid4
|
||||||
|
from textwrap import dedent
|
||||||
|
from traceback import format_exc
|
||||||
|
from requests import post
|
||||||
|
from flask import request, render_template, Blueprint, current_app
|
||||||
|
|
||||||
|
class ContactForm(Blueprint):
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.add_url_rule('/', view_func=self.contact, methods=['GET', 'POST'])
|
||||||
|
|
||||||
|
def validate_turnstile(self, response: str, ip: str) -> bool:
|
||||||
|
turnstile_secret = environ['TURNSTILE_SECRET']
|
||||||
|
cf_response = post(
|
||||||
|
url='https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
||||||
|
data={
|
||||||
|
'secret': turnstile_secret,
|
||||||
|
'response': response,
|
||||||
|
'remoteip': ip,
|
||||||
|
'idempotency_key': uuid4()
|
||||||
|
},
|
||||||
|
timeout=30
|
||||||
|
).json()
|
||||||
|
|
||||||
|
return cf_response.get('success', False)
|
||||||
|
|
||||||
|
def send_to_discord(self, form: dict) -> bool:
|
||||||
|
try:
|
||||||
|
discord_hook = environ['DISCORD_WEBHOOK']
|
||||||
|
except KeyError:
|
||||||
|
current_app.logger.error(format_exc())
|
||||||
|
return False
|
||||||
|
discord_msg = dedent(
|
||||||
|
f'''
|
||||||
|
__**New Contact Form Response**__
|
||||||
|
|
||||||
|
**From:** {form.get('name')} <{form.get('email')}>
|
||||||
|
|
||||||
|
''')
|
||||||
|
if form.get("message") == '':
|
||||||
|
discord_msg += '*No Message*'
|
||||||
|
else:
|
||||||
|
discord_msg += f'>>> {form.get("message")}'
|
||||||
|
discord_response = post(
|
||||||
|
url=discord_hook,
|
||||||
|
data={
|
||||||
|
'username': form.get('name'),
|
||||||
|
'content': discord_msg
|
||||||
|
},
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
if discord_response.status_code == 204:
|
||||||
|
return True
|
||||||
|
current_app.logger.error(discord_response.status_code, discord_response.text)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def contact(self):
|
||||||
|
if request.method == 'POST':
|
||||||
|
if not self.validate_turnstile(request.form['cf-turnstile-response'], request.remote_addr):
|
||||||
|
return render_template('contact.html', error=True, user_message='You appear to be a robot.')
|
||||||
|
send_result = self.send_to_discord(request.form)
|
||||||
|
if send_result:
|
||||||
|
return render_template('contact.html', user_message='Your message has been sent!')
|
||||||
|
return render_template('contact.html', error=True, user_message='An error occurred.')
|
||||||
|
else:
|
||||||
|
return render_template('contact.html', page_title='Contact - ')
|
||||||
Executable
+156
@@ -0,0 +1,156 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
from os import path
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
import frontmatter
|
||||||
|
from markdown import markdown
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from flask import render_template, Response, Blueprint, request, redirect
|
||||||
|
from .storage import LocalStorage
|
||||||
|
from .comments import PostComments, Approval
|
||||||
|
|
||||||
|
class ContentArea(Blueprint):
|
||||||
|
def __init__(self, directory: LocalStorage, *args, **kwargs):
|
||||||
|
self.md_directory = directory
|
||||||
|
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.add_app_template_filter(self.category_title, 'category_title')
|
||||||
|
self.add_app_template_filter(self.human_date, 'human_date')
|
||||||
|
self.add_app_template_filter(self.to_html, 'to_html')
|
||||||
|
|
||||||
|
self.context_processor(self.processor)
|
||||||
|
|
||||||
|
self.add_url_rule('/', view_func=self.projects)
|
||||||
|
self.add_url_rule('/category/<category_id>/', view_func=self.category)
|
||||||
|
self.add_url_rule('/<article_id>', view_func=self.article)
|
||||||
|
self.add_url_rule('/<article_id>/comment', view_func=self.comment, methods=['POST'])
|
||||||
|
|
||||||
|
def processor(self) -> dict:
|
||||||
|
''' Jninja processors '''
|
||||||
|
def get_excerpt(post: frontmatter.Post) -> str:
|
||||||
|
html = markdown(post.content)
|
||||||
|
post_soup = BeautifulSoup(html, 'html.parser')
|
||||||
|
all_text = ' '.join([x.get_text() for x in post_soup.findAll('p')])
|
||||||
|
return ' '.join(all_text.split()[:200])
|
||||||
|
return dict(get_excerpt=get_excerpt)
|
||||||
|
|
||||||
|
def category_title(self, category_id: str) -> str:
|
||||||
|
''' Jninja filter to get a category title by its ID '''
|
||||||
|
with self.md_directory.open('categories.json') as categories_file:
|
||||||
|
categories = json.load(categories_file)
|
||||||
|
|
||||||
|
return categories.get(category_id).get('title', '')
|
||||||
|
|
||||||
|
def human_date(self, iso_date: str) -> str:
|
||||||
|
''' Jninja filter to convert an ISO date to human readable. '''
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(str(iso_date)).strftime('%A %d %B %Y')
|
||||||
|
except ValueError:
|
||||||
|
return iso_date
|
||||||
|
|
||||||
|
def to_html(self, content: str) -> str:
|
||||||
|
''' Jninja filter to wrap markdown '''
|
||||||
|
return markdown(content)
|
||||||
|
|
||||||
|
def get_all_posts(self) -> list:
|
||||||
|
''' Get all posts in the posts directory '''
|
||||||
|
abs_paths = [x.path for x in self.md_directory.ls() if x.path.endswith('.md')]
|
||||||
|
posts = []
|
||||||
|
for p in abs_paths:
|
||||||
|
with self.md_directory.open(p) as f:
|
||||||
|
posts.append(frontmatter.load(f))
|
||||||
|
return posts
|
||||||
|
|
||||||
|
def get_live_posts(self) -> list:
|
||||||
|
''' Get all posts in the posts directory excluding ones with a date in the future '''
|
||||||
|
return [x for x in self.get_all_posts() if x.metadata.get('date') <= datetime.now().date()]
|
||||||
|
|
||||||
|
def get_by_meta_key(self, key: str, value: str) -> list:
|
||||||
|
''' Get posts by a metadata key value pair '''
|
||||||
|
return [x for x in self.get_live_posts() if x.get(key) == value or isinstance(x.get(key, []), list) and value in x.get(key, [])]
|
||||||
|
|
||||||
|
def projects(self) -> str:
|
||||||
|
''' Load the projects page '''
|
||||||
|
articles_to_return = sorted(
|
||||||
|
self.get_live_posts(),
|
||||||
|
key=lambda d: d.metadata.get('date'),
|
||||||
|
reverse=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(articles_to_return) < 1:
|
||||||
|
return render_template('error.html',
|
||||||
|
error='There\'s nothing here... yet.',
|
||||||
|
description='I\'m still working on this page. Check back soon for some content.')
|
||||||
|
try:
|
||||||
|
with self.md_directory.open('categories.json') as categories_file:
|
||||||
|
categories = json.load(categories_file)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return render_template('error.html',
|
||||||
|
error='There\'s nothing here... yet.',
|
||||||
|
description='I\'m still working on this page. Check back soon for some content.')
|
||||||
|
|
||||||
|
return render_template('projects.html',
|
||||||
|
articles=articles_to_return,
|
||||||
|
all_categories=categories,
|
||||||
|
title='Projects',
|
||||||
|
page_title='Projects - ',
|
||||||
|
description='A selection of projects I\'ve been involved in')
|
||||||
|
|
||||||
|
def category(self, category_id: str) -> str:
|
||||||
|
''' Load the page for a given category '''
|
||||||
|
try:
|
||||||
|
with self.md_directory.open('categories.json') as categories_file:
|
||||||
|
categories = json.load(categories_file)
|
||||||
|
the_category = categories.get(category_id)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return render_template('error.html',
|
||||||
|
error='There\'s nothing here... yet.',
|
||||||
|
description='I\'m still working on this page. Check back soon for some content.')
|
||||||
|
|
||||||
|
if the_category is None:
|
||||||
|
return Response(status=404)
|
||||||
|
|
||||||
|
articles_to_return = sorted(
|
||||||
|
self.get_by_meta_key(
|
||||||
|
'categories', category_id),
|
||||||
|
key=lambda d: d.metadata.get('date'),
|
||||||
|
reverse=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(articles_to_return) < 1:
|
||||||
|
return render_template('error.html',
|
||||||
|
error='There\'s nothing here... yet.',
|
||||||
|
description='I\'m still working on this page. Check back soon for some content.')
|
||||||
|
|
||||||
|
return render_template('projects.html', articles=articles_to_return,
|
||||||
|
title=the_category['title'],
|
||||||
|
description=the_category['long_description'],
|
||||||
|
page_title=f'{the_category["title"]} - ',
|
||||||
|
all_categories=categories,
|
||||||
|
current_category=category_id)
|
||||||
|
|
||||||
|
def article(self, article_id: str) -> str:
|
||||||
|
''' Load a single article '''
|
||||||
|
articles = self.get_by_meta_key('id', article_id)
|
||||||
|
|
||||||
|
if len(articles) == 0:
|
||||||
|
return Response(status=404)
|
||||||
|
if len(articles) > 1:
|
||||||
|
return Response(status=500)
|
||||||
|
|
||||||
|
the_article = articles[0]
|
||||||
|
|
||||||
|
comments = PostComments(the_article.metadata['id'], path.join(self.md_directory.uri, 'comments.db')).comments
|
||||||
|
return render_template('article.html', post=markdown(the_article.content),
|
||||||
|
metadata=the_article.metadata,
|
||||||
|
comments = comments,
|
||||||
|
page_title=f'{the_article.metadata["title"]} - ')
|
||||||
|
|
||||||
|
def comment(self, article_id: str):
|
||||||
|
PostComments(article_id, path.join(self.md_directory.uri, 'comments.db')).make_comment(
|
||||||
|
request.form['name'],
|
||||||
|
request.form['comment']
|
||||||
|
)
|
||||||
|
return redirect(f'/projects/{article_id}?comment=true#comments')
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from flask import Blueprint, render_template
|
||||||
|
import json
|
||||||
|
|
||||||
|
class Links(Blueprint):
|
||||||
|
def __init__(self, file, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.add_url_rule('/', view_func=self.links)
|
||||||
|
self.file = file
|
||||||
|
|
||||||
|
def links(self):
|
||||||
|
with open(self.file, encoding='utf8') as file:
|
||||||
|
links = json.load(file)
|
||||||
|
return render_template('links.html', links=links, page_title='Useful Links - ')
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
class File():
|
||||||
|
def __init__(self, storage: LocalStorage, path: str): # pylint: disable=used-before-assignment
|
||||||
|
self.path = path
|
||||||
|
self.storage = storage
|
||||||
|
|
||||||
|
def open(self, *args, **kwargs):
|
||||||
|
''' Open the file using the relevant storage '''
|
||||||
|
return(self.storage.open(self.path, *args, **kwargs))
|
||||||
|
|
||||||
|
class LocalStorage():
|
||||||
|
''' Class to represent a location on a local file system '''
|
||||||
|
def __init__(self, uri: str):
|
||||||
|
self.uri = uri
|
||||||
|
|
||||||
|
def ls(self, path: str = '') -> list[File]:
|
||||||
|
''' Return a listing of a directory '''
|
||||||
|
if path.startswith('/'):
|
||||||
|
path = path[1:]
|
||||||
|
fullpath = os.path.join(self.uri, path)
|
||||||
|
return [File(self, os.path.join(fullpath, x)) for x in os.listdir(fullpath)]
|
||||||
|
|
||||||
|
def open(self, path: str, *args, **kwargs):
|
||||||
|
''' Open a file '''
|
||||||
|
return open(os.path.join(self.uri, path), *args, **kwargs, encoding='utf8')
|
||||||
Executable
+44
@@ -0,0 +1,44 @@
|
|||||||
|
{% extends 'main.html' %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
{% if metadata.gallery %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded",function(){
|
||||||
|
post_gallery = new Viewer(document.getElementById('article'), {
|
||||||
|
'url': 'realsrc',
|
||||||
|
'title': false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main>
|
||||||
|
<section id="article">
|
||||||
|
<h1>{{ metadata.title}} </h1>
|
||||||
|
<p>{{ metadata.date | human_date }}</p>
|
||||||
|
<hr />
|
||||||
|
{{post|safe}}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<section id="comments">
|
||||||
|
<h2>{{comments | length}} Comment{% if comments | length != 1 %}s{% endif %}</h2>
|
||||||
|
{% for comment in comments %}
|
||||||
|
<div class="comment">
|
||||||
|
<strong>{{ comment[0] }} - {{ comment[2] | human_date }}</strong>
|
||||||
|
<p>{{ comment[1] }}</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<h3>Leave a comment</h3>
|
||||||
|
{% if request.args.get('comment') is none %}
|
||||||
|
<form action="./{{metadata.id}}/comment" method="post">
|
||||||
|
<input type="text", name="name" placeholder="Name" required>
|
||||||
|
<textarea name="comment" placeholder="Comment" required></textarea>
|
||||||
|
<input type="submit" value="Submit">
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p>Thank you! Your comment will appear once it is approved</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
Regular → Executable
+4
-2
@@ -1,4 +1,6 @@
|
|||||||
{% include 'header.html' %}
|
{% extends 'main.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
<main id="contact-main">
|
<main id="contact-main">
|
||||||
<h2>Contact Me</h2>
|
<h2>Contact Me</h2>
|
||||||
<p>Got a question or want to talk about something on this site? Drop me a message below:</p>
|
<p>Got a question or want to talk about something on this site? Drop me a message below:</p>
|
||||||
@@ -17,4 +19,4 @@
|
|||||||
<p id="{{ 'contact-error' if error else 'contact-message' }}">{{ user_message }}</p>
|
<p id="{{ 'contact-error' if error else 'contact-message' }}">{{ user_message }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</main>
|
</main>
|
||||||
{% include 'footer.html' %}
|
{% endblock %}
|
||||||
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 %}
|
||||||
-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
+3
@@ -1,6 +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
|
requests>=2.32.3
|
||||||
|
pillow>=11.0.0
|
||||||
|
mod_wsgi>=5.0.2
|
||||||
|
|||||||
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
|
||||||
Regular → Executable
+73
-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,25 +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{
|
#logo-container{
|
||||||
position: absolute;
|
position: absolute;
|
||||||
height: 25vh;
|
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
+147
-11
@@ -13,17 +13,22 @@ h1, h2, h3{
|
|||||||
}
|
}
|
||||||
|
|
||||||
header{
|
header{
|
||||||
background-color: 4c4c4c;
|
background-color: #4c4c4c;
|
||||||
height: 25vh;
|
height: 25vh;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
main{
|
||||||
|
min-height: 65vh;
|
||||||
|
}
|
||||||
|
|
||||||
footer{
|
footer{
|
||||||
background-color: 4c4c4c;
|
background-color: #4c4c4c;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 10vh;
|
height: 10vh;
|
||||||
|
clear: both;
|
||||||
}
|
}
|
||||||
|
|
||||||
#logo-container{
|
#logo-container{
|
||||||
@@ -48,33 +53,32 @@ footer h2, section h2{
|
|||||||
|
|
||||||
#technology{
|
#technology{
|
||||||
background-image: url(../images/njr-code.png);
|
background-image: url(../images/njr-code.png);
|
||||||
height: 100vh;
|
height: fit-content;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
background-position-x: -400px;
|
background-position-x: -400px;
|
||||||
|
background-position-y: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#motorsport{
|
#motorsport{
|
||||||
background-image: url(../images/topfuel_startline.jpg.jpeg);
|
background-image: url(../images/topfuel_startline.jpg.jpeg);
|
||||||
height: 100vh;
|
height: fit-content;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
background-position-x: -65px;
|
background-position-x: -65px;
|
||||||
background-position-y: -90px;
|
background-position-y: -100px;
|
||||||
background-size: 200%;
|
background-size: 200%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gradient{
|
.gradient{
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: fit-content;
|
||||||
background-image: linear-gradient(to top, rgba(23, 22, 20, 1) 70%, rgba(23, 22, 20, 0));
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.text{
|
.text{
|
||||||
height: 70%;
|
height: fit-content;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
background-color: rgba(23, 22, 20, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.text>div{
|
.text>div{
|
||||||
@@ -118,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 {
|
||||||
@@ -204,3 +210,133 @@ label{
|
|||||||
#contact-error{
|
#contact-error{
|
||||||
color: red;
|
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,29 +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>
|
|
||||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<nav id="top-nav">
|
|
||||||
<a href="/">About</a>
|
|
||||||
<a href="/projects/">Projects</a>
|
|
||||||
<a href="/contact/">Contact</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