Compare commits

...

4 commits

21 changed files with 528 additions and 236 deletions

3
.gitignore vendored
View file

@ -3,3 +3,6 @@
build/
**/*.egg-info
**/__pycache__
mounts/
**/*.pem
**/*.secret

11
README.md Normal file
View file

@ -0,0 +1,11 @@
# Development
## JWT Key pair generation
JWT generation uses an RSA public-private key pair. To generate these keys run:
```console
$ openssl genrsa -out private-key.pem 4096
$ openssl rsa -in private-key.pem -pubout > public-key.pem
```

View file

@ -13,3 +13,7 @@ services:
- type: bind
source: ./mounts/database
target: /data/db
networks:
fnb-website:
internal: true

View file

@ -9,8 +9,27 @@ description = "Temporary placeholder for fnb web site"
readme = "README.md"
requires-python = ">=3.11" # Self type added
license = "AGPL-3.0-only"
dependencies = ["flask", "gunicorn"]
dependencies = [
"flask",
"gunicorn",
"structlog",
"pymongo",
"scrypt",
"pyjwt",
# For tests
"pytest"
]
dynamic = ["version"]
[tool.setuptools]
# This is here because the top-level mounts directory gets interpreted by
# setuptools to be a module without it.
packages = ["roc_fnb"]
[project.scripts]
# Put scripts here
bootstrap-first-admin = "roc_fnb.scripts.bootstrap_first_admin:bootstrap_first_admin"
[tool.yapf]
based_on_style = "facebook"

View file

View file

@ -0,0 +1,24 @@
from click import command, option, prompt, confirm
from roc_fnb.website.database import Database
from roc_fnb.website.models.user import User
@command
@option('--name', '-n', type=str, required=True)
@option('--email', '-e', type=str, required=True)
def bootstrap_first_admin(name: str, email: str):
password = prompt('Enter the account password',
hide_input=True, prompt_suffix=': ')
confirmation = prompt('Confirm the account password',
hide_input=True, prompt_suffix=': ')
if password != confirmation:
raise ValueError('passwords did not match')
admin = User.create(email, name, password, moderator=True, admin=True)
db = Database.from_env()
db.store_user(admin)
if confirm('Display an auth token for testing?', default=False):
print(admin.jwt)
if __name__ == '__main__':
bootstrap_first_admin()

1
roc_fnb/util/__init__.py Normal file
View file

@ -0,0 +1 @@
from roc_fnb.util.logging import log

30
roc_fnb/util/base64.py Normal file
View file

@ -0,0 +1,30 @@
from base64 import b64encode
from binascii import a2b_base64
import binascii
def base64_decode(string: str|bytes) -> bytes:
"""
Python's base64.b64_decode requires padding to be correct. This does not.
What silly bullshit.
"""
encoded: bytes = string.encode() if isinstance(string, str) else string
# padc = 4 - len(encoded) % 4
# if padc == 4:
# padc = 0
# fixed = encoded + b'=' * padc
fixed = encoded + b'===='
try:
return a2b_base64(fixed, strict_mode=False)
except binascii.Error as e:
print(f'{string=!r}\n{len(string)=!r}\n{padc=!r}\n{fixed=!r}\n{len(fixed)=!r}')
raise e
def base64_encode(data: bytes) -> str:
"""
Return a base64 encoded string with no padding
Python's b64encode returns bytes. Why?
"""
return b64encode(data).decode('utf-8').rstrip('=')

29
roc_fnb/util/env_file.py Normal file
View file

@ -0,0 +1,29 @@
from os import environ
def env_file(key, default_file=KeyError, default=KeyError, default_fn=KeyError):
"""
Return a value from an environment variable or file specified by one.
Checks first for the value specified by key with "_FILE" appended. If that
is found, read from the file there. Otherwise return the value of the
environment variable, the contents of the specified default file, the default
value, or raises KeyError.
"""
if fp := environ.get(f'{key}_FILE'):
with open(fp) as file:
return file.read()
if var := environ.get(key):
return var
if default_file is not KeyError:
try:
with open(default_file) as file:
return file.read()
except FileNotFoundError:
... # fallthrough
if default is not KeyError:
return default
if default_fn is not KeyError:
return default_fn()
raise KeyError(f'no environment variable found ${key} nor {key}_FILE and default was not specified')

