Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50bf391450 | ||
|
|
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 |
@@ -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
|
||||||
|
|||||||
+8
-5
@@ -1,8 +1,11 @@
|
|||||||
FROM httpd:2.4
|
FROM python:3.14-bookworm
|
||||||
RUN apt-get update
|
RUN apt-get update
|
||||||
RUN apt-get -y install libapache2-mod-wsgi-py3 python3 python3-pip
|
RUN apt-get -y install apache2 apache2-dev
|
||||||
COPY src/requirements.txt /var/www/jc/requirements.txt
|
COPY src/requirements.txt /var/www/jc/requirements.txt
|
||||||
RUN pip3 install -r /var/www/jc/requirements.txt || pip3 install --break-system-packages -r /var/www/jc/requirements.txt
|
RUN /usr/local/bin/pip3 install --upgrade pip
|
||||||
COPY --chown=www-data:www-data config/httpd.conf /usr/local/apache2/conf/httpd.conf
|
RUN /usr/local/bin/pip3 install -r /var/www/jc/requirements.txt
|
||||||
|
COPY --chown=www-data:www-data config/httpd.conf /etc/apache2/apache2.conf
|
||||||
COPY --chown=www-data:www-data src/ /var/www/jc
|
COPY --chown=www-data:www-data src/ /var/www/jc
|
||||||
RUN httpd -t
|
RUN apache2 -t
|
||||||
|
EXPOSE 80
|
||||||
|
ENTRYPOINT ["apache2", "-D", "FOREGROUND"]
|
||||||
Vendored
+44
-23
@@ -33,27 +33,39 @@ pipeline {
|
|||||||
credentialsId: 'Git',
|
credentialsId: 'Git',
|
||||||
url: '[email protected]:jake/jc-ng.git'
|
url: '[email protected]:jake/jc-ng.git'
|
||||||
|
|
||||||
sh "./build.sh registry.jakecharman.co.uk/jakecharman.co.uk $BUILD_NUMBER"
|
sh "./build.sh git.jakecharman.co.uk/jake/jakecharman.co.uk $BUILD_NUMBER"
|
||||||
|
sh "./build.sh europe-west2-docker.pkg.dev/jakecharman/web/jakecharman.co.uk $BUILD_NUMBER"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage('Security scan') {
|
stage('Security scan') {
|
||||||
|
when {
|
||||||
|
expression {
|
||||||
|
return params.Build == true
|
||||||
|
}
|
||||||
|
}
|
||||||
steps {
|
steps {
|
||||||
sh "docker run --name sectest registry.jakecharman.co.uk/jakecharman.co.uk:$BUILD_NUMNER"
|
catchError(buildResult: 'SUCCESS', stageResult: 'FAILURE') {
|
||||||
sh "docker exec sectest pip3 install pip-audit"
|
sh "docker kill sectest || true"
|
||||||
sh "docker exec sectest pip-audit"
|
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 registry') {
|
stage('Push to local registry') {
|
||||||
when {
|
when {
|
||||||
expression {
|
expression {
|
||||||
return params.Build == true
|
return params.Build == true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
steps {
|
steps {
|
||||||
sh "docker push registry.jakecharman.co.uk/jakecharman.co.uk:$BUILD_NUMBER"
|
sh "docker push git.jakecharman.co.uk/jake/jakecharman.co.uk:$BUILD_NUMBER"
|
||||||
sh "docker push registry.jakecharman.co.uk/jakecharman.co.uk:latest"
|
sh "docker push git.jakecharman.co.uk/jake/jakecharman.co.uk:latest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,10 +77,10 @@ pipeline {
|
|||||||
}
|
}
|
||||||
steps{
|
steps{
|
||||||
node('web-staging') {
|
node('web-staging') {
|
||||||
sh "docker pull registry.jakecharman.co.uk/jakecharman.co.uk:latest"
|
sh "docker pull git.jakecharman.co.uk/jake/jakecharman.co.uk:latest"
|
||||||
sh "docker stop jake || true"
|
sh "docker stop jake || true"
|
||||||
sh "docker rm jake || true"
|
sh "docker rm jake || true"
|
||||||
sh "docker run --name jake -e DISCORD_ERR_HOOK=$DISCORD_ERR_STAGING -e DISCORD_WEBHOOK=$DISCORD -e TURNSTILE_SECRET=$TS --restart always --network containers_default -v /opt/containers/jc/projects/:/var/www/jc/projects/ -d registry.jakecharman.co.uk/jakecharman.co.uk:latest"
|
sh "docker run --name jake -e DISCORD_ERR_HOOK=$DISCORD_ERR_STAGING -e DISCORD_WEBHOOK=$DISCORD -e TURNSTILE_SECRET=$TS --restart always --network containers_default -v /opt/containers/jc/projects/:/var/www/jc/projects/ -d git.jakecharman.co.uk/jake/jakecharman.co.uk:latest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,20 +109,25 @@ pipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage('Deploy to production server') {
|
stage('Push to GCP registry') {
|
||||||
|
when {
|
||||||
|
expression {
|
||||||
|
return params.Build == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
steps {
|
||||||
|
sh "docker push europe-west2-docker.pkg.dev/jakecharman/web/jakecharman.co.uk:latest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Deploy to production') {
|
||||||
when {
|
when {
|
||||||
expression {
|
expression {
|
||||||
return params.Deploy == true
|
return params.Deploy == true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
steps{
|
steps{
|
||||||
node('web-server') {
|
sh "gcloud run deploy --project jakecharman --region europe-west1 --image europe-west2-docker.pkg.dev/jakecharman/web/jakecharman.co.uk:latest jakecharman-co-uk"
|
||||||
sh "docker pull registry.jakecharman.co.uk/jakecharman.co.uk:latest"
|
|
||||||
sh "docker stop jake || true"
|
|
||||||
sh "docker rm jake || true"
|
|
||||||
sh "docker run --name jake -e DISCORD_ERR_HOOK=$DISCORD_ERR_PROD -e DISCORD_WEBHOOK=$DISCORD -e TURNSTILE_SECRET=$TS --restart always --network containers_default -v /opt/containers/jc/projects/:/var/www/jc/projects/ -d registry.jakecharman.co.uk/jakecharman.co.uk:latest"
|
|
||||||
sh "/home/jenkins/clearCFCache/clearCache.py a514fb61e1413b88aabbb19df16b8508"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,12 +138,16 @@ pipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
steps {
|
steps {
|
||||||
node('web-server') {
|
git branch: 'master',
|
||||||
git branch: 'master',
|
credentialsId: 'Git',
|
||||||
credentialsId: 'Git',
|
url: '[email protected]:jake/jc-content.git'
|
||||||
url: '[email protected]:jake/jc-content.git'
|
sh "gsutil rsync -rcd . gs://jakecharman.co.uk"
|
||||||
sh "rsync -rv --delete ./ /opt/containers/jc/projects/"
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stage('Clear cache') {
|
||||||
|
steps{
|
||||||
|
sh "/var/lib/jenkins/clearCFCache/clearCache.py a514fb61e1413b88aabbb19df16b8508"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ set -x -o pipefail
|
|||||||
tag=$1
|
tag=$1
|
||||||
build=$2
|
build=$2
|
||||||
|
|
||||||
|
cat <<EOF >src/.buildinfo.json
|
||||||
|
{
|
||||||
|
"tag": "${tag}:${build}",
|
||||||
|
"date": "$(date -I)",
|
||||||
|
"host": "$(hostname -f)",
|
||||||
|
"user": "${USER}"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
docker build -t ${tag}:latest .
|
docker build -t ${tag}:latest .
|
||||||
if [[ $build != "" ]]; then
|
if [[ $build != "" ]]; then
|
||||||
docker build -t ${tag}:${build} .
|
docker build -t ${tag}:${build} .
|
||||||
|
|||||||
+14
-8
@@ -28,7 +28,7 @@
|
|||||||
# same ServerRoot for multiple httpd daemons, you will need to change at
|
# same ServerRoot for multiple httpd daemons, you will need to change at
|
||||||
# least PidFile.
|
# least PidFile.
|
||||||
#
|
#
|
||||||
ServerRoot "/usr/local/apache2"
|
ServerRoot "/usr/lib/apache2"
|
||||||
|
|
||||||
#
|
#
|
||||||
# Mutex: Allows you to set the mutex mechanism and mutex file directory
|
# Mutex: Allows you to set the mutex mechanism and mutex file directory
|
||||||
@@ -123,7 +123,7 @@ LoadModule deflate_module modules/mod_deflate.so
|
|||||||
#LoadModule brotli_module modules/mod_brotli.so
|
#LoadModule brotli_module modules/mod_brotli.so
|
||||||
LoadModule mime_module modules/mod_mime.so
|
LoadModule mime_module modules/mod_mime.so
|
||||||
#LoadModule ldap_module modules/mod_ldap.so
|
#LoadModule ldap_module modules/mod_ldap.so
|
||||||
LoadModule log_config_module modules/mod_log_config.so
|
#LoadModule log_config_module modules/mod_log_config.so
|
||||||
#LoadModule log_debug_module modules/mod_log_debug.so
|
#LoadModule log_debug_module modules/mod_log_debug.so
|
||||||
#LoadModule log_forensic_module modules/mod_log_forensic.so
|
#LoadModule log_forensic_module modules/mod_log_forensic.so
|
||||||
#LoadModule logio_module modules/mod_logio.so
|
#LoadModule logio_module modules/mod_logio.so
|
||||||
@@ -137,7 +137,7 @@ LoadModule headers_module modules/mod_headers.so
|
|||||||
#LoadModule usertrack_module modules/mod_usertrack.so
|
#LoadModule usertrack_module modules/mod_usertrack.so
|
||||||
#LoadModule unique_id_module modules/mod_unique_id.so
|
#LoadModule unique_id_module modules/mod_unique_id.so
|
||||||
LoadModule setenvif_module modules/mod_setenvif.so
|
LoadModule setenvif_module modules/mod_setenvif.so
|
||||||
LoadModule version_module modules/mod_version.so
|
#LoadModule version_module modules/mod_version.so
|
||||||
#LoadModule remoteip_module modules/mod_remoteip.so
|
#LoadModule remoteip_module modules/mod_remoteip.so
|
||||||
#LoadModule proxy_module modules/mod_proxy.so
|
#LoadModule proxy_module modules/mod_proxy.so
|
||||||
#LoadModule proxy_connect_module modules/mod_proxy_connect.so
|
#LoadModule proxy_connect_module modules/mod_proxy_connect.so
|
||||||
@@ -171,7 +171,7 @@ LoadModule version_module modules/mod_version.so
|
|||||||
#LoadModule lbmethod_bytraffic_module modules/mod_lbmethod_bytraffic.so
|
#LoadModule lbmethod_bytraffic_module modules/mod_lbmethod_bytraffic.so
|
||||||
#LoadModule lbmethod_bybusyness_module modules/mod_lbmethod_bybusyness.so
|
#LoadModule lbmethod_bybusyness_module modules/mod_lbmethod_bybusyness.so
|
||||||
#LoadModule lbmethod_heartbeat_module modules/mod_lbmethod_heartbeat.so
|
#LoadModule lbmethod_heartbeat_module modules/mod_lbmethod_heartbeat.so
|
||||||
LoadModule unixd_module modules/mod_unixd.so
|
#LoadModule unixd_module modules/mod_unixd.so
|
||||||
#LoadModule heartbeat_module modules/mod_heartbeat.so
|
#LoadModule heartbeat_module modules/mod_heartbeat.so
|
||||||
#LoadModule heartmonitor_module modules/mod_heartmonitor.so
|
#LoadModule heartmonitor_module modules/mod_heartmonitor.so
|
||||||
#LoadModule dav_module modules/mod_dav.so
|
#LoadModule dav_module modules/mod_dav.so
|
||||||
@@ -198,8 +198,7 @@ LoadModule dir_module modules/mod_dir.so
|
|||||||
LoadModule alias_module modules/mod_alias.so
|
LoadModule alias_module modules/mod_alias.so
|
||||||
#LoadModule rewrite_module modules/mod_rewrite.somod_wsgi-express start-server
|
#LoadModule rewrite_module modules/mod_rewrite.somod_wsgi-express start-server
|
||||||
|
|
||||||
LoadModule wsgi_module /usr/lib/apache2/modules/mod_wsgi.so
|
LoadModule wsgi_module /usr/local/lib/python3.14/site-packages/mod_wsgi/server/mod_wsgi-py314.cpython-314-x86_64-linux-gnu.so
|
||||||
|
|
||||||
|
|
||||||
<IfModule unixd_module>
|
<IfModule unixd_module>
|
||||||
#
|
#
|
||||||
@@ -368,7 +367,7 @@ LogLevel warn
|
|||||||
# TypesConfig points to the file containing the list of mappings from
|
# TypesConfig points to the file containing the list of mappings from
|
||||||
# filename extension to MIME-type.
|
# filename extension to MIME-type.
|
||||||
#
|
#
|
||||||
TypesConfig conf/mime.types
|
TypesConfig /etc/mime.types
|
||||||
|
|
||||||
#
|
#
|
||||||
# AddType allows you to add to or override the MIME configuration
|
# AddType allows you to add to or override the MIME configuration
|
||||||
@@ -423,7 +422,6 @@ ErrorDocument 503 /error/503
|
|||||||
ErrorDocument 505 /error/505
|
ErrorDocument 505 /error/505
|
||||||
|
|
||||||
WSGISocketPrefix /var/run/wsgi
|
WSGISocketPrefix /var/run/wsgi
|
||||||
|
|
||||||
WSGIDaemonProcess jc-wsgi user=www-data group=www-data threads=5
|
WSGIDaemonProcess jc-wsgi user=www-data group=www-data threads=5
|
||||||
WSGIProcessGroup jc-wsgi
|
WSGIProcessGroup jc-wsgi
|
||||||
WSGIScriptAlias / /var/www/jc/projects.wsgi
|
WSGIScriptAlias / /var/www/jc/projects.wsgi
|
||||||
@@ -434,6 +432,14 @@ WSGIScriptAlias / /var/www/jc/projects.wsgi
|
|||||||
Require all granted
|
Require all granted
|
||||||
</Directory>
|
</Directory>
|
||||||
|
|
||||||
|
Alias "/static" "/var/www/jc/static/"
|
||||||
|
<Directory /var/www/jc/static>
|
||||||
|
Order allow,Deny
|
||||||
|
Allow from all
|
||||||
|
</Directory>
|
||||||
|
|
||||||
|
Alias "/robots.txt" "/var/www/jc/static/robots.txt"
|
||||||
|
|
||||||
<Files jc.wsgi>
|
<Files jc.wsgi>
|
||||||
Require all granted
|
Require all granted
|
||||||
</Files>
|
</Files>
|
||||||
|
|||||||
Executable
+9
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/bash --noprofile
|
||||||
|
|
||||||
|
run() {
|
||||||
|
python3 src/projects.wsgi || run
|
||||||
|
}
|
||||||
|
|
||||||
|
export DISCORD_ERR_HOOK='dummy'
|
||||||
|
run
|
||||||
|
unset DISCORD_ERR_HOOK
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"tag": "jc-ng-localtest:",
|
||||||
|
"date": "2025-11-02",
|
||||||
|
"host": "jake-e580",
|
||||||
|
"user": "jake"
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from os import path
|
||||||
|
from datetime import datetime
|
||||||
|
import json
|
||||||
|
from flask import request, Response, redirect
|
||||||
|
from index import app
|
||||||
|
from projects import md_directory, get_by_meta_key
|
||||||
|
|
||||||
|
database = '/tmp/db' #path.join(md_directory, 'comments.db')
|
||||||
|
with sqlite3.Connection(database) as db:
|
||||||
|
db.execute('CREATE TABLE IF NOT EXISTS comments (date INTEGER, article TEXT, name TEXT, comment TEXT)')
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
@app.route('/comments/<article>', methods=['GET', 'POST'])
|
||||||
|
def post_comments(article: str):
|
||||||
|
match request.method:
|
||||||
|
case 'POST':
|
||||||
|
if len(get_by_meta_key(md_directory, 'id', article)) == 0:
|
||||||
|
return Response(status=404)
|
||||||
|
with sqlite3.Connection(database) as db:
|
||||||
|
db.execute('INSERT INTO comments (date, article, name, comment) VALUES (?, ?, ?, ?)', (datetime.now(), article, request.form.get('name'), request.form.get('comment')))
|
||||||
|
db.commit()
|
||||||
|
return redirect(f'/projects/{article}')
|
||||||
|
case 'GET':
|
||||||
|
if len(get_by_meta_key(md_directory, 'id', article)) == 1:
|
||||||
|
with sqlite3.Connection(database) as db:
|
||||||
|
res = db.execute('SELECT * FROM comments WHERE `article` = ?', (article,))
|
||||||
|
return json.dumps([{'author': x[2], 'date': x[0], 'comment': x[3]} for x in res.fetchall()])
|
||||||
+10
-7
@@ -6,7 +6,7 @@ from flask import request, render_template
|
|||||||
from requests import post, get
|
from requests import post, get
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
from textwrap import dedent
|
from textwrap import dedent
|
||||||
|
from traceback import format_exc
|
||||||
def validate_turnstile(response: str, ip: str) -> bool:
|
def validate_turnstile(response: str, ip: str) -> bool:
|
||||||
turnstile_secret = environ['TURNSTILE_SECRET']
|
turnstile_secret = environ['TURNSTILE_SECRET']
|
||||||
cf_response = post(
|
cf_response = post(
|
||||||
@@ -16,7 +16,8 @@ def validate_turnstile(response: str, ip: str) -> bool:
|
|||||||
'response': response,
|
'response': response,
|
||||||
'remoteip': ip,
|
'remoteip': ip,
|
||||||
'idempotency_key': uuid4()
|
'idempotency_key': uuid4()
|
||||||
}
|
},
|
||||||
|
timeout=30
|
||||||
).json()
|
).json()
|
||||||
|
|
||||||
return cf_response.get('success', False)
|
return cf_response.get('success', False)
|
||||||
@@ -24,7 +25,8 @@ def validate_turnstile(response: str, ip: str) -> bool:
|
|||||||
def send_to_discord(form: dict) -> bool:
|
def send_to_discord(form: dict) -> bool:
|
||||||
try:
|
try:
|
||||||
discord_hook = environ['DISCORD_WEBHOOK']
|
discord_hook = environ['DISCORD_WEBHOOK']
|
||||||
except:
|
except KeyError:
|
||||||
|
app.logger.error(format_exc())
|
||||||
return False
|
return False
|
||||||
discord_msg = dedent(
|
discord_msg = dedent(
|
||||||
f'''
|
f'''
|
||||||
@@ -42,11 +44,12 @@ def send_to_discord(form: dict) -> bool:
|
|||||||
data={
|
data={
|
||||||
'username': form.get('name'),
|
'username': form.get('name'),
|
||||||
'content': discord_msg
|
'content': discord_msg
|
||||||
}
|
},
|
||||||
).status_code
|
timeout=30
|
||||||
|
)
|
||||||
if discord_response == 204:
|
if discord_response.status_code == 204:
|
||||||
return True
|
return True
|
||||||
|
app.logger.error(discord_response.status_code, discord_response.text)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@app.route('/contact/', methods=('GET', 'POST'))
|
@app.route('/contact/', methods=('GET', 'POST'))
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ from flask import Flask, render_template, Response
|
|||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
# These imports need to come after our app is defined as they add routes to it.
|
# These imports need to come after our app is defined as they add routes to it.
|
||||||
|
import sitemap # pylint: disable=wrong-import-position,unused-import
|
||||||
import projects # pylint: disable=wrong-import-position,unused-import
|
import projects # pylint: disable=wrong-import-position,unused-import
|
||||||
import contact # pylint: disable=wrong-import-position,unused-import
|
import contact # pylint: disable=wrong-import-position,unused-import
|
||||||
|
import comments # pylint: disable=wrong-import-position,unused-import
|
||||||
|
|
||||||
class DiscordLogger(logging.Handler):
|
class DiscordLogger(logging.Handler):
|
||||||
''' Simple logging handler to send a message to Discord '''
|
''' Simple logging handler to send a message to Discord '''
|
||||||
@@ -81,3 +83,6 @@ def error(code) -> str:
|
|||||||
return render_template('error.html',
|
return render_template('error.html',
|
||||||
error=f'{code}: {error_definitions.get(int(code))}',
|
error=f'{code}: {error_definitions.get(int(code))}',
|
||||||
description=error_desc.get(int(code)))
|
description=error_desc.get(int(code)))
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run()
|
||||||
+1
-1
@@ -45,7 +45,7 @@ def to_html(content: str) -> str:
|
|||||||
''' Jninja filter to wrap markdown '''
|
''' Jninja filter to wrap markdown '''
|
||||||
return markdown(content)
|
return markdown(content)
|
||||||
|
|
||||||
def get_all_posts(directory: str) -> list:
|
def get_all_posts(directory: str = md_directory) -> list:
|
||||||
''' Get all posts in the posts directory '''
|
''' Get all posts in the posts directory '''
|
||||||
abs_paths = [path.join(directory, x) for x in glob(f'{directory}/*.md')]
|
abs_paths = [path.join(directory, x) for x in glob(f'{directory}/*.md')]
|
||||||
return [frontmatter.load(x) for x in abs_paths]
|
return [frontmatter.load(x) for x in abs_paths]
|
||||||
|
|||||||
@@ -4,3 +4,6 @@ import sys
|
|||||||
sys.path.append('/var/www/jc')
|
sys.path.append('/var/www/jc')
|
||||||
|
|
||||||
from index import app as application
|
from index import app as application
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
application.run(debug=True)
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
setuptools>=78.1.1
|
||||||
flask>=2.2.3
|
flask>=2.2.3
|
||||||
flask-markdown>=0.3
|
flask-markdown>=0.3
|
||||||
markdown>=3.4.1
|
markdown>=3.4.1
|
||||||
@@ -5,3 +6,4 @@ beautifulsoup4>=4.11.1
|
|||||||
python-frontmatter>=1.1.0
|
python-frontmatter>=1.1.0
|
||||||
requests>=2.32.3
|
requests>=2.32.3
|
||||||
pillow>=11.0.0
|
pillow>=11.0.0
|
||||||
|
mod_wsgi>=5.0.2
|
||||||
|
|||||||
Executable
+43
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
import json
|
||||||
|
from flask import url_for, request, Response
|
||||||
|
from re import match
|
||||||
|
from index import app
|
||||||
|
from projects import get_all_posts
|
||||||
|
|
||||||
|
def get_routes() -> list:
|
||||||
|
routes = []
|
||||||
|
for rule in app.url_map.iter_rules():
|
||||||
|
if 0 >= len(rule.arguments):
|
||||||
|
url = url_for(rule.endpoint, **(rule.defaults or {}))
|
||||||
|
routes.append(url)
|
||||||
|
return routes
|
||||||
|
|
||||||
|
def get_build_date():
|
||||||
|
try:
|
||||||
|
with open('/var/www/jc/.buildinfo.json', encoding='utf8') as build:
|
||||||
|
build_json = json.load(build)
|
||||||
|
return build_json['date']
|
||||||
|
except:
|
||||||
|
return '1970-01-01'
|
||||||
|
|
||||||
|
@app.route('/sitemap.xml')
|
||||||
|
def sitemap():
|
||||||
|
date = get_build_date()
|
||||||
|
root = ET.Element('urlset', xmlns='http://www.sitemaps.org/schemas/sitemap/0.9')
|
||||||
|
base_url = match(r'^https?:\/\/.+:?\d*(?=\/)', request.base_url).group()
|
||||||
|
base_url = base_url.replace('http://', 'https://')
|
||||||
|
for route in get_routes():
|
||||||
|
url = ET.SubElement(root, 'url')
|
||||||
|
ET.SubElement(url, 'loc').text = base_url + route
|
||||||
|
ET.SubElement(url, 'lastmod').text = date
|
||||||
|
for article in get_all_posts():
|
||||||
|
if 'link' in article.metadata:
|
||||||
|
continue
|
||||||
|
url = ET.SubElement(root, 'url')
|
||||||
|
ET.SubElement(url, 'loc').text = f'{base_url}/projects/{article.metadata['id']}'
|
||||||
|
ET.SubElement(url, 'lastmod').text = article.metadata['date'].strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
return Response(ET.tostring(root, encoding='utf-8'), 200, {'content-type': 'application/xml'})
|
||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://jakecharman.co.uk/sitemap.xml
|
||||||
@@ -1,22 +1,25 @@
|
|||||||
@media (min-width: 1000px) {
|
@media (min-width: 1000px) {
|
||||||
#technology{
|
#technology{
|
||||||
background-position-x: 35vw;
|
background-position-x: 35vw;
|
||||||
height: 75vh;
|
height: fit-content;
|
||||||
min-height: 75vh;
|
min-height: 75vh;
|
||||||
|
background-position-y: 780px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#motorsport{
|
#motorsport{
|
||||||
background-position-y: -180px;
|
background-position-y: -180px;
|
||||||
background-position-x: -200px;
|
background-position-x: -200px;
|
||||||
background-size: auto;
|
background-size: auto;
|
||||||
|
height: fit-content;
|
||||||
min-height: 75vh;
|
min-height: 75vh;
|
||||||
height: 75vh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.text{
|
.text{
|
||||||
width: 50%;
|
width: 50%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
height: fit-content;
|
||||||
|
min-height: 75vh
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-right{
|
.text-right{
|
||||||
@@ -29,6 +32,13 @@
|
|||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gradient {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
background-image: linear-gradient(to top, rgba(23, 22, 20, 1) 70%, rgba(23, 22, 20, 0));
|
||||||
|
min-height: 75vh;
|
||||||
|
}
|
||||||
|
|
||||||
.gradient-left{
|
.gradient-left{
|
||||||
background-image: linear-gradient(to right, rgba(23, 22, 20, 1), rgba(23, 22, 20, 1), rgba(23, 22, 20, 0));
|
background-image: linear-gradient(to right, rgba(23, 22, 20, 1), rgba(23, 22, 20, 1), rgba(23, 22, 20, 0));
|
||||||
}
|
}
|
||||||
@@ -38,7 +48,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#projects{
|
#projects{
|
||||||
align-items: center;
|
align-items: stretch;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -48,7 +58,6 @@
|
|||||||
|
|
||||||
.project{
|
.project{
|
||||||
width: 20vw;
|
width: 20vw;
|
||||||
height: 25vw;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#top-nav{
|
#top-nav{
|
||||||
@@ -67,4 +76,42 @@
|
|||||||
#article>p>img{
|
#article>p>img{
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
}
|
#article>video{
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
#certs>a {
|
||||||
|
max-width: 15%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#techlogos {
|
||||||
|
max-height: 40%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#techlogos>a{
|
||||||
|
max-width: 5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt {
|
||||||
|
width: calc(66% - 40px);
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery {
|
||||||
|
display: flex;
|
||||||
|
height: fit-content;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery * {
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery>img {
|
||||||
|
width: calc(33% - 40px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+76
-10
@@ -13,13 +13,17 @@ 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;
|
||||||
@@ -48,33 +52,32 @@ footer h2, section h2{
|
|||||||
|
|
||||||
#technology{
|
#technology{
|
||||||
background-image: url(../images/njr-code.png);
|
background-image: url(../images/njr-code.png);
|
||||||
height: 100vh;
|
height: fit-content;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
background-position-x: -400px;
|
background-position-x: -400px;
|
||||||
|
background-position-y: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#motorsport{
|
#motorsport{
|
||||||
background-image: url(../images/topfuel_startline.jpg.jpeg);
|
background-image: url(../images/topfuel_startline.jpg.jpeg);
|
||||||
height: 100vh;
|
height: fit-content;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
background-position-x: -65px;
|
background-position-x: -65px;
|
||||||
background-position-y: -90px;
|
background-position-y: -100px;
|
||||||
background-size: 200%;
|
background-size: 200%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gradient{
|
.gradient{
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: fit-content;
|
||||||
background-image: linear-gradient(to top, rgba(23, 22, 20, 1) 70%, rgba(23, 22, 20, 0));
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.text{
|
.text{
|
||||||
height: 70%;
|
height: fit-content;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
background-color: rgba(23, 22, 20, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.text>div{
|
.text>div{
|
||||||
@@ -209,10 +212,73 @@ label{
|
|||||||
|
|
||||||
#article{
|
#article{
|
||||||
padding: 0 10px 0 10px;
|
padding: 0 10px 0 10px;
|
||||||
|
margin-bottom: 25px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#article>p>img{
|
#article>p>img{
|
||||||
display: block;
|
display: block;
|
||||||
margin: 0 auto 0 auto;
|
margin: 0 auto 0 auto;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
max-height: 50vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
#article>video{
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto 0 auto;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 50vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre{
|
||||||
|
overflow-x: auto;
|
||||||
|
background-color: #36332f;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#certs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#certs>a {
|
||||||
|
max-width: 25%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#certs>a>img {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#techlogos {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
max-height:30%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#techlogos>a {
|
||||||
|
max-width: 10%;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#techlogos>a>img {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
height: 30vh;
|
||||||
|
width: 100%;
|
||||||
|
background-image: linear-gradient(to top, rgba(23, 22, 20, 1) 10%, rgba(23, 22, 20, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt {
|
||||||
|
width: 100%;
|
||||||
|
height: 22vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery * {
|
||||||
|
padding: 10px;
|
||||||
|
max-width: 80vw;
|
||||||
}
|
}
|
||||||
@@ -6,5 +6,21 @@
|
|||||||
<hr />
|
<hr />
|
||||||
{{post|safe}}
|
{{post|safe}}
|
||||||
</section>
|
</section>
|
||||||
|
<section id="comments">
|
||||||
|
<h2>{{ comments | length }} comments</h2>
|
||||||
|
{% for comment in comments %}
|
||||||
|
<div class="comment">
|
||||||
|
<strong>{{ comment[2] }}</strong>
|
||||||
|
<p>{{ comment[3] }}</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<form action="/comments/{{ metadata.id }}" method="post">
|
||||||
|
<label for="name">Name:</label>
|
||||||
|
<input type="text" name="name" />
|
||||||
|
<label for="comment">Comment:</label>
|
||||||
|
<textarea name="comment"></textarea>
|
||||||
|
<input type="submit" name="submit" value="Submit">
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
{% include 'footer.html' %}
|
{% include 'footer.html' %}
|
||||||
+44
-11
@@ -2,18 +2,46 @@
|
|||||||
<main>
|
<main>
|
||||||
<section id="technology">
|
<section id="technology">
|
||||||
<div class="gradient gradient-left">
|
<div class="gradient gradient-left">
|
||||||
|
<div class="spacer"></div>
|
||||||
<div class="text text-left">
|
<div class="text text-left">
|
||||||
<div>
|
<div>
|
||||||
<h2>Technology</h2>
|
<h2>Technology</h2>
|
||||||
<hr>
|
<hr>
|
||||||
<p>Working with technology is my day job, I currently specialise in:</p>
|
<p>Technology is my day job. I design and build distributed systems for large companies around the world.</p>
|
||||||
<ul>
|
<p>
|
||||||
<li>Linux (primarily RHEL & Debian based)</li>
|
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.
|
||||||
<li>SCM with Git and Subversion</li>
|
</p>
|
||||||
<li>Big Data (Hadoop & Cloud storage)</li>
|
<p>
|
||||||
<li>Programming (Python, Bash & C#)</li>
|
Below are some of the technologies I'm using most at the moment.
|
||||||
</ul>
|
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>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>
|
</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">
|
<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://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>
|
<a class="button" href="https://github.com/jcharman/"><i class="fa-brands fa-github"></i></a>
|
||||||
@@ -24,13 +52,18 @@
|
|||||||
</section>
|
</section>
|
||||||
<section id="motorsport">
|
<section id="motorsport">
|
||||||
<div class="gradient gradient-right">
|
<div class="gradient gradient-right">
|
||||||
|
<div class="spacer"></div>
|
||||||
<div class="text text-right">
|
<div class="text text-right">
|
||||||
<div>
|
<div>
|
||||||
<h2>Racing</h2>
|
<h2>Racing</h2>
|
||||||
<hr />
|
<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>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>The photo for this section was taken by <a href="https://www.facebook.com/BlackettPhotography">Blackett Photography</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>You can read more about our racing at <a href="https://nitrojunkie.uk">nitrojunkie.uk</a></p>
|
<p>I've also had the opportunity to work on a 10,000 horsepower Top Fuel Dragster. I <a href="https://nitrojunkie.uk/news/post/10000_horses">wrote about this on the Nitro Junkie website.</a></p>
|
||||||
|
<div class="gallery">
|
||||||
|
<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">
|
<div class="social">
|
||||||
<a class="button" href="https://nitrojunkie.uk"><i class="fa-solid fa-globe"></i></a>
|
<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.youtube.com/@NitroJunkieUK"><i class="fa-brands fa-youtube"></i></a>
|
||||||
|
|||||||
Reference in New Issue
Block a user