39 Commits

Author SHA1 Message Date
fff4004a72 Added a new button :D
All checks were successful
Deploy / Deploy website via rsync over SSH (push) Successful in 8s
2025-12-03 17:44:18 +01:00
0a5cb1e850 Added a other page, and written a snuff page.
All checks were successful
Deploy / Deploy website via rsync over SSH (push) Successful in 4s
2025-11-30 04:31:29 +01:00
b58f06bced Added a terms of service page, and fixed no embed image, and updated the sitemap
All checks were successful
Deploy / Deploy website via rsync over SSH (push) Successful in 5s
2025-11-29 16:51:29 +01:00
5e8b8fc2ee Add a ygg.html page, and fix a small thing in the <head> tag.
All checks were successful
Deploy / Deploy website via rsync over SSH (push) Successful in 7s
2025-11-29 16:21:03 +01:00
ff755af41e Change the wording a little bit in the services.html, and add vaultwarden to it.
All checks were successful
Deploy / Deploy website via rsync over SSH (push) Successful in 6s
2025-11-18 07:36:22 +01:00
582c2c5b97 Delete .idea/modules.xml
All checks were successful
Deploy / Deploy website via rsync over SSH (push) Successful in 3s
2025-11-16 00:31:40 +00:00
f7a1538c39 Minor bug fix
All checks were successful
Deploy / Deploy website via rsync over SSH (push) Successful in 3s
2025-11-16 01:30:56 +01:00
c50ea67865 Add an about me page, and a git ignore file.
All checks were successful
Deploy / Deploy website via rsync over SSH (push) Successful in 6s
2025-11-13 20:57:07 +01:00
f1c341d791 Update the sitemap
All checks were successful
Deploy / Deploy website via rsync over SSH (push) Successful in 2s
2025-11-10 23:37:56 +01:00
fd72cd4445 Added a services page, might change it in the future
All checks were successful
Deploy / Deploy website via rsync over SSH (push) Successful in 3s
2025-11-10 23:35:14 +01:00
6470e8daa7 Added a hamburger menu for the navbar
All checks were successful
Deploy / Deploy website via rsync over SSH (push) Successful in 5s
2025-11-10 23:19:04 +01:00
a44530c49c Change the CSS to work better on smaller screens and mobile. 2025-11-10 23:15:48 +01:00
81b8a8968a Small changes to the HTML head tag
All checks were successful
Deploy / Deploy website via rsync over SSH (push) Successful in 3s
2025-11-05 21:44:55 +01:00
fdfd3ae003 Fix the contact page.
All checks were successful
Deploy / Deploy website via rsync over SSH (push) Successful in 3s
2025-11-05 21:40:34 +01:00
f28b1dee3d Add my openPGP key
All checks were successful
Deploy / Deploy website via rsync over SSH (push) Successful in 5s
2025-11-04 23:09:56 +01:00
572c9ec925 small nabar bug fix.
All checks were successful
Deploy / Deploy website via rsync over SSH (push) Successful in 3s
2025-10-31 11:21:39 +01:00
e175e01ef1 Merge branch 'rewrite-static' of git.purplebored.pl:purplebored/purplebored.pl into rewrite-static
All checks were successful
Deploy / Deploy website via rsync over SSH (push) Successful in 3s
2025-10-31 11:18:32 +01:00
0b44ee9ab3 Added some more stuff to the navbar, and added a contact page 2025-10-31 11:17:47 +01:00
fc55b5f5e4 Update .gitea/workflows/deploy.yml
All checks were successful
Deploy / Deploy website via rsync over SSH (push) Successful in 3s
2025-10-30 22:48:40 +00:00
1d8b50de83 Last time I try it today.
Some checks failed
Deploy / Deploy website via rsync over SSH (push) Failing after 2s
2025-10-30 22:35:33 +00:00
d1f0f5a506 Update deploy.yml
Some checks failed
Deploy / Deploy website via rsync over SSH (push) Has been cancelled
2025-10-30 23:25:33 +01:00
ea702c6eed If this not work I give up for today
Some checks failed
Deploy Website / deploy (push) Failing after 3s
2025-10-30 22:18:12 +00:00
2534cbcdf5 Fuck you
Some checks failed
Deploy Website / deploy (push) Failing after 2s
2025-10-30 22:15:56 +00:00
b7083e5b42 Add AI debug shit to help me fix this shit 2025-10-30 22:11:32 +00:00
f178e718d7 Update .gitea/workflows/deploy.yml 2025-10-30 22:10:10 +00:00
fa80126d47 updated the deploy 2025-10-30 23:05:59 +01:00
cfed6c0dde Add a deploy action
Some checks failed
Deploy Website / deploy (push) Failing after 2s
2025-10-30 23:02:26 +01:00
7eaa623703 Updated the navbar, andded a project page 2025-10-30 22:08:29 +01:00
eb82362a61 Run CSS Autoprefixer on the styleshit 2025-10-30 18:00:40 +01:00
4937f6c391 Small oopsie fix the footer. 2025-10-30 17:58:07 +01:00
22360d3904 Added a footer 2025-10-30 17:52:57 +01:00
270450f248 Added a basic readme and robots file and created the sitemap.xml file. 2025-10-29 22:46:50 +01:00
ec500ce89a Add clounds on the bottom, and mess around with the navbar and change some stuff in the head tag. 2025-10-29 22:41:14 +01:00
e44fc8b08d Fix last part of CSS 2025-10-29 21:19:42 +01:00
60ac90124f Create LICENSE
Added the License file
2025-10-29 21:13:30 +01:00
b380449e7c A lot of CSS fuckery, added uzi and a navbar. 2025-10-29 21:10:54 +01:00
34e47d74f4 Added Fonts 2025-10-29 20:36:14 +01:00
33db4b0b2c Add the buttons, add the background made my ari, add some basic css and basic index design. 2025-10-29 20:30:47 +01:00
d95be6e453 Removed all the files to start over. 2025-10-29 20:12:08 +01:00
84 changed files with 1861 additions and 2661 deletions

View File

@@ -1,11 +0,0 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 4
charset = utf-8
trim_trailing_whitespace = true
tab_width = 2

View File

@@ -1,13 +0,0 @@
module.exports = {
"env": {
"browser": true,
"es2021": true
},
"extends": "eslint:recommended",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"rules": {
}
}

View File

@@ -0,0 +1,57 @@
name: Deploy
on:
push:
branches:
- rewrite-static
jobs:
deploy:
name: Deploy website via rsync over SSH
runs-on: [self-hosted, linux]
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Check required secrets
run: |
echo "Checking secrets availability..."
for var in DEPLOY_KEY DEPLOY_USER DEPLOY_HOST DEPLOY_PORT DEPLOY_PATH; do
if [ -z "${!var}" ]; then
echo "Error: Secret $var is empty or undefined!" && exit 1
else
echo "Found secret $var (length: ${#var})"
fi
done
shell: bash
- name: Set up SSH key
run: |
echo "Setting up SSH environment..."
mkdir -p ~/.ssh
chmod 700 ~/.ssh
echo "$DEPLOY_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -p "$DEPLOY_PORT" -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null
echo "SSH setup complete."
shell: bash
- name: Deploy via rsync
run: |
echo "Starting rsync deployment..."
rsync -avz \
--delete \
-e "ssh -i ~/.ssh/id_ed25519 -p $DEPLOY_PORT -o StrictHostKeyChecking=no -o ConnectTimeout=10" \
./ "${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}"
echo "Deployment finished successfully."
shell: bash

172
.gitignore vendored
View File

@@ -1,171 +1 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
.idea/
# Databases
*.db
# Other
.ccls-cache/
!py.typed
.key
*.wasm
/.idea/

View File

@@ -1,4 +0,0 @@
{
"singleQuote": false
}

View File

@@ -0,0 +1,52 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mQINBGaE3vkBEADykpF/yUW3bWN7mnQLQPWZBC/OOuBoSW8X7DoicPBd/fV2vNWW
t+M7UY0NUZQkyvVaj1EVLY7A0Y+SPoonPqcFvme4Z6waC5fYUEn34OTR1/4/epTy
0vXLm0HHylvMk/Fxf4uf/vXDfOamwuWoWWIvaQNPbacCwDdhwEiTSPgpeLNSUBcW
1jdsf4FCvyZeFTBi889TyF3qICok7XHkZCW3pNcGEu5tn3+OK4DCsL93lSOK/rwf
sLESt2d5Qny+mXDn6acSk7/DhjuYp6NX3hJpK74NuthMXJUWPtI/GJv40siQ0+sI
uFTEsm3I1oWeEy2txw7uMeJOJIVph+IJNxq2m7msB9ubJNlatFBpZsFa4cORrs60
KRIHhRCTJLSUIO07voTZ1vmpgJfqC4v1BROqZJHRyFYEhGbr80CANVPuwYbkAmTQ
VXHNNEg3i65PEu5iBqBz6ZPc17wX5hwtfehaDSftwV30hMxpVkVcmyAwltGfXOjn
2JOEai1i/vO8ytKNGpX1QeGuqWTE5pBWcCWq0LUQyibNJvrLVJ3XWhY1hR6icJUo
yfqB0iqoj2ykaBxEYR0xP4/hQ76DB+hXROH78Okp27B4p2EKaP92UQJhg2IMeqRP
+kbs0DGYFTYdie9wDXOhmmBVTmnHqOlNNICQCDrCXakESQgAgeNjWOVepwARAQAB
tB1BbGVrIDxwdXJwbGVib3JlZEBwb3N0ZW8uY29tPokCVwQTAQgAQRYhBAy1JpDN
ZEQT72aiHK7y+ajAM0ZwBQJmhN75AhsDBQkFxNInBQsJCAcCAiICBhUKCQgLAgQW
AgMBAh4HAheAAAoJEK7y+ajAM0ZwDxoQAOq+441ALQg8kZSGSvkcxzZIOWCXFgtV
Ym8zFzB70mnkLD7wTN/mZJQow1bTdDeu2h1KzhrGhfR5a1meOCxMU7XtRPWUSVp/
1g4d0J80rivyXajnvbDkcyiBlHv780eKhgZuXkCiDKzXEdHnqMOv2qK0PeubKqso
fiULDZjjzsQUP2WF8oom7j8Z50zT4LvNL+pe6jfv1sj0keaF84DOS6tWb/qtzo8P
Y6V+ndyuDsWj9FDHRHAqYMg6jIlyq5KFvNwY5rQN/EvO3Ra0jQQLFN0w/GoYUtLw
WZbolJyWyxPoMbDhPP4ZdqUC7UQwEoTTFD8p2oT9jInoBt2GFgK4x0NpehuWMQj2
d0WAh8EV9jU37Sq2dMoAdruHLK1MaxgCwCSEdKuDV8nZnYxQrXnFN5+qYuvFGvWj
vkwipeHGFcjRFAljLi4o+WyrkOWFE/ZO9WhiaeSoDqpRc5l+DsBjFIWvjBhU6Gh8
4CgtnygfHJPRwoOdbeR1a6xmKXrJ4FkXz8zGgNzdwqLAZzA1Zt4T0JYHDsOW0Ru3
3RS+XvUVItWgDyKl+8EWdPqX4cqK5jnGWtrf78ft18H+F/JBQg1Ks74vDxANBASW
HWsjjjm5ueMtgeR6i0gPa4Dlwkz6K88Ssx2o5rEkcE+V3gKPjHQ9+nGkDKaUTPsI
Tre6Ti8c7tFfuQINBGaE3vkBEADnahL3uK7iFPZCAJtMRm3GyClWojxtLhPL5BQB
7qv1G8jO/NABR7VLeDGXfkli9WSH6Dd3YMvN+RwFIHkZOpGhdNNnUmx7UOpk19qo
09Sb9pt82tC3+gGDbnNbLBOwgKOFEP3A/sVyhg3x3UdDLbfxMp5Wo4omZldjdDP+
xr1xr+zfoRojXLdq3+9c2DUuhCcwpbfEveMruR50qRZHhNafwj+oRk2lalOKRLvf
KqQ1m/rDVQTCQZNZpBpOqs1FzWj3GgyynoJ8g3UaxhfX0mP5HTPky9+vyjU/Qf7u
/i9f3fmpDgQrBLwP+9QHXMVPHW6XQY6NlThqeX9MZ36mLnmWOFLSPxEno1m9ke4v
C6VR9Qnz7ofK6Ia4xWrjdA70hrFNEzlV3n58Zp+K05eAoXXx8arj2yco/BusZMau
Z4WmD4k/O4rQWcJHo/NuupkgvsuKWu4JdYouP46PhVkH05hzEObgqh0ToapNhDh+
7+UYFRVqmnBe/G1U6kLDKHae/S//iidJxwxG9yqbhJFcSr/on22vOoGdI/ddprcI
zyVxbBbsPdKAMuHizjj6bLmthcYlWdclEkM7Xp0PWv2iZ6tTt5Ptj3Tg7Jm9CfWK
Efwe23kSr9NIzokHcPKyDgX7AJe6i8bz90S3/EVyslF2EzaA2dY4QDBvpFrIPYDj
Uh8jawARAQABiQI8BBgBCAAmFiEEDLUmkM1kRBPvZqIcrvL5qMAzRnAFAmaE3vkC
GwwFCQXE0icACgkQrvL5qMAzRnDikw/9G95ftdCwtFzujW0FXk4D0tfxfk694/4k
Edt3dZ5cGyMjQbwf9O6FMvkvTIwL9izy9kBcRDNE/2nXFBsEInpE2C3vYKW8aoYV
qYZdYtNyRBX59x7Aq40NHqBlWuVLHXJGp81RZEiZW4rX9TmxXrW1aH/cC2KUtMgP
L6IqsDy3O6bKklDH+zYOk/V0En6JK4P3DsxEx084IiF/8vbMs7dMa9cdfQ3dm/xt
grCrMmwZL9Y+NCkTycpQxncFMjg9J12XLzMacMfrILZuiRKvwsRuXkDrP9m+4Z5Z
t6a6hFu74iCxWebJ9YAKbbHzzr/WB0/F1P3HEG2Djjspbim6RHSaYeRQtMv5Hai3
1Rpi35nq42e1nTXHiwqwftOeRoYLTzG93MUaPwQxszWbQH6iNTB2wLRN0Xy8JNK/
UtwsOn3tuFgqgWWwtEoNrhAn+kP+besKPV5sqDH/GBb1C9yL7qzrLC93wraPn1CY
pbERnblU66hNa+WWMVCF4W3UUIC452FfSjp003CvyRk6ukgqdNeaaJBe9VErjfLd
4fu0nkuV19w0qL5sij+Tjq0SV6f1vJW3L427iG6+d8VM+1tOqifsFDdBNVOkH/HD
ETvMCC7CZ1tS4WhF5h6zSW1R8u4auY97dJdGIQtfwzexKqnNo5gsYik8NU0onmJf
KyoBhwIfl1o=
=qhIS
-----END PGP PUBLIC KEY BLOCK-----

View File

@@ -187,7 +187,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Copyright [2023 - 2025] [Purplebored]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.

View File