30
roc_fnb/util/logging.py Normal file
View file

@ -0,0 +1,30 @@
import logging
from os import environ
from structlog.processors import JSONRenderer, TimeStamper
from structlog.dev import ConsoleRenderer
import structlog
if not structlog.is_configured():
if (env := environ.get('ENV_MODE')) and env == 'production':
timestamper = TimeStamper(fmt="%Y-%m-%d %H:%M:%S", utc=True)
renderer: JSONRenderer | ConsoleRenderer = JSONRenderer()
else:
timestamper = TimeStamper(fmt="%Y-%m-%d %H:%M:%S", utc=False)
renderer = ConsoleRenderer()
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.StackInfoRenderer(),
structlog.dev.set_exc_info,
timestamper,
renderer,
],
wrapper_class=structlog.make_filtering_bound_logger(logging.NOTSET),
context_class=dict,
logger_factory=structlog.PrintLoggerFactory(),
cache_logger_on_first_use=False
)
log = structlog.get_logger()

View file

@ -0,0 +1,11 @@
from random import randbytes
from roc_fnb.util.base64 import *
def test_encode_and_decode():
for size in range(20-40):
data = randbytes(32)
string = base64_encode(data)
decoded = base64_decode(string)
assert data == decoded

View file

@ -0,0 +1,71 @@
from os import environ
from typing import Optional, Self
from pdb import set_trace as debugger
from bson.objectid import ObjectId
import pymongo
from pymongo import MongoClient
from roc_fnb.website.models.user import User
KEYLEN = 64
with open('private-key.pem') as pk:
PRIVATE_KEY = pk.read()
with open('public-key.pem') as pk:
PUBLIK_KEY = pk.read()
class Database(MongoClient):
@classmethod
def from_env(cls) -> Self:
return cls(
host=environ.get('DATABASE_HOST', default='localhost'),
port=int(environ.get('DATABASE_PORT', default=27017))
)
@property
def db(self) -> pymongo.database.Database:
if (env := environ.get('ENV_MODE')) and env == 'production':
return self.production
else:
return self.development
def store_user(self, user: User) -> User:
"""Store the given user in the database, set _id on it, and return it"""
result = self.db.users.insert_one(user.document)
user._id = result.inserted_id
return user
def get_user_by_email(self, email: str) -> Optional[User]:
"""
Return the user associated with the given email.
This does not imply authentiation
"""
if result := self.db.users.find_one({'email': email}):
return User(**result)
return None
def get_user_by_name(self, name: str) -> Optional[User]:
if result := self.db.users.find_one({'name': name}):
return User(**result)
return None
def delete_user(self, _id: ObjectId):
self.db.users.delete_one({'_id': _id})
def get_user_by_id(self, id: ObjectId) -> Optional[User]:
if user := self.db.users.find_one({'_id': id}):
return User(**user)
return None
def get_user_from_token(self, token: str) -> Optional[User]:
"""
Verify a user and retreive a their full profile from the database.
This is like User.verify_jwt except it also fetches fields from the
database which are not present in the client-visible token.
"""
if jwt_user := User.verify_jwt(token):
return self.get_user_by_id(jwt_user._id)
return None

View file

View file

