Compare commits
4 commits
9d1de005d0
...
467f6a77ae
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
467f6a77ae | ||
|
|
41f36b0fd7 | ||
|
|
a481ae9526 | ||
|
|
ae5893c22f |
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -3,3 +3,6 @@
|
|||
build/
|
||||
**/*.egg-info
|
||||
**/__pycache__
|
||||
mounts/
|
||||
**/*.pem
|
||||
**/*.secret
|
||||
|
|
|
|||
11
README.md
Normal file
11
README.md
Normal 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
|
||||
```
|
||||
|
|
@ -13,3 +13,7 @@ services:
|
|||
- type: bind
|
||||
source: ./mounts/database
|
||||
target: /data/db
|
||||
|
||||
networks:
|
||||
fnb-website:
|
||||
internal: true
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
0
roc_fnb/scripts/__init__.py
Normal file
0
roc_fnb/scripts/__init__.py
Normal file
24
roc_fnb/scripts/bootstrap_first_admin.py
Normal file
24
roc_fnb/scripts/bootstrap_first_admin.py
Normal 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
1
roc_fnb/util/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from roc_fnb.util.logging import log
|
||||
30
roc_fnb/util/base64.py
Normal file
30
roc_fnb/util/base64.py
Normal 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
29
roc_fnb/util/env_file.py
Normal 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
30
roc_fnb/util/logging.py
Normal 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()
|
||||
11
roc_fnb/util/test_base64.py
Normal file
11
roc_fnb/util/test_base64.py
Normal 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
|
||||
71
roc_fnb/website/database.py
Normal file
71
roc_fnb/website/database.py
Normal 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
|
||||
0
roc_fnb/website/models/__init__.py
Normal file
0
roc_fnb/website/models/__init__.py
Normal file
50
roc_fnb/website/models/test_user.py
Normal file
50
roc_fnb/website/models/test_user.py
Normal 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)
|
||||
86
roc_fnb/website/models/user.py
Normal file
86
roc_fnb/website/models/user.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
27
roc_fnb/website/templates/base.html
Normal file
27
roc_fnb/website/templates/base.html
Normal 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>
|
||||
60
roc_fnb/website/templates/login.html
Normal file
60
roc_fnb/website/templates/login.html
Normal 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 %}
|
||||
7
roc_fnb/website/templates/profile.html
Normal file
7
roc_fnb/website/templates/profile.html
Normal 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 |
|
|
@ -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 |
Loading…
Reference in a new issue