@@ -1,44 +1,21 @@
# purplebored.pl
## purplebored.pl
> Just my personal website
Welcome to my personal website, located at https://purplebored.pl ! <br>
This is a simple website that serves as my personal space on the internet. (Such fancy wording ik)
## Running
### License Information
Licensed under the [Apache License, Version 2.0](LICENSE)
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
First compile WASM PoW:
http://www.apache.org/licenses/LICENSE-2.0
```sh
cd src/static/js/wasm
./compile.sh
cd ../../../../
```
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License. For the full licene chcek out the LICENSE file
(So much yap about a license)
Then:
```sh
python3 -m venv venv
source venv/bin/activate
source .env # see example.env
pip install --upgrade -r requirements.txt
cd src
memcached -d
# If you remove migrations/:
# flask db init
# vim migrations/script.py.mako (add import flask_app)
# flask db migrate -m 'Initial migration'
flask db upgrade
flask run
```
For production use a WSGI server such as [Gunicorn](https://pypi.org/project/gunicorn/):
```sh
python3 -m venv venv
source venv/bin/activate
source .env # see example.env
pip install --upgrade -r requirements.txt
pip install --upgrade gunicorn
cd src
memcached -d
flask db upgrade
python3 -m gunicorn -b 127.0.0.1:12345 -w 4 app:app # ... Or whatever your configuration is
```
### Contributing
If you want to contribute something to my site please create a issue first, or if you know me, then just DM me about it first!

491
assets/css/style.css Normal file
View File

@@ -0,0 +1,491 @@
/*
* Prefixed by:
* PostCSS: v7.0.29,
* Autoprefixer: v9.7.6
* Browsers: last 4 version
*/
@font-face {
font-family: "Clockopia";
src: url("../fonts/Clockopia.ttf") format("truetype");
}
@font-face {
font-family: "LuxiSans";
src: url("../fonts/luxisr.ttf") format("truetype");
}
body {
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
min-height: 100vh;
background-color: #0e0016;
color: white;
font-family: "system-ui", sans-serif;
overflow-x: hidden;
padding-right: 140px;
}
.navbar {
width: 140px;
background: rgba(60, 26, 77, 0.55);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-left: 1px solid rgba(255, 255, 255, 0.05);
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
padding-top: 2rem;
padding-bottom: 2rem;
position: fixed;
right: 0;
top: 0;
bottom: 0;
z-index: 20;
box-shadow: -2px 0 10px rgba(0, 0, 0, 0.4);
}
.navbar ul {
list-style: none;
margin: 0;
padding: 0;
width: 100%;
}
.navbar li {
width: 100%;
position: relative;
}
.navbar a {
display: block;
color: white;
text-decoration: none;
text-align: center;
padding: 0.75rem 0;
position: relative;
transition: background 0.2s ease, color 0.2s ease;
}
.navbar a::before,
.navbar a::after {
content: "";
display: block;
position: absolute;
left: 0;
width: 100%;
height: 1px;
background-color: rgba(255, 255, 255, 0.25);
transition: background-color 0.2s ease;
}
.navbar a::before { top: 0; }
.navbar a::after { bottom: 0; }
.navbar a:hover {
background-color: #5e2b7a;
color: #fff5a8;
}
.navbar a:hover::before,
.navbar a:hover::after {
background-color: #fff5a8;
}
.navbar-top-icons {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: 16px;
margin-bottom: 1.5rem;
}
.icon-item {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.icon-item img {
width: 32px;
height: 32px;
object-fit: contain;
transition: transform 0.2s ease, opacity 0.2s ease;
}
.icon-item img:hover {
transform: scale(1.1);
opacity: 0.85;
}
.icon-label {
font-family: "Clockopia", sans-serif;
font-size: 0.8rem;
margin-top: 4px;
}
.tor-label { color: #6e2b9e; }
.ygg-label { color: #92ffb3; }
.mobile-nav-toggle {
display: none;
position: fixed;
top: 12px;
right: 12px;
z-index: 50;
background: rgba(60, 26, 77, 0.6);
color: #fff;
font-size: 1.8rem;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 6px;
padding: 4px 10px;
cursor: pointer;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.footer {
position: relative;
width: 100%;
padding: 1rem 0;
text-align: center;
font-family: IBMWeb, "Liberation Mono", monospace;
color: #fff;
background: transparent;
margin-top: auto;
z-index: 10;
}
a { text-decoration: none; color: #447fc6; }
::-moz-selection { color: #000000; background: #a57ae9; }
::selection { color: #000000; background: #a57ae9; }
a::-moz-selection { color: #000; background: #447fc6; }
a::selection { color: #000; background: #447fc6; }
a:not(:has(img)):hover { text-decoration: underline; }
.content {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
padding: 4rem 10%;
gap: 5%;
z-index: 10;
position: relative;
box-sizing: border-box;
flex-wrap: wrap;
max-width: calc(100% - 140px);
}
.left {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
}
.uzi {
width: 90%;
max-width: 650px;
height: auto;
pointer-events: none;
}
.middle {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
text-align: left;
z-index: 10;
max-width: 800px;
}
.middle h1 {
font-family: "Clockopia", sans-serif;
font-size: 3.5rem;
margin-bottom: 1rem;
color: #fff5a8;
}
.middle p {
font-size: 1.1rem;
line-height: 1.7;
margin-bottom: 1rem;
max-width: 550px;
}
.buttons {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 1.5rem;
}
.buttons img {
width: 88px;
height: 31px;
border: none;
image-rendering: pixelated;
}
.services-page {
max-width: 900px;
margin: 0 auto;
padding: 6rem 2rem 8rem 2rem;
text-align: left;
box-sizing: border-box;
}
.service-card {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
padding: 1.5rem;
margin: 1.5rem 0;
border-radius: 6px;
backdrop-filter: blur(6px);
box-shadow: 0 0 12px rgba(0, 0, 0, 0.25);
}
.service-card h2 {
font-family: "Clockopia", sans-serif;
color: #fff5a8;
margin-top: 0;
margin-bottom: .75rem;
}
.service-card ul {
margin-left: 1.25rem;
line-height: 1.7;
color: #ddd;
}
.service-card a {
color: #fff5a8;
text-decoration: none;
}
.service-card a:hover {
text-decoration: underline;
}
@media (max-width: 900px) {
.services-page {
padding-left: 1.5rem;
padding-right: 1.5rem;
padding-top: 5rem;
text-align: center;
}
.service-card ul {
text-align: left;
}
}
/* Background */
#night {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #0e0016;
z-index: -9999;
pointer-events: none;
}
.clouds {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
height: auto;
z-index: -5;
pointer-events: none;
}
.clouds img {
width: 100%;
height: auto;
display: block;
opacity: 0.5;
filter: blur(1px);
}
/* Project Page */
.project-page {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
max-width: calc(100% - 140px);
margin: 0 auto;
padding: 5rem 2rem 8rem;
}
.project-page h1,
.project-page h2,
.project-page h3 {
font-family: "Clockopia", sans-serif;
color: #fff5a8;
}
.project-page a {
color: #fff5a8;
text-decoration: none;
}
.project-page a:hover {
text-decoration: underline;
}
.project-page ul {
text-align: left;
margin: auto;
max-width: 600px;
}
.project-page p {
max-width: 700px;
line-height: 1.5;
margin-bottom: auto;
}
/*Shit to make it not terrible for mobile users.*/
@media (max-width: 1200px) {
.content {
flex-direction: column;
text-align: center;
padding: 3rem 2rem;
max-width: 100%;
}
.left, .middle {
flex: unset;
width: 100%;
}
.uzi {
width: 60%;
max-width: 600px;
margin-bottom: 2rem;
}
.middle {
text-align: center;
align-items: center;
}
}
/* Full mobile mode */
@media (max-width: 900px) {
body {
padding-right: 0;
}
.navbar {
position: relative;
width: 100%;
height: auto;
flex-direction: row;
justify-content: center;
padding: 0.5rem 0;
border-left: none;
border-right: none;
}
.navbar ul {
display: flex;
gap: 1rem;
}
.content,
.project-page {
max-width: 100%;
flex-direction: column;
padding: 2rem 1.5rem;
text-align: center;
}
.middle {
text-align: center;
align-items: center;
max-width: 100%;
}
.uzi {
width: 70%;
margin-bottom: 2rem;
}
.footer {
padding-bottom: 2rem;
}
.navbar-top-icons {
margin-bottom: 0.5rem;
gap: 10px;
}
.icon-item img {
width: 24px;
height: 24px;
}
.icon-label {
font-size: 0.7rem;
}
}
.mobile-nav-toggle {
display: none;
position: fixed;
top: 12px;
right: 12px;
z-index: 50;
background: rgba(60, 26, 77, 0.6);
color: #fff;
font-size: 1.8rem;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 6px;
padding: 4px 10px;
cursor: pointer;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
@media (max-width: 900px) {
.mobile-nav-toggle {
display: block;
}
.navbar {
position: fixed;
right: -100%;
top: 0;
bottom: 0;
width: 200px;
padding-top: 3.5rem;
flex-direction: column;
height: 100vh;
transition: right 0.3s ease;
}
.navbar.open {
right: 0;
}
.navbar ul {
flex-direction: column;
gap: 0;
}
body {
padding-right: 0;
}
}

View File

Before

Width:  |  Height:  |  Size: 941 B

After

Width:  |  Height:  |  Size: 941 B

View File

Before

Width:  |  Height:  |  Size: 999 B

After

Width:  |  Height:  |  Size: 999 B

View File

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

Before

Width:  |  Height:  |  Size: 552 B

After

Width:  |  Height:  |  Size: 552 B

View File

Before

Width:  |  Height:  |  Size: 1001 B

After

Width:  |  Height:  |  Size: 1001 B

View File

Before

Width:  |  Height:  |  Size: 975 B

After

Width:  |  Height:  |  Size: 975 B

View File

Before

Width:  |  Height:  |  Size: 965 B

After

Width:  |  Height:  |  Size: 965 B

View File

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

Before

Width:  |  Height:  |  Size: 556 B

After

Width:  |  Height:  |  Size: 556 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 510 B

After

Width:  |  Height:  |  Size: 510 B

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Before

Width:  |  Height:  |  Size: 426 B

After

Width:  |  Height:  |  Size: 426 B

BIN
assets/images/clouds.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
assets/images/icons/tor.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

BIN
assets/images/icons/ygg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

BIN
assets/images/pfp.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
assets/images/uzi.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -1,58 +1,26 @@
/**
* @licstart The following is the entire license notice for the JavaScript
* code in this file.
*
* Copyright (C) 2025 purplebored.pl <purplebored@posteo.com>
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License, Version 2.0
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*
* @licend The above is the entire license notice for the JavaScript code
* in this page.
*/
"use strict";
function create_night(canvas_element) {
const context = canvas_element.getContext("2d");
const star_range = 0.7; /* Variation in star size */
const star_spread = 0.002; /* Star density per pixel */
const concentration_corner = "top-left"; /* Concentration corner position */
const concentration_strength = 5.0; /* How much should it be concentrated? */
const twinkle_intensity = 5.0; /* How noticeable should the twinkle be? */
const interaction_radius = 150; /* Radius around mouse/touch where stars react */
const interaction_brightness_factor = 5.0; /* How much brightness increases */
const interaction_size_factor = 1.5; /* How much the stars should grow in size (only during interaction) */
const magic = [
16.31189, 32454.4619, 9371.1474, 6848, 1544, 6848, 2156,
]; /* Just some magic numbers */
/* Seeded "random" number */
function seeded_random(seed) {
const value = Math.sin(seed) * 10000;
return value - Math.floor(value);
}
let stars = [];
let time = 0;
let mouse_x = -1,
mouse_y = -1;
/* Determine corner position for concentration */
function get_corner(width, height) {
switch (concentration_corner) {
@@ -66,65 +34,50 @@ function create_night(canvas_element) {
return { x: 0, y: 0 }; /* top-left */
}
}
/* Function to generate star shape */
function draw_star(x, y, radius, alpha) {
const num_points = 5 + Math.floor(Math.random() * 3);
const angle = Math.PI / num_points;
context.beginPath();
for (let idx = 0; idx < num_points; idx++) {
const angle_offset = (idx * 2 * Math.PI) / num_points;
const outer_x = x + radius * Math.cos(angle_offset);
const outer_y = y + radius * Math.sin(angle_offset);
context.lineTo(outer_x, outer_y);
/* Inner points for star shape */
const inner_radius = radius / 2 + Math.random() * 0.2;
const inner_x = x + inner_radius * Math.cos(angle_offset + angle);
const inner_y = y + inner_radius * Math.sin(angle_offset + angle);
context.lineTo(inner_x, inner_y);
}
context.closePath();
context.fillStyle = `rgba(255, 255, 180, ${alpha})`;
context.fill();
}
/* Function to generate star background */
function generate_stars() {
const { width, height } = canvas_element;
const star_count = Math.floor(width * height * star_spread);
const corner = get_corner(width, height);
stars = [];
for (let idx = 0; idx < star_count; idx++) {
const base_random = seeded_random(idx * magic[0]);
/* Uniform random position */
const raw_x = seeded_random(base_random * magic[1]) * width;
const raw_y = seeded_random(base_random * magic[2]) * height;
/* Calculate distance from the corner to control concentration */
const dx = raw_x - corner.x;
const dy = raw_y - corner.y;
const distance = Math.sqrt(dx * dx + dy * dy);
/* Apply exponential falloff based on distance (closer = more likely to be drawn) */
const max_distance = Math.sqrt(width * width + height * height);
const weight = Math.exp(
-concentration_strength * (distance / max_distance),
);
/* Skip stars that don't meet the concentration threshold */
if (seeded_random(base_random * magic[3]) > weight) {
continue;
}
/* Randomized star properties */
const radius =
seeded_random(base_random * magic[4]) * star_range +
@@ -134,7 +87,6 @@ function create_night(canvas_element) {
seeded_random(base_random * magic[5]) * 1.5 + 0.3;
const twinkle_phase =
seeded_random(base_random * magic[6]) * Math.PI * 2;
stars.push({
x: raw_x,
y: raw_y,
@@ -145,17 +97,14 @@ function create_night(canvas_element) {
});
}
}
/* Resize canvas and regenerate stars on window resize */
function resize_canvas() {
canvas_element.width = window.innerWidth;
canvas_element.height = window.innerHeight;
generate_stars();
}
window.addEventListener("resize", resize_canvas);
resize_canvas();
/* Handle mouse/touch movement effects */
function handle_interaction(event) {
if (event.touches) {
@@ -166,41 +115,33 @@ function create_night(canvas_element) {
mouse_y = event.clientY;
}
}
window.addEventListener("mousemove", handle_interaction);
window.addEventListener("touchmove", handle_interaction);
/* Animate stars (twinke) */
function animate() {
time += 0.01;
const { width, height } = canvas_element;
context.fillStyle = "#0e0016";
context.fillRect(0, 0, width, height);
for (const star of stars) {
const twinkle = Math.sin(
time * star.twinkle_speed + star.twinkle_phase,
);
let alpha = 0.6 + twinkle_intensity * (0.5 + 0.5 * twinkle);
/* Interaction effect based on mouse/touch proximity */
if (mouse_x !== -1 && mouse_y !== -1) {
const dx = mouse_x - star.x;
const dy = mouse_y - star.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < interaction_radius) {
const effect = Math.max(
0,
1 - distance / interaction_radius,
);
alpha *=
1 +
effect *
interaction_brightness_factor; /* Make the star brighter */
star.radius =
star.original_radius *
(1 +
@@ -208,21 +149,27 @@ function create_night(canvas_element) {
interaction_size_factor); /* Grow the star size */
}
}
draw_star(star.x, star.y, star.radius, alpha);
}
requestAnimationFrame(animate);
}
animate();
}
function base_main() {
const night = document.getElementById("night");
create_night(night);
}
document.addEventListener("DOMContentLoaded", () => {
base_main();
});
document.addEventListener("DOMContentLoaded", () => {
const toggle = document.querySelector(".mobile-nav-toggle");
const navbar = document.querySelector(".navbar");
if (toggle && navbar) {
toggle.addEventListener("click", () => {
navbar.classList.toggle("open");
});
}
});

View File

@@ -1,7 +0,0 @@
export DATABASE='sqlite:///main.db'
export LICENSE='Apache-2.0-only'
export NAME='Your Name'
export EMAIL='test@example.com'
export MEMCACHED='127.0.0.1:11211'
export SOURCE='https://git.ari.lt/templates/flask'
export OG_LOCALE='en_GB'

134
index.html Normal file
View File

@@ -0,0 +1,134 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Open Graph shit -->
<meta name="og:url" content="https://purplebored.pl/" />
<meta name="og:title" content="Purplebored.pl" />
<meta property="og:type" content="website">
<meta property="og:image" content="/assets/images/pfp.png">
<meta name="og:description" content="Just my personal website where I share cool stuff, the things I like, and the things I do :D" />
<meta name="description" content="Just my personal website where I share cool stuff, the things I like, and the things I do :D" />
<meta name="keywords" content="purplebored, poland, polish, personal website, website, buttons, niko, self-hosted, open source, foss, murder drones, tobacco, md, nicotine" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#0e0016" />
<meta name="author" content="Purplebored">
<link rel="stylesheet" href="assets/css/style.css">
<link rel="canonical" href="https://purplebored.pl/">
<link rel="icon" href="/favicon.png" type="image/png" />
<title>Purplebored</title>
</head>
<body>
<div class="content">
<div class="left">
<img src="assets/images/uzi.png" alt="Uzi" class="uzi">
</div>
<div class="middle">
<h1>Purplebored.pl</h1>
<p> Hi, I am a 17-year-old retard called Purplebored, also known as Niko o/. Right now you are on my own little website which I call my home on the World Wide Web! Nothing special, but I hope you like it :D</p>
<p> I like making simple websites, and I am also a privacy advocate. I also love tobacco, and I am a big nicotine addict. I speak Polish and English, but I am also learning Russian. Regarding programming, I currently only know basic HTML. From other stuff, I also know some Linux and quite a lot about Windows — especially the older versions like Windows 7.</p>
<!-- Badges -->
<div class="buttons">
<a href="https://purplebored.pl/" target="_blank" rel="noopener noreferrer">
<img src="assets/images/buttons/Purplebored_button.png" alt="Purplebored button" />
</a>
<a href="https://eclipse.cx" target="_blank" rel="noopener noreferrer">
<img src="assets/images/buttons/eclipsecx.png" alt="Eclipse Community button" />
</a>
<a href="https://aagaming.me/" target="_blank" rel="noopener noreferrer">
<img src="assets/images/buttons/aagaming.png" alt="AAgaming button" />
</a>
<a href="https://themcgovern.net" target="_blank" rel="noopener noreferrer">
<img src="assets/images/buttons/mcneb10.gif" alt="Mcneb10 button" />
</a>
<a href="https://moody.im/" target="_blank" rel="noopener noreferrer">
<img src="assets/images/buttons/georgemoody.png" alt="Georgemoody button" />
</a>
<a href="https://xameren.fsky.io" target="_blank" rel="noopener noreferrer">
<img src="assets/images/buttons/xameren.png" alt="Xameren button" />
</a>
<a href="https://fsky.io" target="_blank" rel="noopener noreferrer">
<img src="assets/images/buttons/fsky_night.png" alt="FSKY.io button" />
</a>
<a href="https://ari.lt/" title="ari-web badge" target="_blank" rel="noopener noreferrer">
<img src="assets/images/buttons/ari.png" alt="ari-web badge" />
</a>
<a href="https://telepath.im" target="_blank" rel="noopener noreferrer">
<img src="assets/images/buttons/telepath.png" alt="Telepath.im button" />
</a>
<a href="https://aleksey-kon-games.fsky.io/" target="_blank" rel="noopener noreferrer">
<img src="assets/images/buttons/aleksey.png" alt="Aleksey Kon Games button" />
</a>
<a href="https://k327.eu/" target="_blank" rel="noopener noreferrer">
<img src="assets/images/buttons/k327.png" alt="k327.eu button" />
</a>
<a href="https://vaporwavefox.neocities.org" target="_blank" rel="noopener noreferrer">
<img src="assets/images/buttons/trace.png" alt="Trace button" />
</a>
<a href="https://authenyo.xyz" target="_blank" rel="noopener noreferrer">
<img src="assets/images/buttons/authen.gif" alt="Authen button" />
</a>
<a href="https://greatsword.xyz" target="_blank" rel="noopener noreferrer">
<img src="assets/images/buttons/greatsword.png" alt="Greatsword button" />
</a>
<a href="http://[300:5506:25eb:d0d9::]" target="_blank" rel="noopener noreferrer">
<img src="assets/images/buttons/midgard.png" alt="Midgard button" />
</a>
</div>
</div>
</div>
<button class="mobile-nav-toggle" aria-label="Toggle menu">
</button>
<nav class="navbar">
<div class="navbar-top-icons">
<div class="icon-item">
<a href="http://niko.fsky42e4xj4o4q7xiyxzodqvd75iwgztywxtbugdmixamrmui5l4vhid.onion/" target="_blank" rel="noopener noreferrer">
<img src="/assets/images/icons/tor.png" alt="Tor Mirror">
<span class="icon-label tor-label">Tor</span>
</a>
</div>
<div class="icon-item">
<a href="/pages/ygg.html" target="_blank" rel="noopener noreferrer">
<img src="/assets/images/icons/ygg.png" alt="Information about Yggdrasil">
<span class="icon-label ygg-label">Ygg</span>
</a>
</div>
</div>
<ul>
<li><a href="/index.html">Home</a></li>
<li><a href="/pages/about.html">About me</a></li>
<li><a href="/pages/projects.html">Projects</a></li>
<li><a href="/pages/contact.html">Contact</a></li>
<li><a href="/pages/services.html">Services</a></li>
<li><a href="/pages/terms.html">Terms of Service</a></li>
<li><a href="/pages/other.html">Other</a></li>
</ul>
</nav>
<div class="clouds">
<img src="assets/images/clouds.png" alt="Clouds">
</div>
<div class="footer">
Purplebored &copy; 2023-2025 |
<a href="https://git.purplebored.pl/purplebored/purplebored.pl">Source code</a> |
<a href="/pages/contact.html">Contact Me</a> |
<a href="https://status.purplebored.pl"> Uptime / Status</a> |
</div>
<canvas id="night"></canvas>
<script src="assets/js/script.js" defer></script>
</body>
</html>

108
pages/about.html Normal file
View File

@@ -0,0 +1,108 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#0e0016">
<title>About me</title>
<meta name="og:url" content="https://purplebored.pl/pages/about.html" />
<meta name="og:title" content="Purplebored - About me" />
<meta property="og:type" content="website">
<meta property="og:image" content="/assets/images/pfp.png">
<meta name="og:description" content="A little bit about me :D" />
<meta name="description" content="A little bit about me.">
<link rel="icon" href="/favicon.png" type="image/png">
<link rel="stylesheet" href="/assets/css/style.css">
</head>
<body>
<!--Navbar related code-->
<button class="mobile-nav-toggle" aria-label="Toggle menu">
</button>
<nav class="navbar">
<div class="navbar-top-icons">
<div class="icon-item">
<a href="http://niko.fsky42e4xj4o4q7xiyxzodqvd75iwgztywxtbugdmixamrmui5l4vhid.onion/" target="_blank" rel="noopener noreferrer">
<img src="/assets/images/icons/tor.png" alt="Tor Mirror">
<span class="icon-label tor-label">Tor</span>
</a>
</div>
<div class="icon-item">
<a href="/pages/ygg.html" target="_blank" rel="noopener noreferrer">
<img src="/assets/images/icons/ygg.png" alt="Information about Yggdrasil">
<span class="icon-label ygg-label">Ygg</span>
</a>
</div>
</div>
<ul>
<li><a href="/index.html">Home</a></li>
<li><a href="/pages/about.html">About me</a></li>
<li><a href="/pages/projects.html">Projects</a></li>
<li><a href="/pages/contact.html">Contact</a></li>
<li><a href="/pages/services.html">Services</a></li>
<li><a href="/pages/terms.html">Terms of Service</a></li>
<li><a href="/pages/other.html">Other</a></li>
</ul>
</nav>
<div class="content project-page">
<div class="middle">
<h1>About me</h1>
<p>
Hi, Im <strong>Purplebored</strong>, also known as Niko. Welcome to my site and here are some information about me:
</p>
<p>
I like making simple websites like this, and I love self-hosting stuff when I can. I try to avoid most of the modern bloat out there, so that's why I have my own personal website instead of a social media account, or I use XMPP instead of using Discord.
I am also a privacy-minded person, so I try to avoid big tech shit as much as possible.
<br />
Oh, and the most important part I am Polish and I <strong>REALLY</strong> love my country. I am proud to be Polish, and if I had the chance to be born again I would choose Poland every day.
</p>
<h2>What I Do</h2>
<ul>
<li>Create small websites when I am bored (Like this one!)</li>
<li>Self-host services I like and use for example Mumble, or XMPP.</li>
<li>Create tiny shit from time to time like my Discord Userbot IRC bridge</li>
<li>Experiment with Linux, servers, and general sysadmin things</li>
<li>Fuck around with Windows (especially older versions like Windows 7)</li>
</ul>
<h2>Languages I Speak</h2>
<ul>
<li>Polish (native)</li>
<li>English (fluent in writing terrible in speaking.)</li>
<li>Russian (early stages of learning)</li>
</ul>
<h2>Random Facts About Me</h2>
<ul>
<li>I love tobacco and yes, I'm definitely a nicotine fien</li>
<li>I spend way too much time talking about nicotine and Poland</li>
<li>My favorite music band is Кино(Kino)</li>
</ul>
<br />
<a href="/">← Back to Home</a>
</div>
</div>
<div class="footer">
Purplebored &copy; 2023-2025 |
<a href="https://git.purplebored.pl/purplebored/purplebored.pl">Source code</a> |
<a href="/pages/contact.html">Contact Me</a> |
<a href="https://status.purplebored.pl"> Uptime / Status</a> |
</div>
<div class="clouds">
<img src="/assets/images/clouds.png" alt="Clouds">
</div>
<canvas id="night"></canvas>
<script src="/assets/js/script.js" defer></script>
</body>
</html>

89
pages/contact.html Executable file
View File

@@ -0,0 +1,89 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#0e0016">
<title>Contact</title>
<meta name="og:url" content="https://purplebored.pl/pages/contact.html" />
<meta name="og:title" content="Purplebored - Contact me" />
<meta property="og:type" content="website">
<meta property="og:image" content="/assets/images/pfp.png">
<meta name="og:description" content="How to contact purplebored via email, IRC, XMPP, etc." />
<meta name="description" content="How to contact purplebored via email, IRC, XMPP, etc.">
<link rel="icon" href="/favicon.png" type="image/png">
<link rel="stylesheet" href="/assets/css/style.css">
</head>
<body>
<!-- Navbar related code. -->
<button class="mobile-nav-toggle" aria-label="Toggle menu">
</button>
<nav class="navbar">
<div class="navbar-top-icons">
<div class="icon-item">
<a href="http://niko.fsky42e4xj4o4q7xiyxzodqvd75iwgztywxtbugdmixamrmui5l4vhid.onion/" target="_blank" rel="noopener noreferrer">
<img src="/assets/images/icons/tor.png" alt="Tor Mirror">
<span class="icon-label tor-label">Tor</span>
</a>
</div>
<div class="icon-item">
<a href="/pages/ygg.html" target="_blank" rel="noopener noreferrer">
<img src="/assets/images/icons/ygg.png" alt="Information about Yggdrasil">
<span class="icon-label ygg-label">Ygg</span>
</a>
</div>
</div>
<ul>
<li><a href="/index.html">Home</a></li>
<li><a href="/pages/about.html">About me</a></li>
<li><a href="/pages/projects.html">Projects</a></li>
<li><a href="/pages/contact.html">Contact</a></li>
<li><a href="/pages/services.html">Services</a></li>
<li><a href="/pages/terms.html">Terms of Service</a></li>
<li><a href="/pages/other.html">Other</a></li>
</ul>
</nav>
<div class="content project-page">
<div class="middle">
<h1>Contact Me</h1>
<p>You can contact me using those ways, but I would preffer if you used XMPP, or Email.</p>
<ul>
<li><strong>Email:</strong> <a href="mailto:root@purplebored.pl">root(at)purplebored.pl</a></li>
<li><strong>XMPP:</strong>pb@purplebored.pl</li>
<li><strong>Matrix:</strong>@purplebored@telepath.im</li>
<li><strong>IRC:</strong> <code>Purplebored</code> on <a href="https://telepath.im/irc" target="_blank">Telepath IRC</a></li>
<li><strong>Discord:</strong> niko2077 (Please avoid this!! Only use this if <i>really</i> need to! Even using email without PGP would be better!)</li>
</ul>
<p>If youre reporting a bug on this website, or one of my projects,
you can email me about it, OR if you have an account on my git you can also use that to report the issue.</p>
<p>
When using email if you can please encrypt it with PGP. <a href="/0CB52690CD644413EF66A21CAEF2F9A8C0334670.asc" target="_blank">You can get my public key here.</a>
</p>
<p>Messages in both English or Polish are okay :D</p>
<p>
<a href="/">← Back to home page</a>
</p>
</div>
</div>
<div class="footer">
Purplebored &copy; 2023-2025 |
<a href="https://git.purplebored.pl/purplebored/purplebored.pl">Source code</a> |
<a href="/pages/contact.html">Contact Me</a> |
<a href="https://status.purplebored.pl"> Uptime / Status</a> |
</div>
<div class="clouds">
<img src="/assets/images/clouds.png" alt="Clouds">
</div>
<canvas id="night"></canvas>
<script src="/assets/js/script.js" defer></script>
</body>
</html>

82
pages/other.html Normal file
View File

@@ -0,0 +1,82 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#0e0016">
<title>Other</title>
<meta name="og:url" content="https://purplebored.pl/pages/other.html" />
<meta name="og:title" content="Purplebored - Other" />
<meta property="og:type" content="website">
<meta property="og:image" content="/assets/images/pfp.png">
<meta name="og:description" content="Things which don't fit in anywhere else!" />
<meta name="description" content="Things which don't fit in anywhere else!">
<link rel="icon" href="/favicon.png" type="image/png">
<link rel="stylesheet" href="/assets/css/style.css">
</head>
<body>
<!-- Navbar related code. -->
<button class="mobile-nav-toggle" aria-label="Toggle menu">
</button>
<nav class="navbar">
<div class="navbar-top-icons">
<div class="icon-item">
<a href="http://niko.fsky42e4xj4o4q7xiyxzodqvd75iwgztywxtbugdmixamrmui5l4vhid.onion/" target="_blank" rel="noopener noreferrer">
<img src="/assets/images/icons/tor.png" alt="Tor Mirror">
<span class="icon-label tor-label">Tor</span>
</a>
</div>
<div class="icon-item">
<a href="/pages/ygg.html" target="_blank" rel="noopener noreferrer">
<img src="/assets/images/icons/ygg.png" alt="Information about Yggdrasil">
<span class="icon-label ygg-label">Ygg</span>
</a>
</div>
</div>
<ul>
<li><a href="/index.html">Home</a></li>
<li><a href="/pages/about.html">About me</a></li>
<li><a href="/pages/projects.html">Projects</a></li>
<li><a href="/pages/contact.html">Contact</a></li>
<li><a href="/pages/services.html">Services</a></li>
<li><a href="/pages/terms.html">Terms of Service</a></li>
<li><a href="/pages/other.html">Other</a></li>
</ul>
</nav>
<div class="content project-page">
<div class="middle">
<h1>Some other pages!</h1>
<p>
Here you can find some other pages which I did not want to put in the navbar, or link somewhere.
</p>
<ul>
<li><a href="/pages/other/snuff.html">Nasal Snuff!</a></li>
</ul>
<p>
<a href="/">← Back to home page</a>
</p>
</div>
</div>
<div class="footer">
Purplebored &copy; 2023-2025 |
<a href="https://git.purplebored.pl/purplebored/purplebored.pl">Source code</a> |
<a href="/pages/contact.html">Contact Me</a> |
<a href="https://status.purplebored.pl"> Uptime / Status</a> |
</div>
<div class="clouds">
<img src="/assets/images/clouds.png" alt="Clouds">
</div>
<canvas id="night"></canvas>
<script src="/assets/js/script.js" defer></script>
</body>
</html>

115
pages/other/snuff.html Normal file
View File

@@ -0,0 +1,115 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#0e0016">
<title>Nasal snuff</title>
<meta name="og:url" content="https://purplebored.pl/pages/other/snuff.html" />
<meta name="og:title" content="Purplebored - Snuff" />
<meta property="og:type" content="website">
<meta property="og:image" content="/assets/images/pfp.png">
<meta name="og:description" content="A few words about nasal snuff." />
<meta name="description" content="A few words about nasal snuff.">
<link rel="icon" href="/favicon.png" type="image/png">
<link rel="stylesheet" href="/assets/css/style.css">
</head>
<body>
<!-- Navbar related code. -->
<button class="mobile-nav-toggle" aria-label="Toggle menu">
</button>
<nav class="navbar">
<div class="navbar-top-icons">
<div class="icon-item">
<a href="http://niko.fsky42e4xj4o4q7xiyxzodqvd75iwgztywxtbugdmixamrmui5l4vhid.onion/" target="_blank" rel="noopener noreferrer">
<img src="/assets/images/icons/tor.png" alt="Tor Mirror">
<span class="icon-label tor-label">Tor</span>
</a>
</div>
<div class="icon-item">
<a href="/pages/ygg.html" target="_blank" rel="noopener noreferrer">
<img src="/assets/images/icons/ygg.png" alt="Information about Yggdrasil">
<span class="icon-label ygg-label">Ygg</span>
</a>
</div>
</div>
<ul>
<li><a href="/index.html">Home</a></li>
<li><a href="/pages/about.html">About me</a></li>
<li><a href="/pages/projects.html">Projects</a></li>
<li><a href="/pages/contact.html">Contact</a></li>
<li><a href="/pages/services.html">Services</a></li>
<li><a href="/pages/terms.html">Terms of Service</a></li>
<li><a href="/pages/other.html">Other</a></li>
</ul>
</nav>
<div class="content project-page">
<div class="middle">
<h1>Snuff!</h1>
<h5>
MINISTER ZDROWIA OSTRZEGA: UŻYWANIE TYTONIU SZKODZI ZDROWIU.
<br />
THE MINISTER OF HEALTH WARNS: USING TOBACCO IS HARMFUL TO YOUR HEALTH.
<br />
I do not condone snuff taking or any other nicotine use. Snuff is a tobacco based product and contains nicotine which is known to be highly addictive. The content on this page is purley informational.
</h5>
<p>
Hey I first should maybe explain what nasal snuff is for the people who don't know, so
</p>
<h2>What's nasal snuff?</h2>
<p>
Snuff is a type of smokeless tobacco product made from finely ground or pulverized tobacco leaves. It is sniffed (also sometimes written as "snuffed".) into the nasal cavity, delivering nicotine and a flavored scent to the user.
<br />
Snuff is also <i>healthier</i> than normal smoking. BTW <strong>Healthier does not equal healthy!</strong>
Snuff is still a tobacco products and still carriers risks with it.
</p>
<h2>How did my journey with snuff start?</h2>
<p>
It started from pure curiosity, and from me being too poor to afford cigarettes. I saw a box of <code>Gletscher Prise</code> and decided to get it.
And holy fuck was I blown away by how good it was. The first time felt like breathing in 8K or something like that.
</p>
<h2>Why do I take snuff?</h2>
<p>
Mostly for the flavours it offers. Also, I just love the snuff culture I personally find it a lot more respectful and interesting then cigarette smoking.
Also, sometimes when I don't smoke for some reason, I like to use it to get that nicotine hit.
</p>
<h2>What's so interesting about snuff?</h2>
<p>
Snuff is weirdly fascinating because there are so many types fine, coarse, dry, menthol nukes, floral perfumes,
and blends that smell like someone emptied a spice cabinet. Half the fun is just figuring out what strange scent
youve opened this time and wondering who thought it was a good idea.
</p>
<p>
<a href="/">← Back to home page</a>
</p>
</div>
</div>
<div class="footer">
Purplebored &copy; 2023-2025 |
<a href="https://git.purplebored.pl/purplebored/purplebored.pl">Source code</a> |
<a href="/pages/contact.html">Contact Me</a> |
<a href="https://status.purplebored.pl"> Uptime / Status</a> |
</div>
<div class="clouds">
<img src="/assets/images/clouds.png" alt="Clouds">
</div>
<canvas id="night"></canvas>
<script src="/assets/js/script.js" defer></script>
</body>
</html>

114
pages/projects.html Normal file
View File

@@ -0,0 +1,114 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#0e0016">
<title>Projects</title>
<meta name="description" content="Purplebored's projects :D">
<meta name="keywords" content="purplebored, irc, discord, bridge, userbot, open source, purplebored.pl">
<meta property="og:url" content="https://purplebored.pl/projects/projects.html">
<meta property="og:title" content="Purplebored - Projects" />
<meta property="og:description" content="Purplebored's projects :D">
<meta property="og:image" content="/assets/images/pfp.png">
<meta name="description" content="Purplebored's projects :D">
<link rel="icon" href="/favicon.png" type="image/png">
<link rel="stylesheet" href="/assets/css/style.css">
</head>
<body>
<!-- Navbar related code. -->
<button class="mobile-nav-toggle" aria-label="Toggle menu">
</button>
<nav class="navbar">
<div class="navbar-top-icons">
<div class="icon-item">
<a href="http://niko.fsky42e4xj4o4q7xiyxzodqvd75iwgztywxtbugdmixamrmui5l4vhid.onion/" target="_blank" rel="noopener noreferrer">
<img src="/assets/images/icons/tor.png" alt="Tor Mirror">
<span class="icon-label tor-label">Tor</span>
</a>
</div>
<div class="icon-item">
<a href="/pages/ygg.html" target="_blank" rel="noopener noreferrer">
<img src="/assets/images/icons/ygg.png" alt="Information about Yggdrasil">
<span class="icon-label ygg-label">Ygg</span>
</a>
</div>
</div>
<ul>
<li><a href="/index.html">Home</a></li>
<li><a href="/pages/about.html">About me</a></li>
<li><a href="/pages/projects.html">Projects</a></li>
<li><a href="/pages/contact.html">Contact</a></li>
<li><a href="/pages/services.html">Services</a></li>
<li><a href="/pages/terms.html">Terms of Service</a></li>
<li><a href="/pages/other.html">Other</a></li>
</ul>
</nav>
<div class="content project-page">
<div class="middle">
<h1>My Projects:</h1>
<p>
Hi and welcome to my projects page! For now I only have one project, but I am planning on making more of them. You can read about my only project bellow :D
</p>
<h2>= IRC to Discord Bridge =</h2>
<p>
A simple and modern IRC ↔ Discord userbot bridge written in Node.js, used to bridge Discord channels and group chats with IRC rooms.
</p>
<p> (Also worth explaining: a userbot &nbsp;sometimes called a selfbot &nbsp;is a bot that runs on a user account.) </p>
<p>
<a href="https://foundry.fsky.io/purplebored/Discord-userbot-to-irc" target="_blank">
Source code and setup instructions →
</a>
</p>
<h3>Features</h3>
<ul>
<li>Multi-Bridge Support — bridge multiple channels and rooms with one config.</li>
<li>Reactions Bridging — sends a message when a message is reacted to.</li>
<li>Attachments Bridging — automatically forwards images and files to IRC.</li>
<li>TLS/Non-TLS IRC Support — works with both secure and standard IRC ports.</li>
<li>Group Chat Bridges — supports Discord group chats via selfbot.</li>
<li>YAML Configuration — simple and human readable config file.</li>
</ul>
<h3>Why a Userbot Bridge?</h3>
<p> I wanted to stop using Discord, but still chat in a group chat — no other bridge supported that, so I decided to build my own.
It started as a small private project, but after making it stable enough and usefull enough for more people, I open sourced it for anyone who needs something similar. (Which I guess it's not a lot of people :P)</p>
<h3>Project Info</h3>
<ul>
<li>License: <a href="https://foundry.fsky.io/purplebored/Discord-userbot-to-irc/raw/branch/main/LICENSE" target="_blank">Apache 2.0</a></li>
<li>Language: Node.js</li>
<li>Developer: Just me (updates might be slow!)</li>
<li>Note: Using a userbot on Discord is against the ToS — though no bans have been reported so far, but still use at your own risk.</li>
</ul>
<p>
<a href="/">← Back to home page</a>
</p>
</div>
</div>
<div class="footer">
Purplebored &copy; 2023-2025 |
<a href="https://git.purplebored.pl/purplebored/purplebored.pl">Source code</a> |
<a href="/pages/contact.html">Contact Me</a> |
<a href="https://status.purplebored.pl"> Uptime / Status</a> |
</div>
<div class="clouds">
<img src="/assets/images/clouds.png" alt="Clouds">
</div>
<canvas id="night"></canvas>
<script src="/assets/js/script.js" defer></script>
</body>
</html>

144
pages/services.html Normal file
View File

@@ -0,0 +1,144 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#0e0016">
<title>Services</title>
<meta name="description" content="Stuff Purplebored hosts">
<meta name="keywords" content="purplebored, irc, open source, purplebored.pl, mumble, murmur, soju, dis4irc, irc, prosody, xmpp, gamja">
<meta property="og:url" content="https://purplebored.pl/projects/projects.html">
<meta property="og:title" content="Services | Purplebored.pl">
<meta property="og:description" content="Stuff Purplebored hosts">
<meta property="og:image" content="/assets/images/pfp.png">
<link rel="icon" href="/favicon.png" type="image/png">
<link rel="stylesheet" href="/assets/css/style.css">
</head>
<body>
<!-- Navbar related code. -->
<button class="mobile-nav-toggle" aria-label="Toggle menu">
</button>
<nav class="navbar">
<div class="navbar-top-icons">
<div class="icon-item">
<a href="http://niko.fsky42e4xj4o4q7xiyxzodqvd75iwgztywxtbugdmixamrmui5l4vhid.onion/" target="_blank" rel="noopener noreferrer">
<img src="/assets/images/icons/tor.png" alt="Tor Mirror">
<span class="icon-label tor-label">Tor</span>
</a>
</div>
<div class="icon-item">
<a href="/pages/ygg.html" target="_blank" rel="noopener noreferrer">
<img src="/assets/images/icons/ygg.png" alt="Information about Yggdrasil">
<span class="icon-label ygg-label">Ygg</span>
</a>
</div>
</div>
<ul>
<li><a href="/index.html">Home</a></li>
<li><a href="/pages/about.html">About me</a></li>
<li><a href="/pages/projects.html">Projects</a></li>
<li><a href="/pages/contact.html">Contact</a></li>
<li><a href="/pages/services.html">Services</a></li>
<li><a href="/pages/terms.html">Terms of Service</a></li>
<li><a href="/pages/other.html">Other</a></li>
</ul>
</nav>
<div class="project-page services-page">
<h1>Services I Host</h1>
<p>Here are some public and semi-public stuff I run from my servers. I host my stuff from two servers. One named NL1 in the Netherlands and one named PL1 in Poland.
Some require trust to get an account, some are open, and others are on a case-by-case basis.</p>
<div class="service-card">
<h2>Mumble Server</h2>
<p>A privacy-friendly VOIP. No accounts needed, low latency, works even on potato (Polish) connections.</p>
<ul>
<li><strong>Server:</strong> mumble.purplebored.pl</li>
<li><strong>Port:</strong> 64738 (TLS)</li>
<li><strong>Access:</strong> Public</li>
<li><strong>Location:</strong>NL1</li>
</ul>
</div>
<div class="service-card">
<h2>Soju IRC Bouncer</h2>
<p>And pretty decent modern IRC bouncer. Only people I know quite a lot and who trust me, and I am them get an account.</p>
<ul>
<li><strong>Server:</strong> soju.purplebored.pl</li>
<li><strong>Port:</strong> 6697 (TLS)</li>
<li><strong>Access:</strong> Invite-only</li>
<li><strong>Location:</strong>NL1</li>
</ul>
</div>
<div class="service-card">
<h2>Gamja Web IRC Client</h2>
<p>Web IRC client for the Soju IRC bouncer I host.</p>
<ul>
<li><a href="https://soju.purplebored.pl/gamja/" target="_blank">Open Gamja Web IRC</a></li>
<li><strong>Location:</strong>NL1</li>
</ul>
</div>
<div class="service-card">
<h2>Jabber Server</h2>
<p>A federated chat protocol better than Discord IMO. Accounts available only to trusted friends. Until I get a second domain.</p>
<ul>
<li><strong>Server:</strong> purplebored.pl</li>
<li><strong>Access:</strong> Invite-only</li>
<li><strong>Software:</strong> Prosody 13.x</li>
<li><strong>Location:</strong>NL1</li>
</ul>
</div>
<div class="service-card">
<h2>Dis4IRC Bridge</h2>
<p>A bridge written in java (Not my software) that links Discord chats to IRC channels — request access if needed. (Supports webhooks)</p>
<ul>
<li><strong>Access:</strong> Case-by-case</li>
<li><strong>Location:</strong>NL1</li>
</ul>
</div>
<div class="service-card">
<h2>PrivateBin</h2>
<p>PrivateBin is a minimalist, online pastebin where the server has zero knowledge of stored data.</p>
<ul>
<li><strong>Access:</strong> Open to everyone</li>
<li><a href="https://bin.purplebored.pl" target="_blank">Open PrivateBin</a></li>
<li><strong>Location:</strong>PL1</li>
</ul>
</div>
<div class="service-card">
<h2>Vaultwarden</h2>
<p>An unofficial Bitwarden compatible server written in Rust</p>
<ul>
<li><strong>Access:</strong> Case-by-case</li>
<li><a href="https://pass.purplebored.pl" target="_blank">Open Vaultwarden</a></li>
<li><strong>Location:</strong>NL1</li>
</ul>
</div>
<p style="margin-top: 2rem;">
<a href="/">← Back to Home</a>
</p>
</div>
<div class="footer">
Purplebored &copy; 2023-2025 |
<a href="https://git.purplebored.pl/purplebored/purplebored.pl">Source code</a> |
<a href="/pages/contact.html">Contact Me</a> |
<a href="https://status.purplebored.pl"> Uptime / Status</a> |
</div>
<canvas id="night"></canvas>
<script src="/assets/js/script.js" defer></script>
</body>
</html>

119
pages/terms.html Normal file
View File

@@ -0,0 +1,119 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#0e0016">
<title>Terms of Service</title>
<meta name="og:url" content="https://purplebored.pl/pages/terms.html" />
<meta name="og:title" content="Purplebored - ToS" />
<meta property="og:type" content="website">
<meta property="og:image" content="/assets/images/pfp.png">
<meta name="og:description" content="Terms of service for everything purplebored.pl related." />
<meta name="description" content="Terms of service for everything purplebored.pl related.">
<link rel="icon" href="/favicon.png" type="image/png">
<link rel="stylesheet" href="/assets/css/style.css">
</head>
<body>
<!-- Navbar related code. -->
<button class="mobile-nav-toggle" aria-label="Toggle menu">
</button>
<nav class="navbar">
<div class="navbar-top-icons">
<div class="icon-item">
<a href="http://niko.fsky42e4xj4o4q7xiyxzodqvd75iwgztywxtbugdmixamrmui5l4vhid.onion/" target="_blank" rel="noopener noreferrer">
<img src="/assets/images/icons/tor.png" alt="Tor Mirror">
<span class="icon-label tor-label">Tor</span>
</a>
</div>
<div class="icon-item">
<a href="/pages/ygg.html" target="_blank" rel="noopener noreferrer">
<img src="/assets/images/icons/ygg.png" alt="Information about Yggdrasil">
<span class="icon-label ygg-label">Ygg</span>
</a>
</div>
</div>
<ul>
<li><a href="/index.html">Home</a></li>
<li><a href="/pages/about.html">About me</a></li>
<li><a href="/pages/projects.html">Projects</a></li>
<li><a href="/pages/contact.html">Contact</a></li>
<li><a href="/pages/services.html">Services</a></li>
<li><a href="/pages/terms.html">Terms of Service</a></li>
<li><a href="/pages/other.html">Other</a></li>
</ul>
</nav>
<div class="content project-page">
<div class="middle">
<h1>Terms of Service for purplebored.pl services</h1>
<p>
purplebored.pl is a personal project providing services to close friends and certain individuals.
By using purplebored.pl and it's services you agree to abide to those Terms of Service.
<br />
<strong>Last update: 29.11.2025</strong>
</p>
<br />
<h2>User Eligibility:</h2>
<p>
You are older than 16 years old (or the age of digital consent in your country of residence, if higher).
If you are under that age please do not use anything purplebored.pl provides, and do not ask for any accounts.
</p>
<h2>Community Standards</h2>
<ol>
<li>Abide by Polish and Dutch law.</li>
<li>Do not harras, stalk, or abuse anyone.</li>
<li>Distribute hate speech, extremist content, or call to violence</li>
<li>Send spam</li>
<li>Attempt to disrupt, attack, or intentionally overload the services</li>
<li>Distribute malware or attempt unauthorized access to systems</li>
<li>Possess, store, share, request, promote, or justify CSAM or child sexual exploitation in any form.</li>
</ol>
<strong>Violation of these terms may lead to account deletion. </strong>
<h2>User Responsibility</h2>
<p>
You and only you are responsible for keeping your login details confidential and must choose passwords that meet the highest security standards as allowed by the services and things I provide. I will never ask you to give me your password. Never give your passwords to anyone claiming to be me or someone from purplebored.pl.
</p>
<p>
By registering an account, you agree to take full responsibility for all the activities under your username and password.
You must immediately notify me using the contact details provided in this document if you believe that your account or login details may have been compromised, hacked, or stolen.
</p>
<h2>Service availability and disclaimer</h2>
<p>
This is a personal, hobby-run service. I do not guarantee good uptime, data retention, message delivery, or security. Use everything I provide at your own risk.
</p>
<h2>Account Termination</h2>
<p>
You can request deletion of your account at any time by contacting me with the relevant details.
</p>
<p>
<a href="/">← Back to home page</a>
</p>
</div>
</div>
<div class="footer">
Purplebored &copy; 2023-2025 |
<a href="https://git.purplebored.pl/purplebored/purplebored.pl">Source code</a> |
<a href="/pages/contact.html">Contact Me</a> |
<a href="https://status.purplebored.pl"> Uptime / Status</a> |
</div>
<div class="clouds">
<img src="/assets/images/clouds.png" alt="Clouds">
</div>
<canvas id="night"></canvas>
<script src="/assets/js/script.js" defer></script>
</body>
</html>

105
pages/ygg.html Normal file
View File

@@ -0,0 +1,105 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#0e0016">
<title>Yggdrasil</title>
<meta name="og:url" content="https://purplebored.pl/pages/ygg.html" />
<meta name="og:title" content="Purplebored - Yggdrasil" />
<meta property="og:type" content="website">
<meta property="og:image" content="/assets/images/pfp.png">
<meta name="og:description" content="Info about the Yggdrasil network protocol, and stuff I host there." />
<meta name="description" content="Info about the Yggdrasil network protocol, and stuff I host there.">
<link rel="icon" href="/favicon.png" type="image/png">
<link rel="stylesheet" href="/assets/css/style.css">
</head>
<body>
<!-- Navbar related code. -->
<button class="mobile-nav-toggle" aria-label="Toggle menu">
</button>
<nav class="navbar">
<div class="navbar-top-icons">
<div class="icon-item">
<a href="http://niko.fsky42e4xj4o4q7xiyxzodqvd75iwgztywxtbugdmixamrmui5l4vhid.onion/" target="_blank" rel="noopener noreferrer">
<img src="/assets/images/icons/tor.png" alt="Tor Mirror">
<span class="icon-label tor-label">Tor</span>
</a>
</div>
<div class="icon-item">
<a href="/pages/ygg.html" target="_blank" rel="noopener noreferrer">
<img src="/assets/images/icons/ygg.png" alt="Information about Yggdrasil">
<span class="icon-label ygg-label">Ygg</span>
</a>
</div>
</div>
<ul>
<li><a href="/index.html">Home</a></li>
<li><a href="/pages/about.html">About me</a></li>
<li><a href="/pages/projects.html">Projects</a></li>
<li><a href="/pages/contact.html">Contact</a></li>
<li><a href="/pages/services.html">Services</a></li>
<li><a href="/pages/terms.html">Terms of Service</a></li>
<li><a href="#">Other</a></li>
</ul>
</nav>
<div class="content project-page">
<div class="middle">
<h1>Yggdrasil</h1>
<h2>What's Yggdrasil?</h2>
<p>
Citing from their website: "Yggdrasil is a new experimental compact routing scheme. It is designed to be a
future-proof and decentralised alternative to the structured routing protocols commonly used today on the
Internet, as well as an enabling technology for future large-scale mesh networks"
</p>
<h2>Why use Yggdrasil?</h2>
<p>
Yggdrasil is a completely separate network from the “normal” internet. It works as a global, encrypted
mesh network where every device becomes part of a huge, interconnected system. It doesnt rely on ISPs,
central servers, or big companies to function.
</p>
<h2>How to join Yggdrasil?</h2>
<p>
Check out the official installation page <a href="https://yggdrasil-network.github.io/installation.html">here.</a>
</p>
<h2>What I run on Yggdrasil</h2>
<p>
I host an Yggdrasil mirror of my website.
If youre on Ygg, you can reach ot using the link below:
</p>
<ul>
<li>My website on Ygg: <a href="http://[200:38ad:e575:f3c1:aba7:9e86:ae1e:4922]/"><code>http://[200:38ad:e575:f3c1:aba7:9e86:ae1e:4922]/</code></a></li>
<li>More stuff coming soon ;)</li>
</ul>
<p style="margin-top: 2rem;">
<a href="/">← Back to Home</a>
</p>
</div>
</div>
<div class="footer">
Purplebored &copy; 2023-2025 |
<a href="https://git.purplebored.pl/purplebored/purplebored.pl">Source code</a> |
<a href="/pages/contact.html">Contact Me</a> |
<a href="https://status.purplebored.pl"> Uptime / Status</a> |
</div>
<div class="clouds">
<img src="/assets/images/clouds.png" alt="Clouds">
</div>
<canvas id="night"></canvas>
<script src="/assets/js/script.js" defer></script>
</body>
</html>

View File

@@ -1,23 +0,0 @@
[tool.pyright]
pythonVersion = "3.10"
exclude = [
"venv",
"**/node_modules",
"**/__pycache__",
".git",
"src/migrations"
]
include = ["src", "scripts"]
venv = "venv"
stubPath = "src/stubs"
typeCheckingMode = "strict"
useLibraryCodeForTypes = true
reportMissingTypeStubs = true
[tool.mypy]
exclude = [
"^venv/.*",
"^node_modules/.*",
"^__pycache__/.*",
"src/migrations"
]

View File

@@ -1,9 +0,0 @@
flask
flask-csrf
flask-limiter
flask-migrate
flask-sqlalchemy
flask-wtf
portalocker
python-dotenv
pymemcache

11
robots.txt Normal file
View File

@@ -0,0 +1,11 @@
User-agent: *
Allow: /
Sitemap: https://purplebored.pl/sitemap.xml
Disallow: /assets/js/
Disallow: /assets/fonts/
Disallow: /assets/css/
Disallow: /assets/images/temp/
Disallow: /cgi-bin/
Sitemap: https://purplebored.pl/sitemap.xml

9
sitemap.xml Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>https://purplebored.pl/</loc><priority>1.0</priority></url>
<url><loc>https://purplebored.pl/pages/contact.html/</loc><priority>0.9</priority></url>
<url><loc>https://purplebored.pl/pages/services.html/</loc><priority>0.8</priority></url>
<url><loc>https://purplebored.pl/pages/ygg.html/</loc><priority>0.8</priority></url>
<url><loc>https://purplebored.pl/pages/terms.html</loc><priority>0.7</priority></url>
<url><loc>https://purplebored.pl/pages/projects.html/</loc><priority>0.7</priority></url>
</urlset>

View File

@@ -1,26 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Flask app example"""
from warnings import filterwarnings as filter_warnings
from flask import Flask
from pbpl import create_app
app: Flask = create_app(__name__)
def main() -> int:
"""entry/main function"""
app.run("127.0.0.1", 8080, True)
return 0
if __name__ == "__main__":
assert main.__annotations__.get("return") is int, "main() should return an integer"
filter_warnings("error", category=Warning)
raise SystemExit(main())

View File

@@ -1 +0,0 @@
Single-database configuration for Flask.

View File

@@ -1,50 +0,0 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@@ -1,113 +0,0 @@
import logging
from logging.config import fileConfig
from flask import current_app
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
def get_engine():
try:
# this works with Flask-SQLAlchemy<3 and Alchemical
return current_app.extensions['migrate'].db.get_engine()
except (TypeError, AttributeError):
# this works with Flask-SQLAlchemy>=3
return current_app.extensions['migrate'].db.engine
def get_engine_url():
try:
return get_engine().url.render_as_string(hide_password=False).replace(
'%', '%%')
except AttributeError:
return str(get_engine().url).replace('%', '%%')
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
config.set_main_option('sqlalchemy.url', get_engine_url())
target_db = current_app.extensions['migrate'].db
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def get_metadata():
if hasattr(target_db, 'metadatas'):
return target_db.metadatas[None]
return target_db.metadata
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=get_metadata(), literal_binds=True
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
conf_args = current_app.extensions['migrate'].configure_args
if conf_args.get("process_revision_directives") is None:
conf_args["process_revision_directives"] = process_revision_directives
connectable = get_engine()
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=get_metadata(),
**conf_args
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -1,27 +0,0 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
import flask_app
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -1,37 +0,0 @@
"""Initial migration
Revision ID: 57fa0d90dc93
Revises:
Create Date: 2025-07-30 20:21:21.163194
"""
from alembic import op
import sqlalchemy as sa
import pbpl
# revision identifiers, used by Alembic.
revision = '57fa0d90dc93'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('text_model',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('text', sa.Unicode(length=2048), nullable=False),
sa.Column('date', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('text_model')
# ### end Alembic commands ###

View File

@@ -1,376 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Create flask application"""
import hashlib
import os
import re
import secrets
import sys
import time
import typing as t
from datetime import datetime, timedelta, timezone
from functools import lru_cache
from threading import Thread
import flask
import portalocker # type: ignore
from flask_wtf.csrf import CSRFProtect # type: ignore
from werkzeug.exceptions import HTTPException
from werkzeug.middleware.proxy_fix import ProxyFix
from .const import KEY_FILENAME, KEY_SIZE, POW_DIFFICULTY, SOURCE
from .models import * # To preload all models
__all__: t.Tuple[str] = ("create_app",)
INDENTATION: t.Final[re.Pattern[str]] = re.compile(r"(?m)^[ \t]+|^\s*\n")
CSS_REPLACEMENTS: t.Final[t.Dict[str, str]] = {
": ": ":",
", ": ",",
" > ": ">",
" {": "{",
" / ": "/",
" + ": "+",
}
CSS_REPLACEMENTS_RE: t.Final[re.Pattern[str]] = re.compile(
"|".join(map(re.escape, CSS_REPLACEMENTS.keys()))
)
JS_WS_BLOCK: t.Final[re.Pattern[str]] = re.compile(r"@ws.*?@endws", re.DOTALL)
@lru_cache
def unindent_css(css: str) -> str:
"""Unindent CSS"""
return CSS_REPLACEMENTS_RE.sub(
lambda m: CSS_REPLACEMENTS[m.group(0)],
INDENTATION.sub("", css),
)
@lru_cache
def unindent_js(js: str) -> str:
"""Unindent JavaScript"""
parts: t.List[str] = []
last_end: int = 0
for match in JS_WS_BLOCK.finditer(js):
before: str = js[last_end : match.start()]
before_unindented: str = INDENTATION.sub("", before)
parts.append(before_unindented)
parts.append(match.group(0) + "\n")
last_end = match.end()
after: str = js[last_end:]
after_unindented: str = INDENTATION.sub("", after)
parts.append(after_unindented)
return "".join(parts)
def assign_http(app: flask.Flask) -> flask.Flask:
"""Assign HTTP robots stuff"""
# robots
@app.route("/robots.txt", methods=["GET", "POST"])
def __robots__() -> flask.Response:
"""Robots.txt control file"""
robots: str = (
f"""User-agent: *
Disallow: {flask.url_for("static", filename="css")}
Disallow: {flask.url_for("static", filename="js")}
Sitemap: {app.config['PREFERRED_URL_SCHEME']}://{flask.request.host}/sitemap.xml"""
)
response: flask.Response = flask.Response(robots, mimetype="text/plain")
response.headers["Content-Security-Policy"] = "img-src 'self';"
return response
# sitemap
rule: flask.Rule # type: ignore
sitemap: str = (
'<?xml version="1.0" encoding="UTF-8"?>\
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
)
def surl(loc: str) -> str:
"""sitemap url"""
u: str = "<url>"
u += f'<loc>{app.config["PREFERRED_URL_SCHEME"]}://%s{loc}</loc>'
u += "<priority>1.0</priority>"
return u + "</url>"
for rule in app.url_map.iter_rules(): # type: ignore
if rule.alias or not rule.methods or "GET" not in rule.methods: # type: ignore
continue
url: str = rule.rule # type: ignore
if ">" not in url:
sitemap += surl(url) # type: ignore
sitemap += "</urlset>"
@app.route("/sitemap.xml", methods=["GET", "POST"])
def __sitemap__() -> flask.Response:
"""Sitemap (website mapping)"""
response: flask.Response = flask.Response(
sitemap.replace("%s", flask.request.host), mimetype="application/xml"
)
response.headers["Content-Security-Policy"] = "img-src 'self';"
return response
return app
def create_app(name: str) -> flask.Flask:
"""Create flask application"""
for var in (
"DATABASE",
"LICENSE",
"NAME",
"EMAIL",
"MEMCACHED",
"SOURCE",
"OG_LOCALE",
):
if var not in os.environ or len(os.environ[var]) == 0:
print(
f"Error: Required environment variable {var} is unset", file=sys.stderr
)
sys.exit(1)
app: flask.Flask = flask.Flask(name)
if not app.debug:
app.wsgi_app = ProxyFix( # type: ignore
app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 # type: ignore
)
app.config["PREFERRED_URL_SCHEME"] = "http" if app.debug else "https"
# Workers support
if os.path.exists(KEY_FILENAME) and (
time.time() > os.path.getmtime(KEY_FILENAME) + 60
):
try:
with open(KEY_FILENAME, "rb") as fp:
portalocker.lock(fp, portalocker.LOCK_EX)
os.remove(KEY_FILENAME)
portalocker.unlock(fp)
except Exception:
pass
if not os.path.exists(KEY_FILENAME):
old_umask: int = os.umask(0o177)
try:
with open(KEY_FILENAME, "bx") as fc:
portalocker.lock(fc, portalocker.LOCK_EX)
fc.write(os.urandom(KEY_SIZE))
fc.flush()
portalocker.unlock(fc)
except FileExistsError:
pass
else:
def _remove_key():
time.sleep(60)
with open(KEY_FILENAME, "rb") as fp:
portalocker.lock(fp, portalocker.LOCK_EX)
if os.path.exists(KEY_FILENAME):
os.remove(KEY_FILENAME)
portalocker.unlock(fp)
Thread(target=_remove_key, daemon=True).start()
finally:
os.umask(old_umask)
with open(KEY_FILENAME, "rb") as kp:
portalocker.lock(kp, portalocker.LOCK_SH)
app.config["SECRET_KEY"] = kp.read(KEY_SIZE)
portalocker.unlock(kp)
# General config
app.config["SESSION_COOKIE_NAME"] = "__Host-session"
app.config["SESSION_COOKIE_SAMESITE"] = "strict"
app.config["SESSION_COOKIE_SECURE"] = True
app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["SQLALCHEMY_DATABASE_URI"] = os.environ["DATABASE"]
app.config["SQLALCHEMY_ENGINE_OPTIONS"] = {"pool_pre_ping": True}
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
app.config["USE_SESSION_FOR_NEXT"] = True
# Views
from .views import register_blueprints
register_blueprints(app)
# Database
from .db import db, migrate
db.init_app(app)
migrate.init_app(app, db)
# Rate limiting
from .limiter import limiter
limiter.init_app(app)
limiter.enabled = not app.debug
# CSP + CORP + rate limit
@app.before_request
@limiter.limit("")
def _() -> None:
"""Rate limit all requests"""
flask.g.csp_nonce = secrets.token_urlsafe(18)
@app.after_request
def _(response: flask.Response) -> flask.Response:
"""CSP, CORP, cache, security, and privacy"""
is_static: bool = flask.request.path.startswith(
flask.url_for("static", filename="")
)
csp_header: str = (
response.headers.get("Content-Security-Policy", "").strip().rstrip(";")
)
if is_static and "img-src" not in csp_header:
csp_header = f"{csp_header}; img-src 'self'"
if "Cross-Origin-Embedder-Policy" not in response.headers:
response.headers["Cross-Origin-Embedder-Policy"] = "require-corp"
csp_header = f"{csp_header}; default-src 'none'; base-uri 'none'"
if "form-action" not in csp_header:
csp_header = f"{csp_header}; form-action 'none'"
if not app.debug:
csp_header = f"{csp_header}; upgrade-insecure-requests"
response.headers["Content-Security-Policy"] = (
f"{csp_header.strip(';').strip()};"
)
if "Cross-Origin-Resource-Policy" not in response.headers:
response.headers["Cross-Origin-Resource-Policy"] = "same-origin"
if "X-Frame-Options" not in response.headers:
response.headers["X-Frame-Options"] = "DENY"
if "Referrer-Policy" not in response.headers:
response.headers["Referrer-Policy"] = "no-referrer"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-XSS-Protection"] = "0"
response.headers["Permissions-Policy"] = (
"accelerometer=(), autoplay=(), camera=(), cross-origin-isolated=(), display-capture=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), web-share=(), xr-spatial-tracking=(), clipboard-read=(), clipboard-write=(), gamepad=(), hid=(), idle-detection=(), interest-cohort=(), serial=(), unload=()"
)
response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
if is_static:
content_type: str = response.headers.get("Content-Type", "")
if "font/" in content_type:
ext: int = 60 * 60 * 24 * 30 * 6 # 6 months
else:
ext = 60 * 60 * 24 * 7 # 7 days
response.headers["Cache-Control"] = f"public, max-age={ext}"
expire_time: datetime = datetime.now(timezone.utc) + timedelta(seconds=ext)
response.headers["Expires"] = expire_time.strftime(
"%a, %d %b %Y %H:%M:%S GMT"
)
def process_resource_response(processor: t.Callable[[str], str]) -> None:
"""Process resource response"""
response.direct_passthrough = False
content: str = response.get_data(as_text=True)
modified: str = processor(content)
response.set_data(modified)
etag: str = hashlib.sha1(modified.encode("utf-8")).hexdigest()
response.set_etag(etag, weak=False)
response.make_conditional(flask.request)
if "text/css" in content_type:
process_resource_response(unindent_css)
elif "text/javascript" in content_type:
process_resource_response(unindent_js)
return response
# CSRF
CSRFProtect(app)
# Error
@app.errorhandler(HTTPException)
def _(e: HTTPException) -> flask.Response:
"""handle http errors"""
response: flask.Response = flask.make_response(
flask.render_template(
"error.j2",
code=e.code,
summary=e.name,
description=(e.description or f"HTTP error code {e.code}"),
),
)
response.headers["Content-Security-Policy"] = (
f"style-src 'self'; img-src 'self'; script-src 'nonce-{flask.g.csp_nonce}';"
)
response.status_code = e.code or 200
return response
# Template context
license: str = os.environ["LICENSE"]
author_name: str = os.environ["NAME"]
email: str = os.environ["EMAIL"]
og_locale: str = os.environ["OG_LOCALE"]
@app.context_processor # type: ignore
def _() -> t.Any:
"""Context processor"""
now: datetime = datetime.now(timezone.utc)
return {
"current_year": now.year,
"pow_difficulty": POW_DIFFICULTY,
"license": license,
"name": author_name,
"email": email,
"source_code": SOURCE,
"csp_nonce": flask.g.csp_nonce,
"locale": og_locale,
}
# Robots stuff
assign_http(app)
return app

View File

@@ -1,18 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Constants"""
import os
from typing import Final
TEXT_SIZE_MAX: Final[int] = 2048
POW_DIFFICULTY: Final[int] = 5
POW_DIFFICULTY_STR: Final[str] = "0" * POW_DIFFICULTY
POW_EXPIRES: Final[int] = 60 * 16 # 16 minutes
POW_NONCE_SIZE: Final[int] = 12
KEY_FILENAME: Final[str] = ".key"
KEY_SIZE: Final[int] = 128
SOURCE: Final[str] = os.environ.get("SOURCE", "http://127.0.0.1/")

View File

@@ -1,16 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Flask Database"""
from typing import Tuple
from flask_migrate import Migrate # type: ignore
from flask_sqlalchemy import SQLAlchemy # type: ignore
__all__: Tuple[str, str] = (
"db",
"migrate",
)
db: SQLAlchemy = SQLAlchemy()
migrate: Migrate = Migrate()

View File

@@ -1,12 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Forms"""
import typing as t
from . import prow, text
__all__: t.Tuple[str, ...] = (
"text",
"prow",
)

View File

@@ -1,64 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Proof-of-Work"""
import hashlib
import secrets
import time
import typing as t
import flask
from pbpl import const
__all__: t.Tuple[str, str] = (
"proof_of_work_protect_session",
"proof_of_work_verify_session",
)
def proof_of_work_protect_session(name: str) -> str:
"""Add a new proof-of-work session, returning a nonce"""
name = name.strip()
if not name:
raise NameError("PoW solution name cannot be empty.")
nonce: str = secrets.token_hex(const.POW_NONCE_SIZE // 2)
flask.session[f"_pow_{name}_nonce"] = nonce
flask.session[f"_pow_{name}_expires"] = time.time() + const.POW_EXPIRES
return nonce
def proof_of_work_verify_session(name: str, solution: str) -> bool:
"""Verify the proof-of-work solution using HMAC"""
if not (solution.isascii() and solution.isdigit()):
return False
nonce_key: str = f"_pow_{name}_nonce"
expires_key: str = f"_pow_{name}_expires"
if nonce_key not in flask.session or expires_key not in flask.session:
return False
nonce: str = flask.session[nonce_key]
expires: float = flask.session[expires_key]
flask.session.pop(nonce_key, None)
flask.session.pop(expires_key, None)
if len(nonce) != const.POW_NONCE_SIZE:
return False
if time.time() > expires:
return False
return (
hashlib.sha256((nonce + solution).encode("ascii"))
.hexdigest()
.startswith(const.POW_DIFFICULTY_STR)
)

View File

@@ -1,36 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Text form"""
import typing as t
from flask_wtf import FlaskForm # type: ignore
from wtforms import HiddenField, SubmitField, TextAreaField # type: ignore
from wtforms.validators import DataRequired, Length # type: ignore
from pbpl.const import TEXT_SIZE_MAX
__all__: t.Tuple[str, ...] = ("TextForm",)
class TextForm(FlaskForm):
"""Example text form"""
text = TextAreaField(
"Enter some text here",
validators=(
DataRequired(message="You must enter text"),
Length(
min=1,
max=TEXT_SIZE_MAX,
message=f"Invalid length (min=1, max={TEXT_SIZE_MAX}",
),
),
)
pow_solution = HiddenField(
"Proof of Work Solution",
validators=[DataRequired(message="Proof of work is required to submit.")],
)
form_submit = SubmitField("Submit text")

View File

@@ -1,15 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Rate limiting"""
import os
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
limiter: Limiter = Limiter(
get_remote_address,
default_limits=["125 per minute", "25 per second"],
storage_uri=f"memcached://{os.environ['MEMCACHED']}",
key_prefix="pbpl_",
)

View File

@@ -1,9 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Models"""
from typing import Tuple
from .text import TextModel
__all__: Tuple[str, ...] = ("TextModel",)

View File

@@ -1,37 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Text model"""
import datetime
import typing as t
from sqlalchemy import DateTime, Unicode
from pbpl.const import TEXT_SIZE_MAX
from pbpl.db import db
__all__: t.Tuple[str] = ("TextModel",)
class TextModel(db.Model):
"""Some text model"""
id = db.Column(
db.Integer,
unique=True,
primary_key=True,
autoincrement=True,
)
text = db.Column(
Unicode(TEXT_SIZE_MAX),
nullable=False,
)
date = db.Column(
DateTime,
default=lambda: datetime.datetime.now(datetime.timezone.utc),
nullable=False,
)
def __init__(self, text: str) -> None:
assert text and len(text) <= TEXT_SIZE_MAX
self.text = text

View File

@@ -1,19 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Views"""
from typing import Tuple
from flask import Flask
__all__: Tuple[str] = ("register_blueprints",)
def register_blueprints(app: Flask) -> Flask:
"""Assign all blueprints and their URL prefixes"""
from .home import home
app.register_blueprint(home, url_prefix="/")
return app

View File

@@ -1,261 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Blueprint"""
import re
from datetime import datetime, timedelta, timezone
from functools import wraps
from typing import Any, Callable, Final, Optional, Tuple, TypeVar, Union
from flask import Blueprint, Response, g, make_response
__all__: Tuple[str] = ("Bp",)
F = TypeVar("F", bound=Callable[..., Any])
DURATION_PATTERN: Final[re.Pattern[str]] = re.compile(
r"(?P<value>\d+)\s*(?P<unit>[dDhHmMsS])"
)
FRAME_ANCESTORS: Final[re.Pattern[str]] = re.compile(r"frame-ancestors\s+([^;]+)")
def parse_duration(duration_str: str) -> int:
"""Parse duration pattern e.g. '1d 2h 30m 15s' and return total seconds."""
duration_str = duration_str.lower().strip()
if duration_str == "0":
return 0
matches: Any = DURATION_PATTERN.findall(duration_str)
total_seconds: int = 0
for value, unit in matches:
value_int: int = int(value)
unit = unit.lower()
if unit == "d":
total_seconds += value_int * 86400
elif unit == "h":
total_seconds += value_int * 3600
elif unit == "m":
total_seconds += value_int * 60
elif unit == "s":
total_seconds += value_int
return total_seconds
class Bp(Blueprint):
"""Blueprint wrapper with utilities"""
def get(self, rule: str, ishtml: bool = False, **kwargs: Any) -> Callable[[F], F]:
"""Wrapper for GET"""
def decorator(f: F) -> F:
self.route(rule, methods=("GET",), **kwargs)(f)
if ishtml:
base_rule: str = rule.rstrip("/")
alias: bool = kwargs.pop("alias", True)
if base_rule:
self.route(
base_rule + "/", methods=("GET",), alias=alias, **kwargs
)(f)
self.route(
base_rule + ".html", methods=("GET",), alias=alias, **kwargs
)(f)
else:
self.route("/index.html", methods=("GET",), alias=alias, **kwargs)(
f
)
return f
return decorator
def post(self, rule: str, **kwargs: Any) -> Callable[[F], F]:
"""Wrapper for POST"""
def decorator(f: F) -> F:
self.route(rule, methods=("POST",), **kwargs)(f)
return f
return decorator
def csp(self, policy: Union[Callable[..., Any], str]) -> Callable[[F], F]:
"""Decorator to set Content-Security-Policy header for this route"""
if callable(policy):
csp_policy: str = getattr(policy, "_csp_policy", "")
else:
csp_policy = policy
for disallowed in ("default-src", "upgrade-insecure-requests", "base-uri"):
if disallowed in csp_policy:
raise ValueError(
f"Disallowed CSP directive {disallowed!r} should not be in custom CSP"
)
def decorator(f: F) -> F:
previous_policy = getattr(f, "_csp_policy", "")
combined_policy = ";".join(
filter(None, [previous_policy.strip(";"), csp_policy.strip(";")])
)
@wraps(f)
def wrapped(*args: Any, **kwargs: Any):
result: Any = f(*args, **kwargs)
response: Response = make_response(result)
existing_csp: str = (
response.headers.get("Content-Security-Policy", "")
.strip()
.rstrip(";")
)
final_policy: str = combined_policy
final_policy = (
final_policy.replace("$nonce", f"'nonce-{g.csp_nonce}'")
.replace("$self", "'self'")
.replace("$none", "'none'")
.replace("$internal", f"'self' 'nonce-{g.csp_nonce}'")
.replace("$wasm", "'wasm-unsafe-eval'")
.strip()
.rstrip(";")
)
full_csp: str = (
";".join(filter(None, [existing_csp, final_policy])) + ";"
)
if "frame-ancestors" in full_csp:
match = FRAME_ANCESTORS.search(full_csp)
if match:
ancestors_value = match.group(1).strip()
if ancestors_value == "'none'":
response.headers["X-Frame-Options"] = "DENY"
elif ancestors_value == "'self'":
response.headers["X-Frame-Options"] = "SAMEORIGIN"
response.headers["Content-Security-Policy"] = full_csp
return response
setattr(wrapped, "_csp_policy", combined_policy)
return wrapped # type: ignore
return decorator
def corp(
self, origin: Union[str, Callable[..., Any]], *methods: str
) -> Callable[[F], F]:
"""Decoreator to set the Cross-Origin-Resource-Policy"""
if callable(origin):
origin_str: str = getattr(origin, "_corp_origin")
methods = getattr(origin, "_corp_methods")
else:
origin_str = origin
if not methods:
raise ValueError("Must specify at least one method.")
def decorator(f: F) -> Any:
@wraps(f)
def wrapped(*args: Any, **kwargs: Any):
result: Any = f(*args, **kwargs)
response: Response = make_response(result)
response.headers["Cross-Origin-Resource-Policy"] = "cross-origin"
response.headers["Access-Control-Allow-Origin"] = origin_str
response.headers["Access-Control-Allow-Methods"] = ", ".join(methods)
return response
setattr(wrapped, "_corp_origin", origin)
setattr(wrapped, "_corp_methods", methods)
return wrapped
return decorator
def cache(
self, duration: Optional[Union[str, Callable[..., Any]]]
) -> Callable[[F], F]:
"""Decoreator to set the Cache Control"""
if callable(duration):
duration_sec: int = getattr(duration, "_duration_sec", 0)
else:
duration_sec = parse_duration(duration) if duration else 0
def decorator(f: F) -> Any:
@wraps(f)
def wrapped(*args: Any, **kwargs: Any):
result: Any = f(*args, **kwargs)
response: Response = make_response(result)
if duration_sec == 0:
response.headers["Cache-Control"] = "no-cache, must-revalidate"
response.headers["Expires"] = "Thu, 1 Jan 1970 00:00:00 GMT"
else:
response.headers["Cache-Control"] = (
f"public, max-age={duration_sec}"
)
expire_time: datetime = datetime.now(timezone.utc) + timedelta(
seconds=duration_sec
)
response.headers["Expires"] = expire_time.strftime(
"%a, %d %b %Y %H:%M:%S GMT"
)
return response
setattr(wrapped, "_duration_sec", duration_sec)
return wrapped
return decorator
def refpol(self, policy: Union[str, Callable[..., Any]]) -> Callable[[F], F]:
"""Decoreator to set the Referrer Policy"""
if callable(policy):
ref_policy: str = getattr(policy, "_ref_policy", "no-referrer")
else:
ref_policy = policy
def decorator(f: F) -> Any:
@wraps(f)
def wrapped(*args: Any, **kwargs: Any):
result: Any = f(*args, **kwargs)
response: Response = make_response(result)
response.headers["Referrer-Policy"] = ref_policy
return response
setattr(wrapped, "_ref_policy", ref_policy)
return wrapped
return decorator
def coep(self, policy: Union[str, Callable[..., Any]]) -> Callable[[F], F]:
"""Decoreator to set the Cross-Origin-Embedder-Policy"""
if callable(policy):
coep_policy: str = getattr(policy, "_coep_policy")
else:
coep_policy = policy
def decorator(f: F) -> Any:
@wraps(f)
def wrapped(*args: Any, **kwargs: Any):
result: Any = f(*args, **kwargs)
response: Response = make_response(result)
response.headers["Cross-Origin-Embedder-Policy"] = coep_policy
return response
setattr(wrapped, "_coep_policy", coep_policy)
return wrapped
return decorator

View File

@@ -1,97 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Home routes"""
import typing as t
import flask
from werkzeug.wrappers import Response
import pbpl.forms.prow as form_pow
import pbpl.forms.text as text_form
from pbpl.db import db
from pbpl.models import TextModel
from .bp import Bp
home: Bp = Bp("home", __name__)
__all__: t.Tuple[str] = ("home",)
@home.get("/", ishtml=True)
@home.csp("img-src $self; script-src $internal $wasm; style-src $internal; form-action $self; connect-src $self; manifest-src $self")
def index() -> str:
"""Home page"""
nonce: str = form_pow.proof_of_work_protect_session("home.index/text")
return flask.render_template(
"home/index.j2",
form=text_form.TextForm(),
texts=TextModel.query.all(),
pow_nonce=nonce,
)
@home.post("/")
@home.csp("img-src $self; script-src $internal; style-src $internal")
def text() -> Response:
"""Post some text"""
form: text_form.FlaskForm = text_form.TextForm()
if not form.validate_on_submit(): # type: ignore
flask.flash("Invalid form data/form", "error")
flask.abort(400)
if form.text.data is None:
flask.flash("Invalid form text", "error")
flask.abort(400)
if form.pow_solution.data is None or not form_pow.proof_of_work_verify_session(
"home.index/text",
form.pow_solution.data,
):
flask.flash("Invalid Proof-of-Work solution", "error")
flask.abort(403)
db.session.add(TextModel(text=form.text.data))
db.session.commit()
flask.flash("Your text has been saved")
return flask.redirect(flask.url_for("home.index"))
@home.get("/manifest.json")
@home.csp("manifest-src $self; img-src $self")
@home.cache("30d")
def manifest() -> t.Any:
"""Manifest file"""
return flask.jsonify( # type: ignore
{
"$schema": "https://json.schemastore.org/web-manifest-combined.json",
"short_name": "Example page",
"name": "Example page",
"description": "This is an example description",
"icons": [
{
"src": "/favicon.png",
"sizes": "256x256",
"type": "image/png",
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#fbfbfb",
"background_color": "#121212",
}
)
@home.get("/favicon.png")
@home.csp("img-src $self")
@home.cache("30d")
def favicon() -> Response:
"""Website icon"""
return flask.send_from_directory("static", "favicon.png")

View File

@@ -1,116 +0,0 @@
:root {
color-scheme: dark;
}
*,
*::before,
*::after {
-webkit-box-sizing: border-box;
box-sizing: border-box;
word-wrap: break-word;
scroll-behavior: smooth;
}
code,
code *,
pre,
pre * {
white-space: pre-wrap;
white-space: -moz-pre-wrap;
white-space: -pre-wrap;
white-space: -o-pre-wrap;
}
@font-face {
font-family: 'Clockopia';
src: url("/static/fonts/Clockopia.ttf") format("truetype");
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: 'LuxiSans';
src: url("/static/fonts/luxisr.ttf") format("truetype");
font-weight: normal;
font-style: normal;
}
html {
height: 100%;
min-height: 100%;
background-color: #0e0016;
}
body {
height: auto;
min-height: 100%;
padding: 2rem;
min-height: 100vh;
font-family: 'LuxiSans', sans-serif;
}
html,
body {
line-height: 1.5;
}
ol,
ul,
ol *,
ul * {
line-height: 1.8;
}
h1 {
font-family: 'Clockopia', sans-serif;
}
.buttons {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: center;
margin-top: 20px;
}
.buttons img {
width: 88px;
height: 31px;
border: none;
image-rendering: pixelated;
}
code {
white-space: pre-wrap !important;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
-webkit-transition: none !important;
-o-transition: none !important;
transition: none !important;
-webkit-animation: none !important;
animation: none !important;
-webkit-animation-play-state: paused !important;
animation-play-state: paused !important;
scroll-behavior: auto !important;
}
}
#night {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #0e0016;
z-index: -9999;
}

View File

@@ -1,3 +0,0 @@
article {
text-align: center;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -1,353 +0,0 @@
/**
* @licstart The following is the entire license notice for the JavaScript
* code in this file.
*
* Copyright (C) 2025 Arija A. <ari@ari.lt>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* @licend The above is the entire license notice for the JavaScript code
* in this file.
*/
"use strict";
const pow_text_encoder = new TextEncoder();
/**
* Minimal pure JavaScript SHA-256 implementation.
*/
function pow_sha256_js(msg_uint8) {
const K = new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1,
0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786,
0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147,
0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a,
0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
]);
function right_rotate(value, amount) {
return (value >>> amount) | (value << (32 - amount));
}
const msg_length = msg_uint8.length;
const bit_length = msg_length * 8;
/* Padding */
const padded_length =
((msg_length + 9 + 63) >> 6) << 6; /* multiple of 64 */
const padded = new Uint8Array(padded_length);
padded.set(msg_uint8);
padded[msg_length] = 0x80;
const dv = new DataView(padded.buffer);
/* Append 64-bit big-endian length */
dv.setUint32(
padded_length - 8,
Math.floor(bit_length / 0x100000000),
false,
);
dv.setUint32(padded_length - 4, bit_length & 0xffffffff, false);
/* Initial hash values */
let h0 = 0x6a09e667;
let h1 = 0xbb67ae85;
let h2 = 0x3c6ef372;
let h3 = 0xa54ff53a;
let h4 = 0x510e527f;
let h5 = 0x9b05688c;
let h6 = 0x1f83d9ab;
let h7 = 0x5be0cd19;
const w = new Uint32Array(64);
for (let i = 0; i < padded_length; i += 64) {
for (let t = 0; t < 16; ++t) {
w[t] = dv.getUint32(i + t * 4, false);
}
for (let t = 16; t < 64; ++t) {
const s0 =
right_rotate(w[t - 15], 7) ^
right_rotate(w[t - 15], 18) ^
(w[t - 15] >>> 3);
const s1 =
right_rotate(w[t - 2], 17) ^
right_rotate(w[t - 2], 19) ^
(w[t - 2] >>> 10);
w[t] = (w[t - 16] + s0 + w[t - 7] + s1) >>> 0;
}
let a = h0;
let b = h1;
let c = h2;
let d = h3;
let e = h4;
let f = h5;
let g = h6;
let h = h7;
for (let t = 0; t < 64; ++t) {
const S1 =
right_rotate(e, 6) ^ right_rotate(e, 11) ^ right_rotate(e, 25);
const ch = (e & f) ^ (~e & g);
const temp1 = (h + S1 + ch + K[t] + w[t]) >>> 0;
const S0 =
right_rotate(a, 2) ^ right_rotate(a, 13) ^ right_rotate(a, 22);
const maj = (a & b) ^ (a & c) ^ (b & c);
const temp2 = (S0 + maj) >>> 0;
h = g;
g = f;
f = e;
e = (d + temp1) >>> 0;
d = c;
c = b;
b = a;
a = (temp1 + temp2) >>> 0;
}
h0 = (h0 + a) >>> 0;
h1 = (h1 + b) >>> 0;
h2 = (h2 + c) >>> 0;
h3 = (h3 + d) >>> 0;
h4 = (h4 + e) >>> 0;
h5 = (h5 + f) >>> 0;
h6 = (h6 + g) >>> 0;
h7 = (h7 + h) >>> 0;
}
const hash = new Uint8Array(32);
const hash_view = new DataView(hash.buffer);
hash_view.setUint32(0, h0, false);
hash_view.setUint32(4, h1, false);
hash_view.setUint32(8, h2, false);
hash_view.setUint32(12, h3, false);
hash_view.setUint32(16, h4, false);
hash_view.setUint32(20, h5, false);
hash_view.setUint32(24, h6, false);
hash_view.setUint32(28, h7, false);
return hash;
}
/**
* Compute SHA-256 hash of input Uint8Array.
* Uses native crypto.subtle.digest if available, otherwise falls back to pure JS implementation.
*/
async function pow_sha256(message_buffer) {
if (
typeof crypto === "object" &&
crypto.subtle &&
typeof crypto.subtle.digest === "function"
) {
return await crypto.subtle.digest("SHA-256", message_buffer);
} else {
return pow_sha256_js(message_buffer).buffer;
}
}
/**
* Encode a string to Uint8Array using TextEncoder.
*/
function pow_str_to_u8(str) {
return pow_text_encoder.encode(str);
}
/**
* Check if the hash buffer meets the difficulty.
* Difficulty is number of leading zero hex characters (nibbles).
* This function checks bytes directly for better performance.
*/
function pow_meets_difficulty_bytes(hash_buffer, difficulty) {
const hash_bytes = new Uint8Array(hash_buffer);
const zero_bytes = Math.floor(difficulty / 2);
const half_nibble = difficulty % 2;
for (let idx = 0; idx < zero_bytes; ++idx) {
if (hash_bytes[idx] !== 0) return false;
}
if (half_nibble) {
if ((hash_bytes[zero_bytes] & 0xf0) !== 0) return false;
}
return true;
}
/**
* JS Proof-of-Work
*/
async function js_proof_of_work(button, difficulty, nonce) {
let tries = 0;
let solution = 0;
const nonce_str = nonce.toString();
const batch_size = 150000;
while (true) {
for (let idx = 0; idx < batch_size; ++idx) {
const message = nonce_str + solution.toString();
const message_buffer = pow_str_to_u8(message);
const hash_buffer = await pow_sha256(message_buffer);
if (pow_meets_difficulty_bytes(hash_buffer, difficulty)) {
button.value = `Computed PoW after ${tries + idx} tries`;
return solution.toString();
}
++solution;
}
tries += batch_size;
button.value = `Computing PoW (${tries} tries)`;
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
/**
* WASM Proof-of-Work
*/
async function wasm_proof_of_work(button, difficulty, nonce) {
let pow_found = false;
let pow_solution = null;
const wasm_path = "/static/js/wasm/pow.wasm";
const imports = {
global: {
pow_update: (tries) => {
button.value = `Computing PoW (${tries} tries)`;
},
pow_finish: (tries, solution) => {
button.value = `Computed PoW after ${tries} tries`;
pow_solution = solution;
pow_found = true;
},
},
};
let response = await fetch(wasm_path);
if (!response.ok) {
console.warn(`Failed to fetch WASM ${wasm_path}`);
return null;
}
let wasm_module;
if (
"instantiateStreaming" in WebAssembly &&
typeof WebAssembly.instantiateStreaming === "function"
) {
wasm_module = await WebAssembly.instantiateStreaming(response, imports);
} else {
const bytes = await response.arrayBuffer();
wasm_module = await WebAssembly.instantiate(bytes, imports);
}
const { pow_init, pow_solve_batch, memory } = wasm_module.instance.exports;
const encoder = new TextEncoder();
const nonce_bytes = encoder.encode(nonce);
const memory_view = new Uint8Array(memory.buffer);
memory_view.set(nonce_bytes, 0);
pow_init(0, nonce_bytes.length, difficulty);
while (!pow_found) {
const result = pow_solve_batch();
if (result === 1) {
// Solution found, pow_finish callback fired
break;
} else if (result === -1) {
console.error("WASM PoW (initialisation) error.");
return null;
}
// Yield to UI between batches
await new Promise((resolve) => setTimeout(resolve, 0));
}
return pow_solution === null ? null : pow_solution.toString();
}
/**
* Perform proof of work by finding a solution such that
* SHA-256(nonce + solution) hash has leading zeros per difficulty.
*/
async function proof_of_work(button, difficulty, nonce) {
button.value = "Computing PoW (0 tries)";
await new Promise((resolve) => setTimeout(resolve, 0));
if ("WebAssembly" in window && typeof WebAssembly === "object") {
const sol = await wasm_proof_of_work(button, difficulty, nonce);
if (sol !== null) {
return sol;
} else {
console.warn(
"WebAssembly present but failed to compute PoW using web assembly. Falling back to JavaScript.",
);
}
}
return await js_proof_of_work(button, difficulty, nonce);
}
/**
* Wrapper to handle form submission and disable button during PoW.
*/
async function proof_of_work_powform(form, button, difficulty, nonce) {
const was_disabled = button.disabled;
button.disabled = true;
button.setAttribute("aria-label", "Proof of Work is computing...");
try {
form.elements["pow_solution"].value = await proof_of_work(
button,
difficulty,
nonce,
);
} finally {
button.disabled = was_disabled;
button.setAttribute("aria-label", "Submitting...");
}
}
/**
* Wrapper to handle form submission and disable button during PoW on page load.
*/
function proof_of_work_powform_onload(form_id, button_id, difficulty, nonce) {
const form = document.getElementById(form_id);
const form_submit = document.getElementById(button_id);
form.addEventListener("submit", async (evt) => {
evt.preventDefault();
form_submit.disabled = true;
await proof_of_work_powform(form, form_submit, difficulty, nonce);
form_submit.value = "Submitting...";
form.submit();
});
}

View File

@@ -1,19 +0,0 @@
---
BasedOnStyle: LLVM
IndentWidth: 4
SortIncludes: false
AlignConsecutiveAssignments: true
AlignConsecutiveBitFields: true
AlignConsecutiveMacros: true
AlignEscapedNewlines: true
AllowShortCaseLabelsOnASingleLine: true
AllowShortEnumsOnASingleLine: true
AllowShortFunctionsOnASingleLine: true
AllowShortLambdasOnASingleLine: true
BinPackParameters: false
IndentCaseBlocks: true
IndentCaseLabels: true
IndentExternBlock: true
IndentGotoLabels: true
---

View File

@@ -1,6 +0,0 @@
#!/bin/sh
set -xeu
clang --target=wasm32 -s -O3 -flto=full -fno-trapping-math -funroll-loops -ffast-math -fno-math-errno -fomit-frame-pointer -fstrict-aliasing -fvisibility-inlines-hidden -fvisibility=hidden -std=c99 -Werror -Wpedantic -pedantic-errors -pedantic -nostdlib -Wl,--no-entry -Wl,--export=pow_init -Wl,--export=pow_solve_batch -o pow.wasm pow.c
chmod 600 pow.wasm

View File

@@ -1,203 +0,0 @@
#include <stdint.h>
#include <stddef.h>
#define BATCH_SIZE 150000
#define MAX_MSG_LEN 256
#define NONCE_MAX_LEN 128
/* Global state */
static char solution_str[32];
static uint8_t hash_output[32];
static uint8_t message_buffer[MAX_MSG_LEN];
static uint32_t nonce_length = 0;
static uint32_t difficulty = 0;
static uint64_t current_solution = 0;
static uint64_t tries = 0;
static uint32_t zero_bytes = 0;
static uint32_t half_nibble = 0;
__attribute__((__import_module__("global"), __import_name__("pow_update"))) void
pow_update(uint64_t tries);
__attribute__((__import_module__("global"), __import_name__("pow_finish"))) void
pow_finish(uint64_t tries, uint64_t solution);
static const uint32_t K[64] = {
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1,
0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786,
0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147,
0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a,
0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
};
#define ROTR(x, n) (((x) >> (n)) | ((x) << (32 - (n))))
#define CH(x, y, z) (((x) & (y)) ^ (~(x) & (z)))
#define MAJ(x, y, z) (((x) & (y)) ^ ((x) & (z)) ^ ((y) & (z)))
#define EP0(x) (ROTR(x, 2) ^ ROTR(x, 13) ^ ROTR(x, 22))
#define EP1(x) (ROTR(x, 6) ^ ROTR(x, 11) ^ ROTR(x, 25))
#define SIG0(x) (ROTR(x, 7) ^ ROTR(x, 18) ^ ((x) >> 3))
#define SIG1(x) (ROTR(x, 17) ^ ROTR(x, 19) ^ ((x) >> 10))
static inline void g_sha256_hash(uint32_t message_length) {
uint32_t h[8] = {0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19};
const uint64_t bit_length = (uint64_t)message_length * 8;
const uint32_t padded_length = ((message_length + 9 + 63) / 64) * 64;
static uint8_t padded_msg[128];
for (uint32_t idx = 0; idx < message_length; ++idx) {
padded_msg[idx] = message_buffer[idx];
}
padded_msg[message_length] = 0x80;
for (uint32_t idx = message_length + 1; idx < padded_length - 8; ++idx) {
padded_msg[idx] = 0;
}
for (int idx = 0; idx < 8; ++idx) {
padded_msg[padded_length - 1 - idx] =
(uint8_t)(bit_length >> (8 * idx));
}
for (uint32_t chunk = 0; chunk < padded_length; chunk += 64) {
uint32_t w[64];
for (int idx = 0; idx < 16; ++idx) {
w[idx] = ((uint32_t)padded_msg[chunk + idx * 4] << 24) |
((uint32_t)padded_msg[chunk + idx * 4 + 1] << 16) |
((uint32_t)padded_msg[chunk + idx * 4 + 2] << 8) |
((uint32_t)padded_msg[chunk + idx * 4 + 3]);
}
for (int idx = 16; idx < 64; ++idx) {
w[idx] =
SIG1(w[idx - 2]) + w[idx - 7] + SIG0(w[idx - 15]) + w[idx - 16];
}
uint32_t a = h[0], b = h[1], c = h[2], d = h[3];
uint32_t e = h[4], f = h[5], g = h[6], h_val = h[7];
for (int idx = 0; idx < 64; ++idx) {
const uint32_t t1 = h_val + EP1(e) + CH(e, f, g) + K[idx] + w[idx];
const uint32_t t2 = EP0(a) + MAJ(a, b, c);
h_val = g;
g = f;
f = e;
e = d + t1;
d = c;
c = b;
b = a;
a = t1 + t2;
}
h[0] += a;
h[1] += b;
h[2] += c;
h[3] += d;
h[4] += e;
h[5] += f;
h[6] += g;
h[7] += h_val;
}
for (int idx = 0; idx < 8; ++idx) {
hash_output[idx * 4] = (h[idx] >> 24) & 0xFF;
hash_output[idx * 4 + 1] = (h[idx] >> 16) & 0xFF;
hash_output[idx * 4 + 2] = (h[idx] >> 8) & 0xFF;
hash_output[idx * 4 + 3] = h[idx] & 0xFF;
}
}
static inline int uint64_to_str(uint64_t number, char *str) {
if (number == 0) {
str[0] = '0';
str[1] = '\0';
return 1;
}
int length = 0;
uint64_t temp = number;
while (temp > 0) {
++length;
temp /= 10;
}
str[length] = '\0';
for (int idx = length - 1; idx >= 0; --idx) {
str[idx] = '0' + (number % 10);
number /= 10;
}
return length;
}
static inline int g_meets_difficulty(void) {
for (uint32_t idx = 0; idx < zero_bytes; ++idx) {
if (hash_output[idx] != 0) {
return 0;
}
}
if (half_nibble && (hash_output[zero_bytes] & 0xF0) != 0) {
return 0;
}
return 1;
}
__attribute__((export_name("pow_init"))) void
pow_init(uintptr_t nonce_ptr, uint32_t _nonce_length, uint32_t _difficulty) {
const uint8_t *nonce = (const uint8_t *)nonce_ptr;
nonce_length =
(_nonce_length > NONCE_MAX_LEN) ? NONCE_MAX_LEN : _nonce_length;
difficulty = _difficulty;
zero_bytes = _difficulty / 2;
half_nibble = _difficulty % 2;
current_solution = 0;
tries = 0;
for (uint32_t idx = 0; idx < nonce_length; ++idx) {
message_buffer[idx] = nonce[idx];
}
}
__attribute__((export_name("pow_solve_batch"))) int pow_solve_batch(void) {
if (nonce_length == 0) {
return 0;
}
for (uint32_t batch_idx = 0; batch_idx < BATCH_SIZE; batch_idx++) {
const int sol_len = uint64_to_str(current_solution, solution_str);
const int message_length = nonce_length + sol_len;
if (message_length > MAX_MSG_LEN) {
return -1;
}
for (int idx = 0; idx < sol_len; ++idx) {
message_buffer[nonce_length + idx] = (uint8_t)solution_str[idx];
}
g_sha256_hash(message_length);
if (g_meets_difficulty()) {
pow_finish(tries + batch_idx, current_solution);
return 1;
}
++current_solution;
}
tries += BATCH_SIZE;
pow_update(tries);
return 0;
}

View File

@@ -1,101 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Untitled{% endblock %} - {{ request.host | escape }}</title>
<link rel="icon" href="{{ url_for("home.favicon") }}" sizes="128x128" type="image/png" />
<meta name="description" content="{% block description %}Description of an untitled page.{% endblock %}" />
<meta
name="keywords"
content="sample app, example, testing, {% block keywords %}untitled{% endblock %}"
/>
<meta
name="robots"
content="follow, index, max-snippet:-1, max-video-preview:-1, max-image-preview:large"
/>
<meta property="og:type" content="{% block type %}website{% endblock %}" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="{% block colour %}#0e0016{% endblock %}" />
<meta property="og:locale" content="{{ locale | escape }}" />
<meta name="foss:src" content="{{ source_code | escape }}" />
<meta name="author" content="{{ name | escape }}" />
<meta name="license" content="{{ license | escape }}" />
<link rel="manifest" href="{{ url_for("home.manifest") }}" />
<link rel="canonical" href="{{ request.scheme | escape }}://{{ request.host | escape }}{{ request.path | escape }}" />
<link rel="og:url" href="{{ request.scheme | escape }}://{{ request.host | escape }}{{ request.path | escape }}" />
<link rel="sitemap" href="/sitemap.xml" type="application/xml" />
{%- macro style(filename) -%}<link rel="stylesheet" href="{{ url_for("static", filename=filename) }}" type="text/css" referrerpolicy="no-referrer" />{%- endmacro -%}
{%- macro script(filename) -%}<script src="{{ url_for("static", filename=filename) }}" defer referrerpolicy="no-referrer"></script>{%- endmacro -%}
{{ style("css/base.css") }}
<script type="text/javascript" nonce="{{ csp_nonce }}">
<!--//--><![CDATA[//><!--
/**
* @licstart The following is the entire license notice for the JavaScript
* code in this page.
*
* Copyright (C) {{ current_year }} {{ name | escape }} <{{ name | escape }}>
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* You should have received a copy of the Apache License, Version 2.0
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
*
* @licend The above is the entire license notice for the JavaScript code
* in this page.
*/
//--><!]]>
</script>
{{ script("js/base.js") }}
{% block head %}{% endblock %}
</head>
<body>
<canvas id="night"></canvas>
{%- block body -%}{%- endblock -%}
<main>
<article>
<header>
{%- block header -%}<h1>Untitled</h1>{%- endblock -%}
</header>
<div id="content">
{%- with messages = get_flashed_messages(with_categories=True) -%}
{% if messages %}
<ul>
{% for category, message in messages %}
<li>[{{ category | escape }}] {{ message | escape }}</li>
{% endfor %}
</ul>
{% endif %}
{%- endwith -%}
{%- block content -%}<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>{%- endblock -%}
</div>
<footer>
<p>&copy; {{ current_year }} {{ name | escape }} &lt;<a href="mailto:{{ email | escape }}">{{ email | escape }}</a>&gt;. Licensed under {{ license | escape }}. Source code available <a href="{{ source_code | escape }}" rel="noopener noreferrer" target="_blank">here</a>.</p>
</footer>
</article>
</main>
</body>
</html>

View File

@@ -1,11 +0,0 @@
{% extends "base.j2" %}
{% block title %}{{ code }} / {{ summary | escape }}{% endblock %}
{% block head %}<link rel="stylesheet" href="{{ url_for("static", filename="css/error.css")}}" />{% endblock %}
{% block description %}{{ code }} / {{ summary | escape }}{% endblock %}
{% block header %}<h1>{{ code }} / {{ summary | escape }}</h1>{% endblock %}
{% block content %}<p>{{ description | escape }}</p><p>Go back to <a href="{{ url_for("home.index") }}">the homepage</a> or <a href="#prev-page" id="prev-page" rel="noopener noreferrer">the previous page</a> :)</p><script nonce="{{ csp_nonce }}">document.getElementById("prev-page").addEventListener("click",(e)=>{e.preventDefault();history.back()});</script>{% endblock %}

View File

@@ -1,83 +0,0 @@
{% extends "base.j2" %}
{% block title %}Purplebored.pl{% endblock %}
{% block description %}Hello, World! Welcome to purplebored.pl! Probably the best website in all of Poland!{% endblock %}
{% block keywords %}purplebored, poland, polish, personal website, website, buttons, niko, self-hosted, open source, fosshome{% endblock %}
{%- block head -%}
<script src="{{ url_for("static", filename="js/pow.js") }}"></script>
{%- endblock -%}
{%- block header -%}
<h1>Purplebored</h1>
{%- endblock -%}
{%- block content -%}
<p>Hi, I am Purplebored, o/ also known as Niko. Right now you are on my own little website which I call my home on the World Wide Web! </p>
<p>I like making simple websites, and I am also a privacy advocate. I also love tobacco, and I am a big nicotine addict. I speak Polish and English, but I am also learning Russian. Regarding programming, I currently only know basic HTML. From other stuff, I also know some Linux and quite a lot about Windows — especially the older versions like Windows 7.</p>
<script nonce="{{ csp_nonce }}" defer>proof_of_work_powform_onload("form", "form_submit", {{ pow_difficulty }}, {{ pow_nonce | tojson }});</script>
<h2>Badges</h2>
<!-- ====== 88x31 Buttons Section ====== -->
<div class="buttons">
<a href="https://purplebored.pl/" target="_blank">
<img src="{{ url_for('static', filename='images/buttons/Purplebored_button.png') }}" alt="My button" />
</a>
<a href="https://unpato.neocities.org/" target="_blank">
<img src="{{ url_for('static', filename='images/buttons/unpato.png') }}" alt="Unpato writing button" />
</a>
<a href="https://eclipse.cx" target="_blank">
<img src="{{ url_for('static', filename='images/buttons/eclipsecx.png') }}" alt="Eclipse Community button" />
</a>
<a href="https://aagaming.me/" target="_blank">
<img src="{{ url_for('static', filename='images/buttons/aagaming.png') }}" alt="AAgaming button" />
</a>
<a href="https://themcgovern.net" target="_blank">
<img src="{{ url_for('static', filename='images/buttons/mcneb10.gif') }}" alt="Mcneb10 button" />
</a>
<a href="https://moody.im/" target="_blank">
<img src="{{ url_for('static', filename='images/buttons/georgemoody.png') }}" alt="Georgemoody button" />
</a>
<a href="https://xameren.fsky.io" target="_blank">
<img src="{{ url_for('static', filename='images/buttons/xameren.png') }}" alt="Xameren button" />
</a>
<a href="https://fsky.io" target="_blank">
<img src="{{ url_for('static', filename='images/buttons/fsky_night.png') }}" alt="FSKY.io button" />
</a>
<a href="https://ari.lt/" title="ari-web badge" rel="noopener noreferrer" target="_blank">
<img src="{{ url_for('static', filename='images/buttons/ari.png') }}" alt="ari-web badge" />
</a>
<a href="https://telepath.im" target="_blank">
<img src="{{ url_for('static', filename='images/buttons/telepath.png') }}" alt="Telepath.im button" />
</a>
<a href="https://aleksey-kon-games.fsky.io/" target="_blank">
<img src="{{ url_for('static', filename='images/buttons/aleksey.png') }}" alt="Aleksey Kon Games button" />
</a>
<a href="https://k327.eu/" target="_blank">
<img src="{{ url_for('static', filename='images/buttons/k327.png') }}" alt="k327.eu button" />
</a>
<a href="https://vaporwavefox.neocities.org" target="_blank">
<img src="{{ url_for('static', filename='images/buttons/trace.png') }}" alt="Trace button" />
</a>
<a href="https://authenyo.xyz" target="_blank">
<img src="{{ url_for('static', filename='images/buttons/authen.gif') }}" alt="Authen button" />
</a>
<a href="http://[300:5506:25eb:d0d9::]" target="_blank">
<img src="{{ url_for('static', filename='images/buttons/midgard.png') }}" alt="Midgard button" />
</a>
</div>
<!-- ====== End Buttons Section ====== -->
{% for text in texts %}
<div>
<p>{{ text.text | escape }}</p>
<p>Date: {{ text.date }}</p>
</div>
{% endfor %}
{%- endblock -%}

View File

@@ -1,8 +0,0 @@
[tox]
envlist = py310
[flake8]
max-line-length = 160
[pycodestyle]
max-line-length = 160