@ -0,0 +1,50 @@
import json
from random import randbytes
from bson.objectid import ObjectId
from pytest import fixture
from roc_fnb.util.base64 import base64_decode
from roc_fnb.website.database import Database
from roc_fnb.website.models.user import User
@fixture
def user() -> User:
return User.create('test@t.co', 'name', 'monkey')
@fixture
def database() -> Database:
return Database.from_env()
def test_user_and_check_password(user):
assert user.name == 'name'
assert user.email == 'test@t.co'
assert user._id is None
assert user.check_password('monkey')
def test_store_and_retreive(user: User, database: Database):
try:
database.store_user(user)
assert user._id is not None
retreived = database.get_user_by_email(user.email)
assert retreived is not None
assert retreived._id == user._id
assert retreived == user
finally:
if id := user._id:
database.delete_user(id)
def test_store_and_retreive_by_id(user: User, database: Database):
try:
database.store_user(user)
assert user._id is not None
retreived = database.get_user_by_id(user._id)
assert retreived == user
finally:
if id := user._id:
database.delete_user(id)

View file

@ -0,0 +1,86 @@
from base64 import b64decode, b64encode
from dataclasses import dataclass
import json
from random import randbytes
from typing import Optional, Any, Self
from bson.objectid import ObjectId
import scrypt
import jwt
from roc_fnb.util.base64 import base64_encode, base64_decode
with open('private-key.pem') as file:
PRIVATE_KEY = file.read()
with open('public-key.pem') as file:
PUBLIC_KEY = file.read()
@dataclass
class JwtUser:
_id: ObjectId
email: str
name: str
moderator: bool
admin: bool
@classmethod
def from_json(cls, data: dict) -> Self:
_id = ObjectId(base64_decode(data.pop('_id')))
return cls(_id=_id, **data)
@dataclass
class User:
_id: Optional[ObjectId]
email: str
name: str
password_hash: bytes
salt: bytes
moderator: bool
admin: bool
@classmethod
def create(cls, email: str, name: str, password: str|bytes, moderator: bool = False, admin: bool = False):
"""Alternate constructor which hashes a given password"""
salt = randbytes(32)
password_hash = scrypt.hash(password, salt)
return cls(_id=None,
email=email,
name=name,
password_hash=password_hash,
salt=salt,
moderator=moderator,
admin=admin)
@property
def document(self):
doc = {
"email": self.email,
"name": self.name,
"password_hash": self.password_hash,
"salt": self.salt,
"moderator": self.moderator,
"admin": self.admin,
}
if self._id is not None:
doc['_id'] = self._id
return doc
@property
def public_fields(self):
"""
Session data is visible to client scripts.
This is a feature, not a bug; client scripts may need to gather login info.
"""
return {
'_id': base64_encode(self._id.binary),
"email": self.email,
"name": self.name,
"moderator": self.moderator,
"admin": self.admin,
}
def check_password(self, password: str) -> bool:
return self.password_hash == scrypt.hash(password, self.salt)

View file

@ -1,19 +1,81 @@
from flask import Flask, redirect, url_for, request, send_file
from functools import wraps
import json
from pathlib import Path
from random import randbytes
from sys import stderr
from flask import (Flask, redirect, url_for, request, send_file, make_response,
abort, render_template, session, g)
from roc_fnb.util.env_file import env_file
from roc_fnb.website.database import Database
from roc_fnb.website.models.user import JwtUser
db = Database.from_env()
app = Flask(
import_name=__name__.split('.')[0],
static_url_path='/'
static_url_path='/',
template_folder=Path(__file__).absolute().parent / 'templates',
static_folder=Path(__file__).absolute().parent / 'static',
)
app.secret_key = env_file('FLASK_SECRET', default_file='./flask.secret', default_fn=lambda: randbytes(12))
@app.before_request
def decode_user():
if user := session.get('user'):
g.user = JwtUser.from_json(data=json.loads(user))
def require_user(admin = False, moderator = False):
"""
A decorator for any routes which require authentication.
https://stackoverflow.com/a/51820573
"""
def _require_user(handler):
@wraps(handler)
def __require_user():
if getattr(g, 'user', None) is None \
or (admin and not user.admin) \
or (moderator and not user.moderator):
abort(401)
return handler()
return __require_user
return _require_user
@app.route('/ig')
def ig_redir():
return redirect('https://instagram.com/RocFNB')
@app.route('/donate')
def donate_redir():
return redirect('https://venmo.com/RocFoodNotBombs')
@app.route('/')
def index():
return redirect('/index.html')
@app.post('/login')
def submit_login():
form = request.json
user = db.get_user_by_name(form['name'])
if not user.check_password(form['password']):
abort(401) # unauthorized
session['user'] = json.dumps(user.public_fields)
return redirect('/me')
@app.get('/login')
def render_login_page():
if getattr(g, 'user', None):
return redirect('/me')
return render_template('login.html')
@app.get('/me')
@require_user()
def get_profile():
return render_template('profile.html', user=g.user)

