Compare commits
13 Commits
81b8a8968a
...
rewrite
| Author | SHA1 | Date | |
|---|---|---|---|
|
41e00febdb
|
|||
|
9ee1a587d2
|
|||
|
89f75d87c4
|
|||
|
1911daa2ec
|
|||
|
53c1c94f83
|
|||
|
fd6223239e
|
|||
|
d825b910d7
|
|||
|
e2c2f66936
|
|||
|
91f95f81e6
|
|||
|
b91dfc1971
|
|||
|
98a5856029
|
|||
|
56aee59790
|
|||
|
caf4ac7c06
|
11
.editorconfig
Normal file
@@ -0,0 +1,11 @@
|
||||
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
|
||||
|
||||
13
.eslintrc.js
Normal file
@@ -0,0 +1,13 @@
|
||||
module.exports = {
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true
|
||||
},
|
||||
"extends": "eslint:recommended",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
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
|
||||
171
.gitignore
vendored
Normal file
@@ -0,0 +1,171 @@
|
||||
# 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
|
||||
4
.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": false
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
-----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-----
|
||||
2
LICENSE
@@ -187,7 +187,7 @@
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [2023 - 2025] [Purplebored]
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
||||
55
README.md
@@ -1,21 +1,44 @@
|
||||
## purplebored.pl
|
||||
# purplebored.pl
|
||||
|
||||
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)
|
||||
> Just my personal website
|
||||
|
||||
### 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
|
||||
## Running
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
First compile WASM PoW:
|
||||
|
||||
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)
|
||||
```sh
|
||||
cd src/static/js/wasm
|
||||
./compile.sh
|
||||
cd ../../../../
|
||||
```
|
||||
|
||||
### 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!
|
||||
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
|
||||
```
|
||||
|
||||
@@ -1,390 +0,0 @@
|
||||
/*
|
||||
* 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;
|
||||
}
|
||||
|
||||
/* === NAVBAR ON THE RIGHT === */
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* === LINKS === */
|
||||
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;
|
||||
}
|
||||
|
||||
/* === MAIN CONTENT === */
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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: -webkit-optimize-contrast;
|
||||
image-rendering: -moz-crisp-edges;
|
||||
image-rendering: -o-pixelated;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
/* === BACKGROUND CANVAS === */
|
||||
#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: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 5rem 2rem 8rem;
|
||||
}
|
||||
|
||||
.project-page h1,
|
||||
.project-page h2,
|
||||
.project-page h3 {
|
||||
font-family: "Clockopia", sans-serif;
|
||||
color: #fff5a8;
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
|
||||
/* === RESPONSIVE FIXES === */
|
||||
@media (max-width: 900px) {
|
||||
body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.navbar ul {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex-direction: column;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.middle {
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.uzi {
|
||||
width: 70%;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.navbar-top-icons {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.navbar-top-icons img {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.navbar-top-icons {
|
||||
gap: 10px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.icon-item img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.icon-label {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
}
|
||||
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 30 KiB |
7
example.env
Normal file
@@ -0,0 +1,7 @@
|
||||
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'
|
||||
127
index.html
@@ -1,127 +0,0 @@
|
||||
<!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="https://purplebored.pl/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="http://[300:5506:25eb:d0d9::]" target="_blank" rel="noopener noreferrer">
|
||||
<img src="assets/images/buttons/midgard.png" alt="Midgard button" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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="http://[200:38ad:e575:f3c1:aba7:9e86:ae1e:4922]/" target="_blank" rel="noopener noreferrer">
|
||||
<img src="assets/images/icons/ygg.png" alt="Ygg Mirror">
|
||||
<span class="icon-label ygg-label">Ygg</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<ul>
|
||||
<li><a href="/index.html">Home</a></li>
|
||||
<li><a href="#">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="#">Services</a></li>
|
||||
<li><a href="#">Terms of Service</a></li>
|
||||
<li><a href="#">Other</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div class="clouds">
|
||||
<img src="assets/images/clouds.png" alt="Clouds">
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
Purplebored © 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>
|
||||
@@ -1,79 +0,0 @@
|
||||
<!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="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>
|
||||
<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="http://[200:38ad:e575:f3c1:aba7:9e86:ae1e:4922]/" target="_blank" rel="noopener noreferrer">
|
||||
<img src="/assets/images/icons/ygg.png" alt="Ygg Mirror">
|
||||
<span class="icon-label ygg-label">Ygg</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<ul>
|
||||
<li><a href="/index.html">Home</a></li>
|
||||
<li><a href="#">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="#">Services</a></li>
|
||||
<li><a href="#">Terms of Service</a></li>
|
||||
<li><a href="#">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 you’re 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 © 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>
|
||||
@@ -1,109 +0,0 @@
|
||||
<!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="Projects | Purplebored.pl">
|
||||
<meta property="og:description" content="Purplebored's projects :D">
|
||||
<meta property="og:image" content="https://purplebored.pl/assets/images/pfp.png">
|
||||
|
||||
<link rel="icon" href="/favicon.png" type="image/png">
|
||||
<link rel="stylesheet" href="/assets/css/style.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<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="http://[200:38ad:e575:f3c1:aba7:9e86:ae1e:4922]/" target="_blank" rel="noopener noreferrer">
|
||||
<img src="/assets/images/icons/ygg.png" alt="Ygg Mirror">
|
||||
<span class="icon-label ygg-label">Ygg</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<ul>
|
||||
<li><a href="/index.html">Home</a></li>
|
||||
<li><a href="#">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="#">Services</a></li>
|
||||
<li><a href="#">Terms of Service</a></li>
|
||||
<li><a href="#">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 sometimes called a selfbot 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 © 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>
|
||||
23
pyproject.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
[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"
|
||||
]
|
||||
9
requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
flask
|
||||
flask-csrf
|
||||
flask-limiter
|
||||
flask-migrate
|
||||
flask-sqlalchemy
|
||||
flask-wtf
|
||||
portalocker
|
||||
python-dotenv
|
||||
pymemcache
|
||||
11
robots.txt
@@ -1,11 +0,0 @@
|
||||
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
|
||||
@@ -1,5 +0,0 @@
|
||||
<?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/projects/irc-discord-bridge.html</loc><priority>0.7</priority></url>
|
||||
</urlset>
|
||||
26
src/app.py
Normal file
@@ -0,0 +1,26 @@
|
||||
#!/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())
|
||||
1
src/migrations/README
Normal file
@@ -0,0 +1 @@
|
||||
Single-database configuration for Flask.
|
||||
50
src/migrations/alembic.ini
Normal file
@@ -0,0 +1,50 @@
|
||||
# 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
|
||||
113
src/migrations/env.py
Normal file
@@ -0,0 +1,113 @@
|
||||
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()
|
||||
27
src/migrations/script.py.mako
Normal file
@@ -0,0 +1,27 @@
|
||||
"""${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"}
|
||||
37
src/migrations/versions/57fa0d90dc93_initial_migration.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""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 ###
|
||||
376
src/pbpl/__init__.py
Normal file
@@ -0,0 +1,376 @@
|
||||
#!/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
|
||||
18
src/pbpl/const.py
Normal file
@@ -0,0 +1,18 @@
|
||||
#!/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/")
|
||||
16
src/pbpl/db.py
Normal file
@@ -0,0 +1,16 @@
|
||||
#!/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()
|
||||
12
src/pbpl/forms/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Forms"""
|
||||
|
||||
import typing as t
|
||||
|
||||
from . import prow, text
|
||||
|
||||
__all__: t.Tuple[str, ...] = (
|
||||
"text",
|
||||
"prow",
|
||||
)
|
||||
64
src/pbpl/forms/prow.py
Normal file
@@ -0,0 +1,64 @@
|
||||
#!/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)
|
||||
)
|
||||
36
src/pbpl/forms/text.py
Normal file
@@ -0,0 +1,36 @@
|
||||
#!/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")
|
||||
15
src/pbpl/limiter.py
Normal file
@@ -0,0 +1,15 @@
|
||||
#!/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_",
|
||||
)
|
||||
9
src/pbpl/models/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Models"""
|
||||
|
||||
from typing import Tuple
|
||||
|
||||
from .text import TextModel
|
||||
|
||||
__all__: Tuple[str, ...] = ("TextModel",)
|
||||
37
src/pbpl/models/text.py
Normal file
@@ -0,0 +1,37 @@
|
||||
#!/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
|
||||
19
src/pbpl/views/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
#!/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
|
||||
261
src/pbpl/views/bp.py
Normal file
@@ -0,0 +1,261 @@
|
||||
#!/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
|
||||
97
src/pbpl/views/home.py
Normal file
@@ -0,0 +1,97 @@
|
||||
#!/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")
|
||||
116
src/static/css/base.css
Normal file
@@ -0,0 +1,116 @@
|
||||
: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;
|
||||
}
|
||||
|
||||
|
||||
3
src/static/css/error.css
Normal file
@@ -0,0 +1,3 @@
|
||||
article {
|
||||
text-align: center;
|
||||
}
|
||||
BIN
src/static/favicon.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 941 B After Width: | Height: | Size: 941 B |
|
Before Width: | Height: | Size: 999 B After Width: | Height: | Size: 999 B |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 552 B After Width: | Height: | Size: 552 B |
|
Before Width: | Height: | Size: 1001 B After Width: | Height: | Size: 1001 B |
|
Before Width: | Height: | Size: 975 B After Width: | Height: | Size: 975 B |
|
Before Width: | Height: | Size: 965 B After Width: | Height: | Size: 965 B |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 556 B After Width: | Height: | Size: 556 B |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 510 B After Width: | Height: | Size: 510 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 426 B After Width: | Height: | Size: 426 B |
@@ -1,26 +1,58 @@
|
||||
/**
|
||||
* @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) {
|
||||
@@ -34,50 +66,65 @@ 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 +
|
||||
@@ -87,6 +134,7 @@ 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,
|
||||
@@ -97,14 +145,17 @@ 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) {
|
||||
@@ -115,33 +166,41 @@ 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 +
|
||||
@@ -149,16 +208,21 @@ 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();
|
||||
});
|
||||
353
src/static/js/pow.js
Normal file
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* @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();
|
||||
});
|
||||
}
|
||||
19
src/static/js/wasm/.clang-format
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
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
|
||||
---
|
||||
|
||||
6
src/static/js/wasm/compile.sh
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/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
|
||||
203
src/static/js/wasm/pow.c
Normal file
@@ -0,0 +1,203 @@
|
||||
#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;
|
||||
}
|
||||
101
src/templates/base.j2
Normal file
@@ -0,0 +1,101 @@
|
||||
<!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>© {{ current_year }} {{ name | escape }} <<a href="mailto:{{ email | escape }}">{{ email | escape }}</a>>. 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>
|
||||
11
src/templates/error.j2
Normal file
@@ -0,0 +1,11 @@
|
||||
{% 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 %}
|
||||
83
src/templates/home/index.j2
Normal file
@@ -0,0 +1,83 @@
|
||||
{% 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 -%}
|
||||