add user database code

This commit is contained in:
D. Scott Boggs 2025-05-28 10:16:13 -04:00
parent ae5893c22f
commit a481ae9526
12 changed files with 315 additions and 233 deletions

2
.gitignore vendored
View file

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

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 - type: bind
source: ./mounts/database source: ./mounts/database
target: /data/db target: /data/db
networks:
fnb-website:
internal: true

View file

@ -13,9 +13,20 @@ dependencies = [
"flask", "flask",
"gunicorn", "gunicorn",
"structlog", "structlog",
"pymongo",
"scrypt",
"pyjwt",
# For tests
"pytest"
] ]
dynamic = ["version"] 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] [project.scripts]
# Put scripts here # Put scripts here

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('=')

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,85 @@
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_jwt(user):
user._id = (_id := ObjectId(randbytes(12)))
token = user.jwt
header, payload, sig = (base64_decode(part.replace('.', ''))
for part in token.split('.'))
header = json.loads(header)
payload = json.loads(payload)
assert header['alg'] == 'RS256'
assert header['typ'] == 'JWT'
assert set(header.keys()) == {'alg', 'typ'}
# Note that JWT contents are visible to the user: this can be useful but
# must be done with caution
assert payload['email'] == user.email
assert payload['name'] == user.name
assert ObjectId(base64_decode(payload['_id'])) == user._id == _id
assert set(payload.keys()) == {'email', 'name', '_id', 'admin', 'moderator'}
result = user.verify_jwt(token)
assert result.email == user.email
assert result.name == user.name
assert result._id == user._id == _id
assert not result.admin
assert not result.moderator
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)
def test_store_and_retreive_by_jwt(user: User, database: Database):
try:
token = database.store_user(user).jwt
assert user._id is not None
retreived = database.get_user_from_token(token)
assert retreived == user
finally:
if id := user._id:
database.delete_user(id)

View file

@ -0,0 +1,90 @@
from base64 import b64decode, b64encode
from dataclasses import dataclass
import json
from random import randbytes
from typing import Optional, Any
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
@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):
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)
@property
def jwt(self) -> str:
return jwt.encode(self.public_fields, PRIVATE_KEY, algorithm='RS256')
@staticmethod
def verify_jwt(token: str) -> JwtUser:
verified = jwt.decode(token, PUBLIC_KEY, verify=True, algorithms=['RS256'])
return JwtUser(
_id=ObjectId(base64_decode(verified['_id'])),
name=verified['name'],
email=verified['email'],
moderator=verified['moderator'],
admin=verified['admin'],
)

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