View file

@ -0,0 +1,27 @@
<html>
<head>
<link rel="stylesheet" href="style.css">
<title>
Rochester Food Not Bombs
</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Teko:wght@300..700&display=swap" rel="stylesheet">
<script>
function start_animation() {
var element = document.getElementById("biglogo")
element.style.animation = 'none';
element.offsetHeight;
element.style.animation = null;
}
</script>
</head>
<body>
{% block content %}
{% endblock %}
</body>
</html>

View file

@ -0,0 +1,60 @@
{% extends "base.html" %} {% block content %}
<script>
document.addEventListener("readystatechange", () => {
if (document.readyState === "complete") {
/**
* @type {HTMLInputElement}
*/
const nameInput = document.getElementById("input-name");
/**
* @type {HTMLInputElement}
*/
const passwordInput = document.getElementById("input-password");
/**
* @type {HTMLButtonElement}
*/
const button = document.getElementById("submit-button");
button.addEventListener("click", async (event) => {
const name = nameInput.value;
const password = passwordInput.value;
const result = await fetch("/login", {
method: "POST",
body: JSON.stringify({ name, password }),
headers: {'Content-Type': 'application/json'}
});
if (result.ok) {
window.location = '/me'
} else {
console.dir(result)
// TODO handle error!
}
});
}
});
</script>
<img
id="biglogo"
class="spinny"
onclick="start_animation()"
src="logo.png"
alt="logo"
/>
<h1>Rochester Food Not Bombs!</h1>
<h3>Login:</h3>
<div>
<label for="name">Your name</label>
<input type="text" id="input-name" />
</div>
<div>
<label for="password">Your password</label>
<input type="password" id="input-password" />
</div>
<div>
<button id="submit-button" type="submit">Log in</button>
</div>
{% endblock %}

View file

@ -0,0 +1,7 @@
{% extends "base.html" %}
{% block content %}
<img id="biglogo" class="spinny" onclick="start_animation()" src="logo.png" alt="logo">
<h1>Rochester Food Not Bombs!</h1>
<p>This will be the profile/settings page for {{user.name}}</p>
{% endblock %}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 316 B

View file

@ -1,233 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<!-- Created with qrencode 4.1.1 (https://fukuchi.org/works/qrencode/index.html) -->
<svg width="3.07cm" height="3.07cm" viewBox="0 0 29 29" preserveAspectRatio="none" version="1.1" xmlns="http://www.w3.org/2000/svg">
<g id="QRcode">
<rect x="0" y="0" width="29" height="29" fill="#ffffff"/>
<g id="Pattern" transform="translate(4,4)">
<rect x="0" y="0" width="1" height="1" fill="#000000"/>
<rect x="1" y="0" width="1" height="1" fill="#000000"/>
<rect x="2" y="0" width="1" height="1" fill="#000000"/>
<rect x="3" y="0" width="1" height="1" fill="#000000"/>
<rect x="4" y="0" width="1" height="1" fill="#000000"/>
<rect x="5" y="0" width="1" height="1" fill="#000000"/>
<rect x="6" y="0" width="1" height="1" fill="#000000"/>
<rect x="11" y="0" width="1" height="1" fill="#000000"/>
<rect x="14" y="0" width="1" height="1" fill="#000000"/>
<rect x="15" y="0" width="1" height="1" fill="#000000"/>
<rect x="16" y="0" width="1" height="1" fill="#000000"/>
<rect x="17" y="0" width="1" height="1" fill="#000000"/>
<rect x="18" y="0" width="1" height="1" fill="#000000"/>
<rect x="19" y="0" width="1" height="1" fill="#000000"/>
<rect x="20" y="0" width="1" height="1" fill="#000000"/>
<rect x="0" y="1" width="1" height="1" fill="#000000"/>
<rect x="6" y="1" width="1" height="1" fill="#000000"/>
<rect x="8" y="1" width="1" height="1" fill="#000000"/>
<rect x="9" y="1" width="1" height="1" fill="#000000"/>
<rect x="11" y="1" width="1" height="1" fill="#000000"/>
<rect x="12" y="1" width="1" height="1" fill="#000000"/>
<rect x="14" y="1" width="1" height="1" fill="#000000"/>
<rect x="20" y="1" width="1" height="1" fill="#000000"/>
<rect x="0" y="2" width="1" height="1" fill="#000000"/>
<rect x="2" y="2" width="1" height="1" fill="#000000"/>
<rect x="3" y="2" width="1" height="1" fill="#000000"/>
<rect x="4" y="2" width="1" height="1" fill="#000000"/>
<rect x="6" y="2" width="1" height="1" fill="#000000"/>
<rect x="11" y="2" width="1" height="1" fill="#000000"/>
<rect x="12" y="2" width="1" height="1" fill="#000000"/>
<rect x="14" y="2" width="1" height="1" fill="#000000"/>
<rect x="16" y="2" width="1" height="1" fill="#000000"/>
<rect x="17" y="2" width="1" height="1" fill="#000000"/>
<rect x="18" y="2" width="1" height="1" fill="#000000"/>
<rect x="20" y="2" width="1" height="1" fill="#000000"/>
<rect x="0" y="3" width="1" height="1" fill="#000000"/>
<rect x="2" y="3" width="1" height="1" fill="#000000"/>
<rect x="3" y="3" width="1" height="1" fill="#000000"/>
<rect x="4" y="3" width="1" height="1" fill="#000000"/>
<rect x="6" y="3" width="1" height="1" fill="#000000"/>
<rect x="8" y="3" width="1" height="1" fill="#000000"/>
<rect x="9" y="3" width="1" height="1" fill="#000000"/>
<rect x="10" y="3" width="1" height="1" fill="#000000"/>
<rect x="12" y="3" width="1" height="1" fill="#000000"/>
<rect x="14" y="3" width="1" height="1" fill="#000000"/>
<rect x="16" y="3" width="1" height="1" fill="#000000"/>
<rect x="17" y="3" width="1" height="1" fill="#000000"/>
<rect x="18" y="3" width="1" height="1" fill="#000000"/>
<rect x="20" y="3" width="1" height="1" fill="#000000"/>
<rect x="0" y="4" width="1" height="1" fill="#000000"/>
<rect x="2" y="4" width="1" height="1" fill="#000000"/>
<rect x="3" y="4" width="1" height="1" fill="#000000"/>
<rect x="4" y="4" width="1" height="1" fill="#000000"/>
<rect x="6" y="4" width="1" height="1" fill="#000000"/>
<rect x="14" y="4" width="1" height="1" fill="#000000"/>
<rect x="16" y="4" width="1" height="1" fill="#000000"/>
<rect x="17" y="4" width="1" height="1" fill="#000000"/>
<rect x="18" y="4" width="1" height="1" fill="#000000"/>
<rect x="20" y="4" width="1" height="1" fill="#000000"/>
<rect x="0" y="5" width="1" height="1" fill="#000000"/>
<rect x="6" y="5" width="1" height="1" fill="#000000"/>
<rect x="8" y="5" width="1" height="1" fill="#000000"/>
<rect x="11" y="5" width="1" height="1" fill="#000000"/>
<rect x="12" y="5" width="1" height="1" fill="#000000"/>
<rect x="14" y="5" width="1" height="1" fill="#000000"/>
<rect x="20" y="5" width="1" height="1" fill="#000000"/>
<rect x="0" y="6" width="1" height="1" fill="#000000"/>
<rect x="1" y="6" width="1" height="1" fill="#000000"/>
<rect x="2" y="6" width="1" height="1" fill="#000000"/>
<rect x="3" y="6" width="1" height="1" fill="#000000"/>
<rect x="4" y="6" width="1" height="1" fill="#000000"/>
<rect x="5" y="6" width="1" height="1" fill="#000000"/>
<rect x="6" y="6" width="1" height="1" fill="#000000"/>
<rect x="8" y="6" width="1" height="1" fill="#000000"/>
<rect x="10" y="6" width="1" height="1" fill="#000000"/>
<rect x="12" y="6" width="1" height="1" fill="#000000"/>
<rect x="14" y="6" width="1" height="1" fill="#000000"/>
<rect x="15" y="6" width="1" height="1" fill="#000000"/>
<rect x="16" y="6" width="1" height="1" fill="#000000"/>
<rect x="17" y="6" width="1" height="1" fill="#000000"/>
<rect x="18" y="6" width="1" height="1" fill="#000000"/>
<rect x="19" y="6" width="1" height="1" fill="#000000"/>
<rect x="20" y="6" width="1" height="1" fill="#000000"/>
<rect x="9" y="7" width="1" height="1" fill="#000000"/>
<rect x="0" y="8" width="1" height="1" fill="#000000"/>
<rect x="1" y="8" width="1" height="1" fill="#000000"/>
<rect x="2" y="8" width="1" height="1" fill="#000000"/>
<rect x="3" y="8" width="1" height="1" fill="#000000"/>
<rect x="4" y="8" width="1" height="1" fill="#000000"/>
<rect x="6" y="8" width="1" height="1" fill="#000000"/>
<rect x="7" y="8" width="1" height="1" fill="#000000"/>
<rect x="8" y="8" width="1" height="1" fill="#000000"/>
<rect x="9" y="8" width="1" height="1" fill="#000000"/>
<rect x="11" y="8" width="1" height="1" fill="#000000"/>
<rect x="13" y="8" width="1" height="1" fill="#000000"/>
<rect x="15" y="8" width="1" height="1" fill="#000000"/>
<rect x="17" y="8" width="1" height="1" fill="#000000"/>
<rect x="19" y="8" width="1" height="1" fill="#000000"/>
<rect x="0" y="9" width="1" height="1" fill="#000000"/>
<rect x="1" y="9" width="1" height="1" fill="#000000"/>
<rect x="7" y="9" width="1" height="1" fill="#000000"/>
<rect x="10" y="9" width="1" height="1" fill="#000000"/>
<rect x="13" y="9" width="1" height="1" fill="#000000"/>
<rect x="14" y="9" width="1" height="1" fill="#000000"/>
<rect x="15" y="9" width="1" height="1" fill="#000000"/>
<rect x="16" y="9" width="1" height="1" fill="#000000"/>
<rect x="17" y="9" width="1" height="1" fill="#000000"/>
<rect x="20" y="9" width="1" height="1" fill="#000000"/>
<rect x="1" y="10" width="1" height="1" fill="#000000"/>
<rect x="3" y="10" width="1" height="1" fill="#000000"/>
<rect x="5" y="10" width="1" height="1" fill="#000000"/>
<rect x="6" y="10" width="1" height="1" fill="#000000"/>
<rect x="9" y="10" width="1" height="1" fill="#000000"/>
<rect x="11" y="10" width="1" height="1" fill="#000000"/>
<rect x="12" y="10" width="1" height="1" fill="#000000"/>
<rect x="13" y="10" width="1" height="1" fill="#000000"/>
<rect x="14" y="10" width="1" height="1" fill="#000000"/>
<rect x="17" y="10" width="1" height="1" fill="#000000"/>
<rect x="19" y="10" width="1" height="1" fill="#000000"/>
<rect x="2" y="11" width="1" height="1" fill="#000000"/>
<rect x="3" y="11" width="1" height="1" fill="#000000"/>
<rect x="5" y="11" width="1" height="1" fill="#000000"/>
<rect x="10" y="11" width="1" height="1" fill="#000000"/>
<rect x="11" y="11" width="1" height="1" fill="#000000"/>
<rect x="13" y="11" width="1" height="1" fill="#000000"/>
<rect x="15" y="11" width="1" height="1" fill="#000000"/>
<rect x="16" y="11" width="1" height="1" fill="#000000"/>
<rect x="17" y="11" width="1" height="1" fill="#000000"/>
<rect x="18" y="11" width="1" height="1" fill="#000000"/>
<rect x="20" y="11" width="1" height="1" fill="#000000"/>
<rect x="0" y="12" width="1" height="1" fill="#000000"/>
<rect x="1" y="12" width="1" height="1" fill="#000000"/>
<rect x="6" y="12" width="1" height="1" fill="#000000"/>
<rect x="9" y="12" width="1" height="1" fill="#000000"/>
<rect x="11" y="12" width="1" height="1" fill="#000000"/>
<rect x="13" y="12" width="1" height="1" fill="#000000"/>
<rect x="14" y="12" width="1" height="1" fill="#000000"/>
<rect x="16" y="12" width="1" height="1" fill="#000000"/>
<rect x="8" y="13" width="1" height="1" fill="#000000"/>
<rect x="15" y="13" width="1" height="1" fill="#000000"/>
<rect x="16" y="13" width="1" height="1" fill="#000000"/>
<rect x="18" y="13" width="1" height="1" fill="#000000"/>
<rect x="19" y="13" width="1" height="1" fill="#000000"/>
<rect x="20" y="13" width="1" height="1" fill="#000000"/>
<rect x="0" y="14" width="1" height="1" fill="#000000"/>
<rect x="1" y="14" width="1" height="1" fill="#000000"/>
<rect x="2" y="14" width="1" height="1" fill="#000000"/>
<rect x="3" y="14" width="1" height="1" fill="#000000"/>
<rect x="4" y="14" width="1" height="1" fill="#000000"/>
<rect x="5" y="14" width="1" height="1" fill="#000000"/>
<rect x="6" y="14" width="1" height="1" fill="#000000"/>
<rect x="8" y="14" width="1" height="1" fill="#000000"/>
<rect x="11" y="14" width="1" height="1" fill="#000000"/>
<rect x="12" y="14" width="1" height="1" fill="#000000"/>
<rect x="14" y="14" width="1" height="1" fill="#000000"/>
<rect x="15" y="14" width="1" height="1" fill="#000000"/>
<rect x="17" y="14" width="1" height="1" fill="#000000"/>
<rect x="19" y="14" width="1" height="1" fill="#000000"/>
<rect x="0" y="15" width="1" height="1" fill="#000000"/>
<rect x="6" y="15" width="1" height="1" fill="#000000"/>
<rect x="12" y="15" width="1" height="1" fill="#000000"/>
<rect x="13" y="15" width="1" height="1" fill="#000000"/>
<rect x="15" y="15" width="1" height="1" fill="#000000"/>
<rect x="16" y="15" width="1" height="1" fill="#000000"/>
<rect x="17" y="15" width="1" height="1" fill="#000000"/>
<rect x="18" y="15" width="1" height="1" fill="#000000"/>
<rect x="19" y="15" width="1" height="1" fill="#000000"/>
<rect x="0" y="16" width="1" height="1" fill="#000000"/>
<rect x="2" y="16" width="1" height="1" fill="#000000"/>
<rect x="3" y="16" width="1" height="1" fill="#000000"/>
<rect x="4" y="16" width="1" height="1" fill="#000000"/>
<rect x="6" y="16" width="1" height="1" fill="#000000"/>
<rect x="8" y="16" width="1" height="1" fill="#000000"/>
<rect x="9" y="16" width="1" height="1" fill="#000000"/>
<rect x="10" y="16" width="1" height="1" fill="#000000"/>
<rect x="11" y="16" width="1" height="1" fill="#000000"/>
<rect x="12" y="16" width="1" height="1" fill="#000000"/>
<rect x="14" y="16" width="1" height="1" fill="#000000"/>
<rect x="15" y="16" width="1" height="1" fill="#000000"/>
<rect x="0" y="17" width="1" height="1" fill="#000000"/>
<rect x="2" y="17" width="1" height="1" fill="#000000"/>
<rect x="3" y="17" width="1" height="1" fill="#000000"/>
<rect x="4" y="17" width="1" height="1" fill="#000000"/>
<rect x="6" y="17" width="1" height="1" fill="#000000"/>
<rect x="8" y="17" width="1" height="1" fill="#000000"/>
<rect x="13" y="17" width="1" height="1" fill="#000000"/>
<rect x="15" y="17" width="1" height="1" fill="#000000"/>
<rect x="16" y="17" width="1" height="1" fill="#000000"/>
<rect x="17" y="17" width="1" height="1" fill="#000000"/>
<rect x="18" y="17" width="1" height="1" fill="#000000"/>
<rect x="19" y="17" width="1" height="1" fill="#000000"/>
<rect x="0" y="18" width="1" height="1" fill="#000000"/>
<rect x="2" y="18" width="1" height="1" fill="#000000"/>
<rect x="3" y="18" width="1" height="1" fill="#000000"/>
<rect x="4" y="18" width="1" height="1" fill="#000000"/>
<rect x="6" y="18" width="1" height="1" fill="#000000"/>
<rect x="8" y="18" width="1" height="1" fill="#000000"/>
<rect x="9" y="18" width="1" height="1" fill="#000000"/>
<rect x="10" y="18" width="1" height="1" fill="#000000"/>
<rect x="11" y="18" width="1" height="1" fill="#000000"/>
<rect x="12" y="18" width="1" height="1" fill="#000000"/>
<rect x="13" y="18" width="1" height="1" fill="#000000"/>
<rect x="14" y="18" width="1" height="1" fill="#000000"/>
<rect x="0" y="19" width="1" height="1" fill="#000000"/>
<rect x="6" y="19" width="1" height="1" fill="#000000"/>
<rect x="8" y="19" width="1" height="1" fill="#000000"/>
<rect x="9" y="19" width="1" height="1" fill="#000000"/>
<rect x="11" y="19" width="1" height="1" fill="#000000"/>
<rect x="15" y="19" width="1" height="1" fill="#000000"/>
<rect x="16" y="19" width="1" height="1" fill="#000000"/>
<rect x="17" y="19" width="1" height="1" fill="#000000"/>
<rect x="18" y="19" width="1" height="1" fill="#000000"/>
<rect x="0" y="20" width="1" height="1" fill="#000000"/>
<rect x="1" y="20" width="1" height="1" fill="#000000"/>
<rect x="2" y="20" width="1" height="1" fill="#000000"/>
<rect x="3" y="20" width="1" height="1" fill="#000000"/>
<rect x="4" y="20" width="1" height="1" fill="#000000"/>
<rect x="5" y="20" width="1" height="1" fill="#000000"/>
<rect x="6" y="20" width="1" height="1" fill="#000000"/>
<rect x="8" y="20" width="1" height="1" fill="#000000"/>
<rect x="11" y="20" width="1" height="1" fill="#000000"/>
<rect x="14" y="20" width="1" height="1" fill="#000000"/>
<rect x="16" y="20" width="1" height="1" fill="#000000"/>
<rect x="19" y="20" width="1" height="1" fill="#000000"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 14 KiB