From cdd34dbaa0e07d1e4ba95b73753b029132ab01f2 Mon Sep 17 00:00:00 2001 From: Dirk Loeckx <> Date: Fri, 12 Mar 2021 00:38:54 +0100 Subject: [PATCH 01/73] Changed platform and code tag in binary --- README.md | 8 +++----- server.py | 55 +++++++++++++++++++++++++++---------------------------- 2 files changed, 30 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 95e1914..f57d759 100644 --- a/README.md +++ b/README.md @@ -68,12 +68,12 @@ Below if an implementation for _ESP32_ that works with the server. Remember to c #define VERSION "v1.0.2" #define HOST "Chase" -const char* urlBase = "http://192.168.0.10:5000/update"; +const char* urlBase = "http://192.168.0.10:5000/"; /***************************************************/ void checkForUpdates(void) { - String checkUrl = String( urlBase); + String checkUrl = String( ota_update_server) + String("update?ver=" VERSION "&dev=" HOST); checkUrl.concat( "?ver=" + String(VERSION) ); checkUrl.concat( "&dev=" + String(HOST) ); @@ -114,9 +114,7 @@ const char* urlBase = "http://192.168.0.10:5000/update"; /***************************************************/ void checkForUpdates(void) { - String checkUrl = String( urlBase); - checkUrl.concat( "?ver=" + String(VERSION) ); - checkUrl.concat( "&dev=" + String(HOST) ); + String checkUrl = String( ota_update_server) + String("update?ver=" VERSION "&dev=" HOST "); Serial.println("INFO: Checking for updates at URL: " + String( checkUrl ) ); diff --git a/server.py b/server.py index 7935449..0e95c38 100644 --- a/server.py +++ b/server.py @@ -127,37 +127,36 @@ def upload(): if file and allowed_ext(file.filename): data = file.read() for __dev in platforms.keys(): - if re.search(__dev.encode('UTF-8'), data, re.IGNORECASE): - m = re.search(b'v\d+\.\d+\.\d+', data) - if m: - __ver = m.group()[1:].decode('utf-8') - if (platforms[__dev]['version'] is None) or (platforms[__dev]['version'] and version.parse(platforms[__dev]['version']) < version.parse(__ver)): - old_file = platforms[__dev]['file'] - filename = __dev + '_' + __ver.replace('.', '_') + '.bin' - platforms[__dev]['version'] = __ver - platforms[__dev]['downloads'] = 0 - platforms[__dev]['file'] = filename - platforms[__dev]['uploaded'] = datetime.now().strftime('%Y-%m-%d') - file.seek(0) - file.save(os.path.join(app.config['UPLOAD_FOLDER'], filename)) - file.close() - if save_yaml(platforms): - # Only delete old file after YAML file is updated. - if old_file: - try: - os.remove(os.path.join(app.config['UPLOAD_FOLDER'], old_file)) - except: - flash('Error: Removing old file failed.') - flash('Success: File uploaded.') - else: - flash('Error: Could not save file.') - return redirect(url_for('index')) + m = re.search(b"update\?dev=" + __dev.encode('UTF-8') + b"&ver=(v\d+\.\d+\.\d+)\x00", data, re.IGNORECASE) + if m: + __ver = m.groups()[0][1:].decode('utf-8') + if (platforms[__dev]['version'] is None) or (platforms[__dev]['version'] and version.parse(platforms[__dev]['version']) < version.parse(__ver)): + old_file = platforms[__dev]['file'] + filename = __dev + '_' + __ver.replace('.', '_') + '.bin' + platforms[__dev]['version'] = __ver + platforms[__dev]['downloads'] = 0 + platforms[__dev]['file'] = filename + platforms[__dev]['uploaded'] = datetime.now().strftime('%Y-%m-%d') + file.seek(0) + file.save(os.path.join(app.config['UPLOAD_FOLDER'], filename)) + file.close() + if save_yaml(platforms): + # Only delete old file after YAML file is updated. + if old_file: + try: + os.remove(os.path.join(app.config['UPLOAD_FOLDER'], old_file)) + except: + flash('Error: Removing old file failed.') + flash('Success: File uploaded.') else: - flash('Error: Version must increase. File not uploaded.') - return redirect(request.url) + flash('Error: Could not save file.') + return redirect(url_for('index')) else: - flash('Error: No version found in file. File not uploaded.') + flash('Error: Version must increase. File not uploaded.') return redirect(request.url) + else: + flash('Error: No version found in file. File not uploaded.') + return redirect(request.url) flash('Error: No known platform name found in file. File not uploaded.') return redirect(request.url) else: From cd8ee980e5ff7f6541495df6539aace39c8be97f Mon Sep 17 00:00:00 2001 From: Dirk Loeckx <> Date: Fri, 12 Mar 2021 00:41:17 +0100 Subject: [PATCH 02/73] Changed platform and code tag (fix readme) --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index f57d759..038e5ad 100644 --- a/README.md +++ b/README.md @@ -74,8 +74,6 @@ const char* urlBase = "http://192.168.0.10:5000/"; void checkForUpdates(void) { String checkUrl = String( ota_update_server) + String("update?ver=" VERSION "&dev=" HOST); - checkUrl.concat( "?ver=" + String(VERSION) ); - checkUrl.concat( "&dev=" + String(HOST) ); Serial.println("INFO: Checking for updates at URL: " + String( checkUrl ) ); From 0e14b149f5b5b20cd9978cd6e39c80dcb254df4f Mon Sep 17 00:00:00 2001 From: Marco van Noord Date: Sun, 14 Mar 2021 16:46:51 +0100 Subject: [PATCH 03/73] Don't crash if no version has been uploaded. --- server.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server.py b/server.py index 0e95c38..6e5d362 100644 --- a/server.py +++ b/server.py @@ -90,6 +90,9 @@ def update(): if platforms: if __dev in platforms.keys(): if __mac in platforms[__dev]['whitelist']: + if not platforms[__dev]['version']: + log_event("ERROR: No update available.") + return 'No update available.', 400 if version.parse(__ver) < version.parse(platforms[__dev]['version']): if os.path.isfile(app.config['UPLOAD_FOLDER'] + '/' + platforms[__dev]['file']): platforms[__dev]['downloads'] += 1 From d8b5549fc5c1ec7ec930d131cd8e705a206795b9 Mon Sep 17 00:00:00 2001 From: Marco van Noord Date: Sun, 14 Mar 2021 16:49:50 +0100 Subject: [PATCH 04/73] Keep track of ESP devices seen before, to make it easier to add them to the backend. Also it helps tracking on when they have been checking for updates. --- bin/macs.yml | 1 + server.py | 52 ++++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 bin/macs.yml diff --git a/bin/macs.yml b/bin/macs.yml new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/bin/macs.yml @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/server.py b/server.py index 6e5d362..f6ca2de 100644 --- a/server.py +++ b/server.py @@ -1,10 +1,12 @@ -from datetime import datetime -from flask import Flask, request, render_template, flash, redirect, url_for, send_from_directory -from packaging import version +import os import re import time -import os +from datetime import datetime + import yaml +from flask import (Flask, flash, redirect, render_template, request, + send_from_directory, url_for) +from packaging import version __author__ = 'Kristian Stobbe' __copyright__ = 'Copyright 2019, K. Stobbe' @@ -20,6 +22,7 @@ app.config['UPLOAD_FOLDER'] = './bin' app.config['SECRET_KEY'] = 'Kri57i4n570bb33r3nF1ink3rFyr' PLATFORMS_YAML = app.config['UPLOAD_FOLDER'] + '/platforms.yml' +MACS_YAML = app.config['UPLOAD_FOLDER'] + '/macs.yml' def log_event(msg): @@ -60,6 +63,37 @@ def save_yaml(platforms): return False +def load_known_mac_yaml(): + macs = None + try: + with open(MACS_YAML, 'r') as stream: + try: + macs = yaml.load(stream, Loader=yaml.FullLoader) + except yaml.YAMLError as err: + flash(err) + except: + flash('Error: File not found.') + if macs: + for known_mac in macs.values(): + if known_mac['first_seen']: + known_mac['first_seen'] = str(known_mac['first_seen']) + if known_mac['last_seen']: + known_mac['last_seen'] = str(known_mac['last_seen']) + if not macs: + macs = dict() + return macs + + +def save_known_mac_yaml(macs): + try: + with open(MACS_YAML, 'w') as outfile: + yaml.dump(macs, outfile, default_flow_style=False) + return True + except: + flash('Error: Known MAC data not saved.') + return False + + @app.context_processor def utility_processor(): def format_mac(mac): @@ -71,6 +105,7 @@ def format_mac(mac): def update(): __error = 400 platforms = load_yaml() + known_macs = load_known_mac_yaml() __dev = request.args.get('dev', default=None) if 'X_ESP8266_STA_MAC' in request.headers: __mac = request.headers['X_ESP8266_STA_MAC'] @@ -85,6 +120,15 @@ def update(): log_event("WARN: Update called without known headers.") __ver = request.args.get('ver', default=None) if __dev and __mac and __ver: + # If we know this device already + if __mac in known_macs.keys(): + known_macs[__mac]['last_seen'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + else: + known_macs[__mac] = {'first_seen': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'last_seen': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'IP': None, + 'type': None} + save_known_mac_yaml(known_macs) log_event("INFO: Dev: " + __dev + "Ver: " + __ver) __dev = __dev.lower() if platforms: From 27999314ca787bb431b2e295a96b99eb2afde5bd Mon Sep 17 00:00:00 2001 From: Marco van Noord Date: Sun, 14 Mar 2021 16:50:41 +0100 Subject: [PATCH 05/73] added gitignore --- .gitignore | 197 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c61d0bf --- /dev/null +++ b/.gitignore @@ -0,0 +1,197 @@ +# File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig + +# Created by https://www.toptal.com/developers/gitignore/api/windows,visualstudiocode,flask +# Edit at https://www.toptal.com/developers/gitignore?templates=windows,visualstudiocode,flask + +### Flask ### +instance/* +!instance/.gitignore +.webassets-cache + +### Flask.Python Stack ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +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/ +pytestdebug.log + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ +doc/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.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 +#poetry.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +# .env +.env/ +.venv/ +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +pythonenv* + +# 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/ + +# operating system-related files +*.DS_Store #file properties cache/storage on macOS +Thumbs.db #thumbnail cache on Windows + +# profiling data +.prof + + +### VisualStudioCode ### +.vscode/* +!.vscode/tasks.json +!.vscode/launch.json +*.code-workspace + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/windows,visualstudiocode,flask + +# Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) + From 15d5a387299a44c437b4c53b7be44d9974508e74 Mon Sep 17 00:00:00 2001 From: Marco van Noord Date: Sun, 14 Mar 2021 17:18:42 +0100 Subject: [PATCH 06/73] Update readme to reflect changes in the new way of parsing. Renamed "HOST" on the ESP to "DEVICE_PLATFORM" to keep it consistent with the server. --- README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 038e5ad..4624e5a 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ In a web browser, when the server is running, enter the IP address of the machin Devices requesting download of a binary file for upgrade must access path `update` and include _device name_ and current _version number_ in a query like below - substitute the IP address with your own. ``` -http://192.168.0.10:5000/update?ver=v1.0.2&dev=chase +http://192.168.0.10:5000/update?dev=chase&ver=v1.0.2 ``` The server will respond with _HTTP Error Code_: @@ -65,15 +65,15 @@ Below if an implementation for _ESP32_ that works with the server. Remember to c #include #include -#define VERSION "v1.0.2" -#define HOST "Chase" +#define VERSION "v1.0.2 +#define DEVICE_PLATFORM "Chase" -const char* urlBase = "http://192.168.0.10:5000/"; +const char* ota_update_server = "http://192.168.0.10:5000/"; /***************************************************/ void checkForUpdates(void) { - String checkUrl = String( ota_update_server) + String("update?ver=" VERSION "&dev=" HOST); + String checkUrl = String( ota_update_server) + String("update?dev=" DEVICE_PLATFORM "&ver=" VERSION ); Serial.println("INFO: Checking for updates at URL: " + String( checkUrl ) ); @@ -104,15 +104,15 @@ For _ESP8266_ the implementation is very similar with a few changes. Remember to #include #include -#define VERSION "v1.0.2" -#define HOST "Chase" +#define VERSION "v1.0.2 +#define DEVICE_PLATFORM "Chase" -const char* urlBase = "http://192.168.0.10:5000/update"; +const char* ota_update_server = "http://192.168.0.10:5000/"; /***************************************************/ void checkForUpdates(void) { - String checkUrl = String( ota_update_server) + String("update?ver=" VERSION "&dev=" HOST "); + String checkUrl = String( ota_update_server) + String("update?dev=" DEVICE_PLATFORM "&ver=" VERSION ); Serial.println("INFO: Checking for updates at URL: " + String( checkUrl ) ); From 3f57c56fa1d30259900c843b1fc6349ed7152894 Mon Sep 17 00:00:00 2001 From: Marco Date: Sun, 14 Mar 2021 17:20:16 +0100 Subject: [PATCH 07/73] Update README.md Update readme to reflect changes in the new way of parsing. Renamed "HOST" on the ESP to "DEVICE_PLATFORM" to keep it consistent with the server. --- README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 038e5ad..4624e5a 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ In a web browser, when the server is running, enter the IP address of the machin Devices requesting download of a binary file for upgrade must access path `update` and include _device name_ and current _version number_ in a query like below - substitute the IP address with your own. ``` -http://192.168.0.10:5000/update?ver=v1.0.2&dev=chase +http://192.168.0.10:5000/update?dev=chase&ver=v1.0.2 ``` The server will respond with _HTTP Error Code_: @@ -65,15 +65,15 @@ Below if an implementation for _ESP32_ that works with the server. Remember to c #include #include -#define VERSION "v1.0.2" -#define HOST "Chase" +#define VERSION "v1.0.2 +#define DEVICE_PLATFORM "Chase" -const char* urlBase = "http://192.168.0.10:5000/"; +const char* ota_update_server = "http://192.168.0.10:5000/"; /***************************************************/ void checkForUpdates(void) { - String checkUrl = String( ota_update_server) + String("update?ver=" VERSION "&dev=" HOST); + String checkUrl = String( ota_update_server) + String("update?dev=" DEVICE_PLATFORM "&ver=" VERSION ); Serial.println("INFO: Checking for updates at URL: " + String( checkUrl ) ); @@ -104,15 +104,15 @@ For _ESP8266_ the implementation is very similar with a few changes. Remember to #include #include -#define VERSION "v1.0.2" -#define HOST "Chase" +#define VERSION "v1.0.2 +#define DEVICE_PLATFORM "Chase" -const char* urlBase = "http://192.168.0.10:5000/update"; +const char* ota_update_server = "http://192.168.0.10:5000/"; /***************************************************/ void checkForUpdates(void) { - String checkUrl = String( ota_update_server) + String("update?ver=" VERSION "&dev=" HOST "); + String checkUrl = String( ota_update_server) + String("update?dev=" DEVICE_PLATFORM "&ver=" VERSION ); Serial.println("INFO: Checking for updates at URL: " + String( checkUrl ) ); From e51a61d10c6834962832601570e3725b737f7d6c Mon Sep 17 00:00:00 2001 From: Marco van Noord Date: Sun, 14 Mar 2021 17:43:40 +0100 Subject: [PATCH 08/73] Added a list of ESP devices that have been seen. This makes it easier to add new devices to the system. Added both a "first seen" and "last seen" column for each device. --- server.py | 3 ++- static/style.css | 1 + templates/whitelist.html | 19 +++++++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/server.py b/server.py index f6ca2de..407aa1e 100644 --- a/server.py +++ b/server.py @@ -270,6 +270,7 @@ def delete(): @app.route('/whitelist', methods=['GET', 'POST']) def whitelist(): platforms = load_yaml() + known_macs = load_known_mac_yaml() if platforms and request.method == 'POST': if 'Add' in request.form['action']: # Ensure valid data. @@ -305,7 +306,7 @@ def whitelist(): flash('Error: Unknown action.') if platforms: - return render_template('whitelist.html', platforms=platforms) + return render_template('whitelist.html', platforms=platforms, known_macs = known_macs) else: return render_template('status.html', platforms=platforms) diff --git a/static/style.css b/static/style.css index 99fd26a..3e75702 100644 --- a/static/style.css +++ b/static/style.css @@ -12,6 +12,7 @@ h2 { font-size: 1.2em; } .add-entry dl { font-weight: bold; } .metanav { text-align: right; font-size: 0.8em; padding: 0.3em; margin-bottom: 1em; background: #fafafa; } +.maclist { padding: 0.3em; margin-bottom: 1em; } .flash { background: #cee5F5; padding: 0.5em; border: 1px solid #aacbe2; } .error { background: #f0d6d6; padding: 0.5em; } diff --git a/templates/whitelist.html b/templates/whitelist.html index 2bee036..aefadd9 100644 --- a/templates/whitelist.html +++ b/templates/whitelist.html @@ -43,5 +43,24 @@

Manage Whitelists

{% endfor %} +

+ + + + + + + + {% for key, value in known_macs.items(): %} + {% if value['first_seen']: %} + + + + + + {% endif %} + {% endfor %} +
MAC AddressFirst seenLast seen
{{ format_mac(key.upper()) }}{{ value['first_seen'] }}{{ value['last_seen'] }}
+ {% endblock %} From 2bcf76cd7dbaf4e27582f232825be8311e22b598 Mon Sep 17 00:00:00 2001 From: Marco van Noord Date: Sun, 14 Mar 2021 18:18:21 +0100 Subject: [PATCH 09/73] added some very very basic (pun intended) authentication. --- bin/users.yml | 4 ++++ requirements.txt | 1 + server.py | 39 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 bin/users.yml diff --git a/bin/users.yml b/bin/users.yml new file mode 100644 index 0000000..f216d4f --- /dev/null +++ b/bin/users.yml @@ -0,0 +1,4 @@ +John: + 'Welcome' +Steven: + '12345678' \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 160edaf..de8406c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ flask==1.0.2 pyYAML==5.1 packaging==19.0 +Flask-HTTPAuth==4.2.0 \ No newline at end of file diff --git a/server.py b/server.py index 407aa1e..9c2aae0 100644 --- a/server.py +++ b/server.py @@ -7,6 +7,8 @@ from flask import (Flask, flash, redirect, render_template, request, send_from_directory, url_for) from packaging import version +from flask_httpauth import HTTPBasicAuth +from werkzeug.security import generate_password_hash, check_password_hash __author__ = 'Kristian Stobbe' __copyright__ = 'Copyright 2019, K. Stobbe' @@ -23,8 +25,19 @@ app.config['SECRET_KEY'] = 'Kri57i4n570bb33r3nF1ink3rFyr' PLATFORMS_YAML = app.config['UPLOAD_FOLDER'] + '/platforms.yml' MACS_YAML = app.config['UPLOAD_FOLDER'] + '/macs.yml' +USERS_YAML = app.config['UPLOAD_FOLDER'] + '/users.yml' +auth = HTTPBasicAuth() + +users = {} + +@auth.verify_password +def verify_password(username, password): + if username in users and \ + check_password_hash(users.get(username), password): + return username + def log_event(msg): st = datetime.fromtimestamp(time.time()).strftime('%Y-%m-%d %H:%M:%S') print(st + ' ' + msg) @@ -52,6 +65,24 @@ def load_yaml(): value['whitelist'][i] = str(value['whitelist'][i]) return platforms +def load_users(): + users = None + try: + with open(USERS_YAML, 'r') as stream: + try: + users = yaml.load(stream, Loader=yaml.FullLoader) + except yaml.YAMLError as err: + flash(err) + except: + flash('Error: Users file not found.') + if users: + for user in users: # generate hash from the plaintext password + users[user] = generate_password_hash(users[user]) + if not users: + users = dict() + print(users) + return users + def save_yaml(platforms): try: @@ -159,8 +190,8 @@ def update(): log_event("ERROR: Invalid parameters.") return 'Error: Invalid parameters.', 400 - @app.route('/upload', methods=['GET', 'POST']) +@auth.login_required def upload(): platforms = load_yaml() if platforms and request.method == 'POST': @@ -216,6 +247,7 @@ def upload(): @app.route('/create', methods=['GET', 'POST']) +@auth.login_required def create(): if request.method == 'POST': if not request.form['name']: @@ -239,6 +271,7 @@ def create(): @app.route('/delete', methods=['GET', 'POST']) +@auth.login_required def delete(): if request.method == 'POST': if not request.form['name']: @@ -268,6 +301,7 @@ def delete(): @app.route('/whitelist', methods=['GET', 'POST']) +@auth.login_required def whitelist(): platforms = load_yaml() known_macs = load_known_mac_yaml() @@ -310,12 +344,13 @@ def whitelist(): else: return render_template('status.html', platforms=platforms) - @app.route('/') +@auth.login_required def index(): platforms = load_yaml() return render_template('status.html', platforms=platforms) if __name__ == '__main__': + users = load_users() app.run(host='0.0.0.0', port=int('5000'), debug=False) From 1da3344e1bef991a2e43c3955a7766e5f6e86f8c Mon Sep 17 00:00:00 2001 From: Marco van Noord Date: Mon, 15 Mar 2021 09:35:14 +0100 Subject: [PATCH 10/73] make the documentation more clear --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4624e5a..a0b63f2 100644 --- a/README.md +++ b/README.md @@ -31,10 +31,10 @@ python3 server.py Ready-made Docker images are available on [Docker Hub](https://hub.docker.com/r/kstobbe/esp-update-server/) which support running on Linux on both AMD64 and ARM32V6 architectures - i.e. desktops, laptops, and Raspberry Pis. -To run the server in a Docker container create a directory for storing binaries. Go inside this directory and execute the following command: +To run the server in a Docker container create a directory for storing binaries. Then run following command: ``` -docker run -d -v $PWD:/esp-update-server/bin -p 5000:5000 kstobbe/esp-update-server:latest +docker run -d -v $PWD/bin:/esp-update-server/bin -p 5000:5000 kstobbe/esp-update-server:latest ``` Using the `-v` option ensures files are stored outside the Docker container and are thus persisted even if the container is terminated. From 18b23f40c4fb6025b4ba9760723e7de04cb69588 Mon Sep 17 00:00:00 2001 From: Marco van Noord Date: Mon, 15 Mar 2021 09:35:29 +0100 Subject: [PATCH 11/73] version bump due to incompatibility with original fork --- server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server.py b/server.py index 9c2aae0..5ae90cb 100644 --- a/server.py +++ b/server.py @@ -14,7 +14,7 @@ __copyright__ = 'Copyright 2019, K. Stobbe' __credits__ = ['Kristian Stobbe'] __license__ = 'MIT' -__version__ = '1.1.0' +__version__ = '2.1.0' __maintainer__ = 'Kristian Stobbe' __email__ = 'mail@kstobbe.dk' __status__ = 'Production' From 619d8d07a30e08305eea132a4c0dd2f527bd52a6 Mon Sep 17 00:00:00 2001 From: Marco van Noord Date: Mon, 15 Mar 2021 10:13:02 +0100 Subject: [PATCH 12/73] =?UTF-8?q?=F0=9F=90=9B=20fixed=20bug=20where=20uplo?= =?UTF-8?q?ading=20a=20file=20would=20only=20work=20when=20uploading=20for?= =?UTF-8?q?=20the=20first=20platform.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/server.py b/server.py index 5ae90cb..5476042 100644 --- a/server.py +++ b/server.py @@ -225,18 +225,20 @@ def upload(): os.remove(os.path.join(app.config['UPLOAD_FOLDER'], old_file)) except: flash('Error: Removing old file failed.') - flash('Success: File uploaded.') + flash('Success: File uploaded for platform {} with version {}.'.format(__dev, __ver)) else: flash('Error: Could not save file.') return redirect(url_for('index')) else: flash('Error: Version must increase. File not uploaded.') return redirect(request.url) - else: - flash('Error: No version found in file. File not uploaded.') - return redirect(request.url) - flash('Error: No known platform name found in file. File not uploaded.') - return redirect(request.url) + m = re.search(b"update\?dev=" + __dev.encode('UTF-8')+ b"&ver=$", data, re.IGNORECASE) + if m: # a platform was found, meaning no version was found + flash('Error: No version found in file. File not uploaded.') + return redirect(request.url) + else: + flash('Error: No known platform name found in file. File not uploaded.') + return redirect(request.url) else: flash('Error: File type not allowed.') return redirect(request.url) From c7973b4b813304cc6fefa8c8c7e96453911eb3a5 Mon Sep 17 00:00:00 2001 From: Marco van Noord Date: Mon, 15 Mar 2021 10:25:20 +0100 Subject: [PATCH 13/73] update readme to reflect recent changes. --- README.md | 9 ++++++--- img/status.png | Bin 0 -> 19122 bytes img/whitelist.png | Bin 0 -> 33358 bytes 3 files changed, 6 insertions(+), 3 deletions(-) create mode 100644 img/status.png create mode 100644 img/whitelist.png diff --git a/README.md b/README.md index a0b63f2..26edddd 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,11 @@ The main feature are: - **Platform Names**: Each platform supported must be created on the web interface before a binary is uploaded. If an existing _platform name_ is not found in an uploaded binary it is rejected. Multiple devices can share the same _platform name_ and thus receive the same binary. Individual devices are controlled through _whitelists_ described below. - **Semantic Versioning**: Updates are only done if a newer version of a binary is available compared to the version the device is already running. Semantic versioning is assumed e.g. v1.0.2. The uploaded binary must contain a version number starting with _v_ and three version number components i.e MAJOR, MINOR, PATCH - _v1.0.2_. An uploaded binary is rejected if such a version number is not found in the binary or if the version number is not increased compared to the one already known. - **Whitelists**: Download control is enforced by MAC Address whitelists. On the web interface WiFi MAC Addresses can be added and removed to created platforms. Only whitelisted devices will be allowed to update. -- **Binary Upload**: Uploading binaries is simple and administration is kept to a minimum by automatic detection of _platform name_ and _version number_. +- **Binary Upload**: Uploading binaries is simple and administration is kept to a minimum by automatic detection of _platform name_ and _version number_. This detection helps prevent mistakes where the OTA update routine is not called or you forgot to enter a valid platform name. ## How Do I Use It? -The server is _intended_ to run on internal network where it cannot be accessed from the internet. As such it does not offer any security mechanisms. +The server is _intended_ to run on internal network where it cannot be accessed from the internet. As such it does only offer very basic security. In the users.yml, you can create additional users that are allowed to access the server ### Start Server From Code @@ -42,7 +42,10 @@ Using the `-v` option ensures files are stored outside the Docker container and ### Access Server For Management In a web browser, when the server is running, enter the IP address of the machine running the server and port 5000, e.g. `http://192.168.0.10:5000`. Now platforms can be created and deleted. Whitelists can be managed and binaries uploaded. - +![alt text](img/status.png "Status overview") +Status overview +![alt text](img/whitelist.png "Whitelist page") +Whitelisting devices, and assigning them to a platform ### Access Server For Update Devices requesting download of a binary file for upgrade must access path `update` and include _device name_ and current _version number_ in a query like below - substitute the IP address with your own. diff --git a/img/status.png b/img/status.png new file mode 100644 index 0000000000000000000000000000000000000000..a9d71cb3cb4d7793a73463df3c2b303044d26016 GIT binary patch literal 19122 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 X>p&5m;6&V{bKmWos&aYPO}+mQA{X1W literal 0 HcmV?d00001 From eb456c16db1af8f5f58becdef1e02e816aff796a Mon Sep 17 00:00:00 2001 From: Marco Date: Mon, 15 Mar 2021 10:28:31 +0100 Subject: [PATCH 14/73] Update README.md --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 26edddd..59e394a 100644 --- a/README.md +++ b/README.md @@ -41,11 +41,12 @@ Using the `-v` option ensures files are stored outside the Docker container and ### Access Server For Management -In a web browser, when the server is running, enter the IP address of the machine running the server and port 5000, e.g. `http://192.168.0.10:5000`. Now platforms can be created and deleted. Whitelists can be managed and binaries uploaded. +In a web browser, when the server is running, enter the IP address of the machine running the server and port 5000, e.g. `http://192.168.0.10:5000`. Now platforms can be created and deleted. Whitelists can be managed and binaries uploaded. +**Status overview** ![alt text](img/status.png "Status overview") -Status overview +**Whitelisting devices, and assigning them to a platform** ![alt text](img/whitelist.png "Whitelist page") -Whitelisting devices, and assigning them to a platform + ### Access Server For Update Devices requesting download of a binary file for upgrade must access path `update` and include _device name_ and current _version number_ in a query like below - substitute the IP address with your own. From 32063536d2de2b48b23441c263639e59704e0d3b Mon Sep 17 00:00:00 2001 From: Marco Date: Mon, 5 Jul 2021 13:07:15 +0200 Subject: [PATCH 15/73] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 59e394a..1a01fbf 100644 --- a/README.md +++ b/README.md @@ -31,10 +31,10 @@ python3 server.py Ready-made Docker images are available on [Docker Hub](https://hub.docker.com/r/kstobbe/esp-update-server/) which support running on Linux on both AMD64 and ARM32V6 architectures - i.e. desktops, laptops, and Raspberry Pis. -To run the server in a Docker container create a directory for storing binaries. Then run following command: +To run the server in a Docker container create a directory `bin` for storing binaries. Then run following command from the directory where you have the `server.py` ``` -docker run -d -v $PWD/bin:/esp-update-server/bin -p 5000:5000 kstobbe/esp-update-server:latest +docker run -d --restart unless-stopped --name esp-update-server -v $PWD/bin:/esp-update-server/bin -p 5000:5000 kstobbe/esp-update-server:latest ``` Using the `-v` option ensures files are stored outside the Docker container and are thus persisted even if the container is terminated. From f6a192bde5267c6efaca2d259cb20101cf791c6a Mon Sep 17 00:00:00 2001 From: Marco Date: Mon, 26 Jul 2021 10:06:02 +0200 Subject: [PATCH 16/73] Made maclist table a bit more readable by stretching it to full width --- static/style.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/static/style.css b/static/style.css index 3e75702..5fd4f39 100644 --- a/static/style.css +++ b/static/style.css @@ -12,7 +12,8 @@ h2 { font-size: 1.2em; } .add-entry dl { font-weight: bold; } .metanav { text-align: right; font-size: 0.8em; padding: 0.3em; margin-bottom: 1em; background: #fafafa; } -.maclist { padding: 0.3em; margin-bottom: 1em; } +.maclist { padding: 0.3em; margin-bottom: 1em; width: 100%; } +.maclist td { padding-left: 0.3em; padding-right: 0.3em } .flash { background: #cee5F5; padding: 0.5em; border: 1px solid #aacbe2; } .error { background: #f0d6d6; padding: 0.5em; } From 9ae6df9ee0e4087077c933bfa57afc026ae0505f Mon Sep 17 00:00:00 2001 From: Marco Date: Mon, 26 Jul 2021 10:10:43 +0200 Subject: [PATCH 17/73] add favicon --- static/favicon.ico | Bin 0 -> 1150 bytes templates/layout.html | 1 + 2 files changed, 1 insertion(+) create mode 100644 static/favicon.ico diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..541dd4ad85383e22741b2a1ecfade6e4058426c8 GIT binary patch literal 1150 zcmah}O=}ZT6n*pF%$sR4HD4q(b|Yd+c1m$01Z|T{hENR&Q<6%PcF|a+r9VN`Reyts z3i%V=3yNFmT9@L&A0TexIWIF|P(i}UeYx}Qx%ZxXCqh{G8%R4!{M^ z!Ve<^o)xnTnBR*p-?FSoDzzu2^oRTVa;x1YEI_5~KYfVVRZ7)d&#T$C9l5USKO794 z`>hu5+kgY`tYe$@DdZkyGMPF!>dLmq;OKfQE7=GAzTpVEUHkFLiQEETxU-{2lL_l0 zCm|5$TT({0;~eA*1y{T7ZSYJy&&v*mL#I$Ix?1ZyrE+-{wdH-^zY%k=4)y%}+$xqz zIrPE2o3Mf7I8E?{xSP4QzMeyk7x2G^zkxfs4(dRx(c^BTA;709Yip~xH+HlRjMTQ> zLoa>!j#1-V*f}|_R;&Kx=!iWe9Ew^E$75BlRIcDoh&+s&0bhZiz&CKbgS}ucuVypm zCX)GRawK(tqX&G({g1$R;1_UQMK9E&4NVbi^n+zBW;Ab*#83SH}bU(nwk z?j;)j$ze3b^W|*gJ&oe`rH1?wo>M<^By;5XFCR1!)6-LloP6I$sAZS+r~U_)M|*kt G|KT^Dv!;^( literal 0 HcmV?d00001 diff --git a/templates/layout.html b/templates/layout.html index ba6844d..860a7f7 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -2,6 +2,7 @@ ESP Update Server +
From 55c14c350cf4538df27f5f19cc5093b3a3a645f5 Mon Sep 17 00:00:00 2001 From: Marco Date: Mon, 26 Jul 2021 10:16:58 +0200 Subject: [PATCH 18/73] Style whitelist to make it more readable add link in footer --- static/style.css | 5 +++-- templates/layout.html | 2 +- templates/whitelist.html | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/static/style.css b/static/style.css index 5fd4f39..120ebdc 100644 --- a/static/style.css +++ b/static/style.css @@ -8,12 +8,13 @@ h2 { font-size: 1.2em; } .entries { list-style: none; margin: 0; padding: 0; } .entries li { margin: 0.8em 1.2em; } .entries li h2 { margin-left: -1em; } -.add-entry { font-size: 0.9em; border-bottom: 1px solid #ccc; } +.add-entry { font-size: 0.9em;} .add-entry dl { font-weight: bold; } .metanav { text-align: right; font-size: 0.8em; padding: 0.3em; margin-bottom: 1em; background: #fafafa; } .maclist { padding: 0.3em; margin-bottom: 1em; width: 100%; } -.maclist td { padding-left: 0.3em; padding-right: 0.3em } +.maclist td { padding-left: 0.3em; padding-right: 0.3em } +.whitelist {width: 75%; } .flash { background: #cee5F5; padding: 0.5em; border: 1px solid #aacbe2; } .error { background: #f0d6d6; padding: 0.5em; } diff --git a/templates/layout.html b/templates/layout.html index 860a7f7..45520d8 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -19,7 +19,7 @@

ESP Update Server

{% endfor %} {% block body %}{% endblock %}
diff --git a/templates/whitelist.html b/templates/whitelist.html index aefadd9..edac581 100644 --- a/templates/whitelist.html +++ b/templates/whitelist.html @@ -18,7 +18,7 @@

Manage Whitelists

- +
From fd80593a49452dcda7c46f1107eb70d9f150797f Mon Sep 17 00:00:00 2001 From: Marco Date: Mon, 26 Jul 2021 11:04:24 +0200 Subject: [PATCH 19/73] the "first seen" and "last seen" date and time are now show in the local time of the user in the "last seen", also added xxx-time ago, when the device was last seen --- requirements.txt | 3 ++- server.py | 5 +++++ static/style.css | 2 +- templates/layout.html | 1 + templates/whitelist.html | 4 ++-- 5 files changed, 11 insertions(+), 4 deletions(-) diff --git a/requirements.txt b/requirements.txt index de8406c..c48a10c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ flask==1.0.2 pyYAML==5.1 packaging==19.0 -Flask-HTTPAuth==4.2.0 \ No newline at end of file +Flask-HTTPAuth==4.2.0 +flask-moment==1.0.2 \ No newline at end of file diff --git a/server.py b/server.py index 5476042..f50ec30 100644 --- a/server.py +++ b/server.py @@ -2,10 +2,12 @@ import re import time from datetime import datetime +from collections import OrderedDict import yaml from flask import (Flask, flash, redirect, render_template, request, send_from_directory, url_for) +from flask_moment import Moment from packaging import version from flask_httpauth import HTTPBasicAuth from werkzeug.security import generate_password_hash, check_password_hash @@ -21,6 +23,7 @@ ALLOWED_EXTENSIONS = set(['bin']) app = Flask(__name__) +moment = Moment(app) app.config['UPLOAD_FOLDER'] = './bin' app.config['SECRET_KEY'] = 'Kri57i4n570bb33r3nF1ink3rFyr' PLATFORMS_YAML = app.config['UPLOAD_FOLDER'] + '/platforms.yml' @@ -108,8 +111,10 @@ def load_known_mac_yaml(): for known_mac in macs.values(): if known_mac['first_seen']: known_mac['first_seen'] = str(known_mac['first_seen']) + known_mac['first_seen_dt'] = datetime.strptime(known_mac['first_seen'], '%Y-%m-%d %H:%M:%S') if known_mac['last_seen']: known_mac['last_seen'] = str(known_mac['last_seen']) + known_mac['last_seen_dt'] = datetime.strptime(known_mac['last_seen'], '%Y-%m-%d %H:%M:%S') if not macs: macs = dict() return macs diff --git a/static/style.css b/static/style.css index 120ebdc..ced17fd 100644 --- a/static/style.css +++ b/static/style.css @@ -3,7 +3,7 @@ a, h1, h2 { color: #377ba8; } h1, h2 { font-family: 'Georgia', serif; margin: 0; } h1 { border-bottom: 2px solid #eee; } h2 { font-size: 1.2em; } -.page { margin: 2em auto; width: 35em; border: 5px solid #ccc; +.page { margin: 2em auto; width: 40em; border: 5px solid #ccc; padding: 0.8em; background: white; } .entries { list-style: none; margin: 0; padding: 0; } .entries li { margin: 0.8em 1.2em; } diff --git a/templates/layout.html b/templates/layout.html index 45520d8..4f777ce 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -3,6 +3,7 @@ ESP Update Server + {{ moment.include_moment() }}
diff --git a/templates/whitelist.html b/templates/whitelist.html index edac581..86f9641 100644 --- a/templates/whitelist.html +++ b/templates/whitelist.html @@ -55,8 +55,8 @@

Manage Whitelists

{% if value['first_seen']: %}
- - + + {% endif %} {% endfor %} From 8d5a4cc7ca3a247eda024ad3dbac43dbcd94d026 Mon Sep 17 00:00:00 2001 From: Marco Date: Mon, 26 Jul 2021 11:30:01 +0200 Subject: [PATCH 20/73] added "checkmark" to show the user which devices are new, and which devices are already on a whitelist --- server.py | 11 +++++++++-- templates/whitelist.html | 4 +++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/server.py b/server.py index f50ec30..dab6510 100644 --- a/server.py +++ b/server.py @@ -129,6 +129,13 @@ def save_known_mac_yaml(macs): flash('Error: Known MAC data not saved.') return False +def detect_known_macs(known_macs, platforms): + for key, known_mac in known_macs.items(): + for platform, platform_values in platforms.items(): + if(platform_values['whitelist'] and key in platform_values['whitelist']): + # This mac is whitelisted, store the platform-name, so we can use that in the future + known_mac['platform'] = platform + return known_macs @app.context_processor def utility_processor(): @@ -345,9 +352,9 @@ def whitelist(): flash('Error: Could not save file.') else: flash('Error: Unknown action.') - if platforms: - return render_template('whitelist.html', platforms=platforms, known_macs = known_macs) + known_macs_with_platform = detect_known_macs(known_macs, platforms) + return render_template('whitelist.html', platforms=platforms, known_macs = known_macs_with_platform) else: return render_template('status.html', platforms=platforms) diff --git a/templates/whitelist.html b/templates/whitelist.html index 86f9641..725b77c 100644 --- a/templates/whitelist.html +++ b/templates/whitelist.html @@ -46,6 +46,7 @@

Manage Whitelists



Platform MAC Address
{{ format_mac(key.upper()) }}{{ value['first_seen'] }}{{ value['last_seen'] }}{{ moment(value['first_seen_dt']).format('DD-MM-YYYY HH:mm') }}{{ moment(value['last_seen_dt']).format('DD-MM-YYYY HH:mm') }} ({{ moment(value['last_seen_dt']).fromNow()}})
+ @@ -54,7 +55,8 @@

Manage Whitelists

{% for key, value in known_macs.items(): %} {% if value['first_seen']: %} - + + From 29e8c4a253b5869738f484e181a868f00508296f Mon Sep 17 00:00:00 2001 From: Marco Date: Mon, 26 Jul 2021 11:47:28 +0200 Subject: [PATCH 21/73] update readme to show users how to build the docker-file --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 1a01fbf..4d0a09f 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,13 @@ To run the server directly from code start it with the following command: python3 server.py ``` +### Build docker file yourself +From the root-directory of this app, run: `docker build -t esp-update-server:latest . ` to re-build the docker-image from source +To directly run this app, run +``` +docker run -d --restart unless-stopped --name esp-update-server -v $PWD/bin:/esp-update-server/bin -p 5000:5000 esp-update-server:latest +``` + ### Start Server From Docker? Ready-made Docker images are available on [Docker Hub](https://hub.docker.com/r/kstobbe/esp-update-server/) which support running on Linux on both AMD64 and ARM32V6 architectures - i.e. desktops, laptops, and Raspberry Pis. From a3b5c30610d7188f1426594a057ce4b81ebd1c19 Mon Sep 17 00:00:00 2001 From: Marco Date: Mon, 26 Jul 2021 11:57:55 +0200 Subject: [PATCH 22/73] Update whitelist image to reflect recent changes --- img/whitelist.png | Bin 33358 -> 68302 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/img/whitelist.png b/img/whitelist.png index 9f346a34467760b8eda040f5c0722dbfe45b5fb6..f36926e9ac213bc68db8004c806863bbdab9c518 100644 GIT binary patch literal 68302 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 VF}+guLm*#;Y)?B_mY93S{Renr0)GGi literal 33358 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 X>p&5m;6&V{bKmWos&aYPO}+mQA{X1W From 048554786614cf080a7e36e1607d9636c9c2b726 Mon Sep 17 00:00:00 2001 From: Marco Date: Mon, 26 Jul 2021 12:09:02 +0200 Subject: [PATCH 23/73] Remove link --- templates/layout.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/layout.html b/templates/layout.html index 4f777ce..211a21e 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -20,7 +20,7 @@

ESP Update Server

{% endfor %} {% block body %}{% endblock %}
From 8f8fd648d4c7259d2143cb2d2a4934b8e88ef755 Mon Sep 17 00:00:00 2001 From: Marco Date: Wed, 22 Dec 2021 14:23:31 +0100 Subject: [PATCH 24/73] add sentry to help debugging --- requirements.txt | 3 +- server.py | 122 +++++++++++++++++++++++++++-------------------- 2 files changed, 73 insertions(+), 52 deletions(-) diff --git a/requirements.txt b/requirements.txt index c48a10c..1f7c4aa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,5 @@ flask==1.0.2 pyYAML==5.1 packaging==19.0 Flask-HTTPAuth==4.2.0 -flask-moment==1.0.2 \ No newline at end of file +flask-moment==1.0.2 +sentry-sdk[flask]==1.5.1 \ No newline at end of file diff --git a/server.py b/server.py index dab6510..9ace0d2 100644 --- a/server.py +++ b/server.py @@ -11,6 +11,9 @@ from packaging import version from flask_httpauth import HTTPBasicAuth from werkzeug.security import generate_password_hash, check_password_hash +import sentry_sdk +from flask import Flask +from sentry_sdk.integrations.flask import FlaskIntegration __author__ = 'Kristian Stobbe' __copyright__ = 'Copyright 2019, K. Stobbe' @@ -35,6 +38,20 @@ users = {} +sentry_sdk.init( + dsn="https://ccfebfa76dc645acbc16566836763e5b@o231748.ingest.sentry.io/6118097", + integrations=[FlaskIntegration()], + + # Set traces_sample_rate to 1.0 to capture 100% + # of transactions for performance monitoring. + # We recommend adjusting this value in production. + traces_sample_rate=1.0 +) + +@app.route('/debug-sentry') +def trigger_error(): + division_by_zero = 1 / 0 + @auth.verify_password def verify_password(username, password): if username in users and \ @@ -146,61 +163,64 @@ def format_mac(mac): @app.route('/update', methods=['GET', 'POST']) def update(): - __error = 400 - platforms = load_yaml() - known_macs = load_known_mac_yaml() - __dev = request.args.get('dev', default=None) - if 'X_ESP8266_STA_MAC' in request.headers: - __mac = request.headers['X_ESP8266_STA_MAC'] - __mac = str(re.sub(r'[^0-9A-fa-f]+', '', __mac.lower())) - log_event("INFO: Update called by ESP8266 with MAC " + __mac) - elif 'x_ESP32_STA_MAC' in request.headers: - __mac = request.headers['x_ESP32_STA_MAC'] - __mac = str(re.sub(r'[^0-9A-fa-f]+', '', __mac.lower())) - log_event("INFO: Update called by ESP32 with MAC " + __mac) - else: - __mac = '' - log_event("WARN: Update called without known headers.") - __ver = request.args.get('ver', default=None) - if __dev and __mac and __ver: - # If we know this device already - if __mac in known_macs.keys(): - known_macs[__mac]['last_seen'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + try: + __error = 400 + platforms = load_yaml() + known_macs = load_known_mac_yaml() + __dev = request.args.get('dev', default=None) + if 'X_ESP8266_STA_MAC' in request.headers: + __mac = request.headers['X_ESP8266_STA_MAC'] + __mac = str(re.sub(r'[^0-9A-fa-f]+', '', __mac.lower())) + log_event("INFO: Update called by ESP8266 with MAC " + __mac) + elif 'x_ESP32_STA_MAC' in request.headers: + __mac = request.headers['x_ESP32_STA_MAC'] + __mac = str(re.sub(r'[^0-9A-fa-f]+', '', __mac.lower())) + log_event("INFO: Update called by ESP32 with MAC " + __mac) else: - known_macs[__mac] = {'first_seen': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), - 'last_seen': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), - 'IP': None, - 'type': None} - save_known_mac_yaml(known_macs) - log_event("INFO: Dev: " + __dev + "Ver: " + __ver) - __dev = __dev.lower() - if platforms: - if __dev in platforms.keys(): - if __mac in platforms[__dev]['whitelist']: - if not platforms[__dev]['version']: - log_event("ERROR: No update available.") - return 'No update available.', 400 - if version.parse(__ver) < version.parse(platforms[__dev]['version']): - if os.path.isfile(app.config['UPLOAD_FOLDER'] + '/' + platforms[__dev]['file']): - platforms[__dev]['downloads'] += 1 - save_yaml(platforms) - return send_from_directory(directory=app.config['UPLOAD_FOLDER'], filename=platforms[__dev]['file'], - as_attachment=True, mimetype='application/octet-stream', - attachment_filename=platforms[__dev]['file']) + __mac = '' + log_event("WARN: Update called without known headers.") + __ver = request.args.get('ver', default=None) + if __dev and __mac and __ver: + # If we know this device already + if __mac in known_macs.keys(): + known_macs[__mac]['last_seen'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + else: + known_macs[__mac] = {'first_seen': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'last_seen': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'IP': None, + 'type': None} + save_known_mac_yaml(known_macs) + log_event("INFO: Dev: " + __dev + "Ver: " + __ver) + __dev = __dev.lower() + if platforms: + if __dev in platforms.keys(): + if __mac in platforms[__dev]['whitelist']: + if not platforms[__dev]['version']: + log_event("ERROR: No update available.") + return 'No update available.', 400 + if version.parse(__ver) < version.parse(platforms[__dev]['version']): + if os.path.isfile(app.config['UPLOAD_FOLDER'] + '/' + platforms[__dev]['file']): + platforms[__dev]['downloads'] += 1 + save_yaml(platforms) + return send_from_directory(directory=app.config['UPLOAD_FOLDER'], filename=platforms[__dev]['file'], + as_attachment=True, mimetype='application/octet-stream', + attachment_filename=platforms[__dev]['file']) + else: + log_event("INFO: No update needed.") + return 'No update needed.', 304 else: - log_event("INFO: No update needed.") - return 'No update needed.', 304 + log_event("ERROR: Device not whitelisted.") + return 'Error: Device not whitelisted.', 400 else: - log_event("ERROR: Device not whitelisted.") - return 'Error: Device not whitelisted.', 400 + log_event("ERROR: Unknown platform.") + return 'Error: Unknown platform.', 400 else: - log_event("ERROR: Unknown platform.") - return 'Error: Unknown platform.', 400 - else: - log_event("ERROR: Create platforms before updating.") - return 'Error: Create platforms before updating.', 500 - log_event("ERROR: Invalid parameters.") - return 'Error: Invalid parameters.', 400 + log_event("ERROR: Create platforms before updating.") + return 'Error: Create platforms before updating.', 500 + log_event("ERROR: Invalid parameters.") + return 'Error: Invalid parameters.', 400 + except Exception as e: + print(e) @app.route('/upload', methods=['GET', 'POST']) @auth.login_required From 97fcaba30d757c550a4e3a962203c9010066d9fb Mon Sep 17 00:00:00 2001 From: Marco Date: Fri, 31 Dec 2021 11:45:22 +0100 Subject: [PATCH 25/73] upgrade to flask 2, add sqlalchemy to prepare for sql database --- server.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/server.py b/server.py index 9ace0d2..066ae4c 100644 --- a/server.py +++ b/server.py @@ -2,18 +2,16 @@ import re import time from datetime import datetime -from collections import OrderedDict +import sentry_sdk import yaml from flask import (Flask, flash, redirect, render_template, request, send_from_directory, url_for) +from flask_httpauth import HTTPBasicAuth from flask_moment import Moment from packaging import version -from flask_httpauth import HTTPBasicAuth -from werkzeug.security import generate_password_hash, check_password_hash -import sentry_sdk -from flask import Flask from sentry_sdk.integrations.flask import FlaskIntegration +from werkzeug.security import check_password_hash, generate_password_hash __author__ = 'Kristian Stobbe' __copyright__ = 'Copyright 2019, K. Stobbe' From 4eaddf8c9a1068c00c684dc0e42976a68b902c5c Mon Sep 17 00:00:00 2001 From: Marco Date: Fri, 31 Dec 2021 12:20:44 +0100 Subject: [PATCH 26/73] add templates, basic login and register boilerplate --- .gitignore | 2 +- .vscode/launch.json | 23 +++++++ bin/macs.yml | 1 - bin/platforms.yml | 1 - bin/users.yml | 4 -- requirements.txt | 8 ++- server/__init__.py | 26 ++++++++ server/auth.py | 63 ++++++++++++++++++ server/db.sqlite | Bin 0 -> 12288 bytes {img => server/img}/status.png | Bin {img => server/img}/whitelist.png | Bin server/main.py | 15 +++++ server/models.py | 10 +++ server.py => server/server.py | 0 {static => server/static}/favicon.ico | Bin {static => server/static}/style.css | 0 {templates => server/templates}/create.html | 0 {templates => server/templates}/delete.html | 0 server/templates/index.html | 12 ++++ server/templates/layout.html | 42 ++++++++++++ server/templates/login.html | 38 +++++++++++ server/templates/profile.html | 9 +++ server/templates/signup.html | 39 +++++++++++ {templates => server/templates}/status.html | 0 {templates => server/templates}/upload.html | 0 .../templates}/whitelist.html | 0 templates/layout.html | 26 -------- 27 files changed, 283 insertions(+), 36 deletions(-) create mode 100644 .vscode/launch.json delete mode 100644 bin/macs.yml delete mode 100644 bin/platforms.yml delete mode 100644 bin/users.yml create mode 100644 server/__init__.py create mode 100644 server/auth.py create mode 100644 server/db.sqlite rename {img => server/img}/status.png (100%) rename {img => server/img}/whitelist.png (100%) create mode 100644 server/main.py create mode 100644 server/models.py rename server.py => server/server.py (100%) rename {static => server/static}/favicon.ico (100%) rename {static => server/static}/style.css (100%) rename {templates => server/templates}/create.html (100%) rename {templates => server/templates}/delete.html (100%) create mode 100644 server/templates/index.html create mode 100644 server/templates/layout.html create mode 100644 server/templates/login.html create mode 100644 server/templates/profile.html create mode 100644 server/templates/signup.html rename {templates => server/templates}/status.html (100%) rename {templates => server/templates}/upload.html (100%) rename {templates => server/templates}/whitelist.html (100%) delete mode 100644 templates/layout.html diff --git a/.gitignore b/.gitignore index c61d0bf..db1d2d6 100644 --- a/.gitignore +++ b/.gitignore @@ -194,4 +194,4 @@ $RECYCLE.BIN/ # End of https://www.toptal.com/developers/gitignore/api/windows,visualstudiocode,flask # Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) - +server/bin/* diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..d1372f4 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,23 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python: Flask", + "type": "python", + "request": "launch", + "module": "flask", + "env": { + "FLASK_APP": "server", + "FLASK_ENV": "development" + }, + "args": [ + "run", + "--no-debugger" + ], + "jinja": true + } + ] +} \ No newline at end of file diff --git a/bin/macs.yml b/bin/macs.yml deleted file mode 100644 index 9e26dfe..0000000 --- a/bin/macs.yml +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/bin/platforms.yml b/bin/platforms.yml deleted file mode 100644 index 0967ef4..0000000 --- a/bin/platforms.yml +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/bin/users.yml b/bin/users.yml deleted file mode 100644 index f216d4f..0000000 --- a/bin/users.yml +++ /dev/null @@ -1,4 +0,0 @@ -John: - 'Welcome' -Steven: - '12345678' \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 1f7c4aa..7d6a4ed 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,8 @@ -flask==1.0.2 +flask==2.0.2 pyYAML==5.1 packaging==19.0 Flask-HTTPAuth==4.2.0 -flask-moment==1.0.2 -sentry-sdk[flask]==1.5.1 \ No newline at end of file +flask-moment>=1.0.2 +sentry-sdk[flask]>=1.5.1 +flask-login >= 0.5.0 +flask-sqlalchemy>=2.5.1 \ No newline at end of file diff --git a/server/__init__.py b/server/__init__.py new file mode 100644 index 0000000..0c5ead7 --- /dev/null +++ b/server/__init__.py @@ -0,0 +1,26 @@ +from flask import Flask +from flask_sqlalchemy import SQLAlchemy +from flask_moment import Moment + +# init SQLAlchemy so we can use it later in our models +db = SQLAlchemy() + +def create_app(): + app = Flask(__name__) + moment = Moment(app) + + app.config['SECRET_KEY'] = 'secret-key-goes-here' + app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///db.sqlite' + + db.init_app(app) + + # blueprint for auth routes in our app + from .auth import auth as auth_blueprint + app.register_blueprint(auth_blueprint) + + # blueprint for non-auth parts of app + from .main import main as main_blueprint + app.register_blueprint(main_blueprint) + print("Running create_app") + + return app \ No newline at end of file diff --git a/server/auth.py b/server/auth.py new file mode 100644 index 0000000..c2d049c --- /dev/null +++ b/server/auth.py @@ -0,0 +1,63 @@ +# auth.py + +from flask import Blueprint, render_template, redirect, url_for, request, flash +from werkzeug.security import generate_password_hash, check_password_hash +from flask_login import login_user, logout_user, login_required +from .models import User +from . import db + +auth = Blueprint('auth', __name__) + +@auth.route('/login') +def login(): + return render_template('login.html') + +@auth.route('/login', methods=['POST']) +def login_post(): + email = request.form.get('email') + password = request.form.get('password') + remember = True if request.form.get('remember') else False + + user = User.query.filter_by(email=email).first() + + # check if user actually exists + # take the user supplied password, hash it, and compare it to the hashed password in database + if not user or not check_password_hash(user.password, password): + flash('Please check your login details and try again.') + return redirect(url_for('auth.login')) # if user doesn't exist or password is wrong, reload the page + + # if the above check passes, then we know the user has the right credentials + login_user(user, remember=remember) + return redirect(url_for('main.profile')) + +@auth.route('/signup') +def signup(): + return render_template('signup.html') + +@auth.route('/signup', methods=['POST']) +def signup_post(): + + email = request.form.get('email') + name = request.form.get('name') + password = request.form.get('password') + + user = User.query.filter_by(email=email).first() # if this returns a user, then the email already exists in database + + if user: # if a user is found, we want to redirect back to signup page so user can try again + flash('Email address already exists') + return redirect(url_for('auth.signup')) + + # create new user with the form data. Hash the password so plaintext version isn't saved. + new_user = User(email=email, name=name, password=generate_password_hash(password, method='sha256')) + + # add the new user to the database + db.session.add(new_user) + db.session.commit() + + return redirect(url_for('auth.login')) + +@auth.route('/logout') +@login_required +def logout(): + logout_user() + return redirect(url_for('main.index')) \ No newline at end of file diff --git a/server/db.sqlite b/server/db.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..094db50973880cae83ea467954123713681cfa2a GIT binary patch literal 12288 zcmeI#ze~eF6bJCTC~5;GT|(DK7ZVX_ad2=kmZJt^v@sDng-A{aB#kyHb=AMd|HgmE z(Oje;ICK=s_u#$o-d%p&C)3?ey{wk>R4$67rc>5qoU;oe#u#s6r-}7Y_3pZ8uBzTN zI6Ikqbd4{j83SevjL%IsU>*VxfB*y_009U<00Izz00bcLKLT$@e7|p+{Qay>9&@>@ zy}=^a@lSxyg5FurmEYcw3%s{5Q& z)v8>ieLjT6l1TFl7G()JON?@l8@R{H{5w-2?w{<+CnHc#bi^^!Md z`#4$FWn0JUE8~&M>Rf-IJi99ORKkD&1Rwwb2tWV=5P$##AOHafKww)1bhR;@|F`w` aVqXw|00bZa0SG_<0uX=z1Rwx`R^S_g(nO*F literal 0 HcmV?d00001 diff --git a/img/status.png b/server/img/status.png similarity index 100% rename from img/status.png rename to server/img/status.png diff --git a/img/whitelist.png b/server/img/whitelist.png similarity index 100% rename from img/whitelist.png rename to server/img/whitelist.png diff --git a/server/main.py b/server/main.py new file mode 100644 index 0000000..00cf5dc --- /dev/null +++ b/server/main.py @@ -0,0 +1,15 @@ +# main.py + +from flask import Blueprint, render_template +from flask_login import login_required, current_user + +main = Blueprint('main', __name__) + +@main.route('/') +def index(): + return render_template('index.html') + +@main.route('/profile') +@login_required +def profile(): + return render_template('profile.html', name=current_user.name) \ No newline at end of file diff --git a/server/models.py b/server/models.py new file mode 100644 index 0000000..de45ad1 --- /dev/null +++ b/server/models.py @@ -0,0 +1,10 @@ +# models.py + +from flask_login import UserMixin +from . import db + +class User(UserMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) # primary keys are required by SQLAlchemy + email = db.Column(db.String(100), unique=True) + password = db.Column(db.String(100)) + name = db.Column(db.String(1000)) \ No newline at end of file diff --git a/server.py b/server/server.py similarity index 100% rename from server.py rename to server/server.py diff --git a/static/favicon.ico b/server/static/favicon.ico similarity index 100% rename from static/favicon.ico rename to server/static/favicon.ico diff --git a/static/style.css b/server/static/style.css similarity index 100% rename from static/style.css rename to server/static/style.css diff --git a/templates/create.html b/server/templates/create.html similarity index 100% rename from templates/create.html rename to server/templates/create.html diff --git a/templates/delete.html b/server/templates/delete.html similarity index 100% rename from templates/delete.html rename to server/templates/delete.html diff --git a/server/templates/index.html b/server/templates/index.html new file mode 100644 index 0000000..f3a0714 --- /dev/null +++ b/server/templates/index.html @@ -0,0 +1,12 @@ + + +{% extends "layout.html" %} + +{% block content %} +

+ Flask Login Example +

+

+ Easy authentication and authorization in Flask. +

+{% endblock %} \ No newline at end of file diff --git a/server/templates/layout.html b/server/templates/layout.html new file mode 100644 index 0000000..9feb725 --- /dev/null +++ b/server/templates/layout.html @@ -0,0 +1,42 @@ + + + + + + + + + + Flask Auth Example + + + + +
+ +
+ +
+ +
+
+ {% block content %} + {% endblock %} +
+
+
+ + + \ No newline at end of file diff --git a/server/templates/login.html b/server/templates/login.html new file mode 100644 index 0000000..3ab3454 --- /dev/null +++ b/server/templates/login.html @@ -0,0 +1,38 @@ + + +{% extends "layout.html" %} + +{% block content %} +
+

Login

+
+ {% with messages = get_flashed_messages() %} + {% if messages %} +
+ {{ messages[0] }} +
+ {% endif %} + {% endwith %} +
+
+
+ +
+
+ +
+
+ +
+
+
+ +
+ + +
+
+{% endblock %} \ No newline at end of file diff --git a/server/templates/profile.html b/server/templates/profile.html new file mode 100644 index 0000000..0224761 --- /dev/null +++ b/server/templates/profile.html @@ -0,0 +1,9 @@ + + +{% extends "layout.html" %} + +{% block content %} +

+ Welcome, {{ name }}! +

+{% endblock %} \ No newline at end of file diff --git a/server/templates/signup.html b/server/templates/signup.html new file mode 100644 index 0000000..0ea40f2 --- /dev/null +++ b/server/templates/signup.html @@ -0,0 +1,39 @@ + + +{% extends "layout.html" %} + +{% block content %} +
+

Sign Up

+
+ {% with messages = get_flashed_messages() %} + {% if messages %} +
+ {{ messages[0] }}. Go to login page. +
+ {% endif %} + {% endwith %} +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + + +
+
+{% endblock %} \ No newline at end of file diff --git a/templates/status.html b/server/templates/status.html similarity index 100% rename from templates/status.html rename to server/templates/status.html diff --git a/templates/upload.html b/server/templates/upload.html similarity index 100% rename from templates/upload.html rename to server/templates/upload.html diff --git a/templates/whitelist.html b/server/templates/whitelist.html similarity index 100% rename from templates/whitelist.html rename to server/templates/whitelist.html diff --git a/templates/layout.html b/templates/layout.html deleted file mode 100644 index 211a21e..0000000 --- a/templates/layout.html +++ /dev/null @@ -1,26 +0,0 @@ - - - ESP Update Server - - - {{ moment.include_moment() }} - - -
-

ESP Update Server

- - {% for message in get_flashed_messages() %} -
{{ message }}
- {% endfor %} - {% block body %}{% endblock %} -
- K. Stobbe / M. van Noord -
-
- From 52b9842e056d34ff42dbbd364e3242d566bd627c Mon Sep 17 00:00:00 2001 From: Marco Date: Fri, 31 Dec 2021 12:28:58 +0100 Subject: [PATCH 27/73] fix login system --- server/__init__.py | 19 +++++++++++++++---- server/db.sqlite | Bin 12288 -> 12288 bytes 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/server/__init__.py b/server/__init__.py index 0c5ead7..721caee 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -1,19 +1,31 @@ +# init.py + from flask import Flask from flask_sqlalchemy import SQLAlchemy -from flask_moment import Moment +from flask_login import LoginManager # init SQLAlchemy so we can use it later in our models db = SQLAlchemy() def create_app(): app = Flask(__name__) - moment = Moment(app) - app.config['SECRET_KEY'] = 'secret-key-goes-here' + app.config['SECRET_KEY'] = 'SECRET_KEY_HERE' app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///db.sqlite' db.init_app(app) + login_manager = LoginManager() + login_manager.login_view = 'auth.login' + login_manager.init_app(app) + + from .models import User + + @login_manager.user_loader + def load_user(user_id): + # since the user_id is just the primary key of our user table, use it in the query for the user + return User.query.get(int(user_id)) + # blueprint for auth routes in our app from .auth import auth as auth_blueprint app.register_blueprint(auth_blueprint) @@ -21,6 +33,5 @@ def create_app(): # blueprint for non-auth parts of app from .main import main as main_blueprint app.register_blueprint(main_blueprint) - print("Running create_app") return app \ No newline at end of file diff --git a/server/db.sqlite b/server/db.sqlite index 094db50973880cae83ea467954123713681cfa2a..be4441cbc45bad56d8c9dccccaae2556c8b075c5 100644 GIT binary patch delta 198 zcmZojXh@hK&B!!S#+i|6W5N=CHb(vy2L6`Kf&x|i^;L{)4Azad;<<@M$@yi8d3pKy zMJW#Hxrv!Mddc~@#Tki4re-RJ#_7h{$!XzHxv8N=$tKAv$%bhb7AdAFCTSLlhN&qQ zNy%o$sflUE28Kx%=BZ|>X=xV528n5AmOynz7AB@fMkXeS=0?V;DM==VW=00SAnUjo a7}yyY`JXcIKLtAGHovqKvo$9UhX4Q%&pVg^ delta 43 qcmZojXh@hK&B!=W#+i|EW5N=CCI*4cf(lpoCpM_DaWMb^oC^Q~;R{j# From a55f7a8b50ea4f0fc2a8c1dd502f7158e4b2fbc4 Mon Sep 17 00:00:00 2001 From: Marco Date: Fri, 31 Dec 2021 13:11:05 +0100 Subject: [PATCH 28/73] Auto-create database on index load allow adding platforms --- server/__init__.py | 6 ++--- server/db.sqlite | Bin 12288 -> 0 bytes server/main.py | 37 +++++++++++++++++++++++++++++-- server/models.py | 21 +++++++++++++++++- server/templates/create.html | 41 ++++++++++++++++++++++++++--------- server/templates/index.html | 4 ++-- server/templates/layout.html | 27 ++++++++++++++++++++--- server/templates/login.html | 2 +- 8 files changed, 116 insertions(+), 22 deletions(-) delete mode 100644 server/db.sqlite diff --git a/server/__init__.py b/server/__init__.py index 721caee..c5c0f59 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -11,15 +11,15 @@ def create_app(): app = Flask(__name__) app.config['SECRET_KEY'] = 'SECRET_KEY_HERE' - app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///db.sqlite' - + app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///bin/db.sqlite' + app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = True db.init_app(app) login_manager = LoginManager() login_manager.login_view = 'auth.login' login_manager.init_app(app) - from .models import User + from .models import User, Platform, Device @login_manager.user_loader def load_user(user_id): diff --git a/server/db.sqlite b/server/db.sqlite deleted file mode 100644 index be4441cbc45bad56d8c9dccccaae2556c8b075c5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12288 zcmeI$Z)?*)90%~bRHiMA_@or}=okZAk&=H+BW18y!Yu2mi>)%BlxuRC!KRr>yUCt) z_+orFz6#%i4|i7?2qJwXB7A?`?~?nIT=IE#_vUPvE(J^SBI8SDlgEToa>N)RM3Y&O zc^_2XJh*Sjt7>lxjkJ%y?&?2?rtcGdU;no40gOWc0uX=z1Rwwb2tWV=5P$##{*A!; zDSgxY3q4%pa#F$|dMVa-}KjoDx{4vvG6jb4q}Xfhl=W3{Tt zcsgfq{jmSi51Xc8v{dgUFU#w^h<_Vc@J#&L`E&Z~aB%8}=jk3jzWVfB*y_ z009U<00Izz00bZafxjqlLw87Teb~r&5#?8WvB>42z|n;|GiXP7R({}C$2~CZ3;Sb~ zynCOCNf9~GL1ZSL7k6SO@wh2sZx*?>;E8RRGp{RLktCjN@Wg#C=UJZ9u`I{oUCS2n x%rRZdI91El + {% extends "layout.html" %} -{% block body %} -

Create Platform

-
-
-
Platform name: -
-
-
- -{% endblock %} +{% block content %} +
+

Add platform

+
+ {% with messages = get_flashed_messages() %} + {% if messages %} +
+ {{ messages[0] }}. Go to login page. +
+ {% endif %} + {% endwith %} +
+
+
+ +
+
+ +
+
+ +
+
+ + + +
+
+{% endblock %} \ No newline at end of file diff --git a/server/templates/index.html b/server/templates/index.html index f3a0714..9c9074c 100644 --- a/server/templates/index.html +++ b/server/templates/index.html @@ -4,9 +4,9 @@ {% block content %}

- Flask Login Example + ESP Update server

- Easy authentication and authorization in Flask. + Easy system to manage OTA updates for Espressif-based devices

{% endblock %} \ No newline at end of file diff --git a/server/templates/layout.html b/server/templates/layout.html index 9feb725..2cc0b4e 100644 --- a/server/templates/layout.html +++ b/server/templates/layout.html @@ -1,4 +1,3 @@ - @@ -7,7 +6,7 @@ - Flask Auth Example + ESP update server @@ -23,7 +22,29 @@ Home - + {% if current_user.is_authenticated %} + + Profile + + {% endif %} + {% if current_user.is_authenticated %} + + Create platform + + {% endif %} + {% if not current_user.is_authenticated %} + + Login + + + Sign Up + + {% endif %} + {% if current_user.is_authenticated %} + + Logout + + {% endif %} diff --git a/server/templates/login.html b/server/templates/login.html index 3ab3454..c3e5a7d 100644 --- a/server/templates/login.html +++ b/server/templates/login.html @@ -27,7 +27,7 @@

Login

From 578fa435e9efe1b644e0f04862319ec6e2943236 Mon Sep 17 00:00:00 2001 From: Marco Date: Fri, 31 Dec 2021 14:29:36 +0100 Subject: [PATCH 29/73] added status screen, started working on the Update mechanics --- server/main.py | 89 ++++++++++++++++++- server/models.py | 10 ++- server/templates/layout.html | 5 ++ server/templates/status.html | 164 +++++++++++++++++++++++------------ 4 files changed, 207 insertions(+), 61 deletions(-) diff --git a/server/main.py b/server/main.py index 7267ecd..530e6e3 100644 --- a/server/main.py +++ b/server/main.py @@ -4,9 +4,17 @@ from flask_login import login_required, current_user from .models import User, Platform, Device from . import db +from datetime import datetime +import time +import re + main = Blueprint('main', __name__) +def log_event(msg): + st = datetime.fromtimestamp(time.time()).strftime('%Y-%m-%d %H:%M:%S') + print(st + ' ' + msg) + @main.route('/') def index(): db.create_all() # a bit dirty, but create all tables on load of the main. Doesn't re-create any already existing tables @@ -33,7 +41,6 @@ def create_post(): flash("No platform name entered") return redirect(url_for('main.create')) - platform = Platform.query.filter_by(name=platform_name).first() # if this returns a user, then the email already exists in database if platform: flash('Platform already exists') @@ -45,4 +52,82 @@ def create_post(): # add the new user to the database db.session.add(new_platform) db.session.commit() - return redirect(url_for('main.index')) \ No newline at end of file + return redirect(url_for('main.index')) + + +@main.context_processor +def utility_processor(): + def format_mac(mac): + return ':'.join(mac[i:i+2] for i in range(0,12,2)) + return dict(format_mac=format_mac) + +@main.route('/status') +@login_required +def status(): + platforms = Platform.query.all() + return render_template('status.html', platforms=platforms) + + + +@main.route('/update', methods=['GET']) +def update(): + + __error = 400 + + __dev = request.args.get('dev', default=None) # get requested device version + if 'X_ESP8266_STA_MAC' in request.headers: + __mac = request.headers['X_ESP8266_STA_MAC'] + __mac = str(re.sub(r'[^0-9A-fa-f]+', '', __mac.lower())) + log_event("INFO: Update called by ESP8266 with MAC " + __mac) + elif 'x_ESP32_STA_MAC' in request.headers: + __mac = request.headers['x_ESP32_STA_MAC'] + __mac = str(re.sub(r'[^0-9A-fa-f]+', '', __mac.lower())) + log_event("INFO: Update called by ESP32 with MAC " + __mac) + else: + __mac = '' + log_event("WARN: Update called without known headers.") + __ver = request.args.get('ver', default=None) + if __dev and __mac and __ver: + # If we know this device already + device = Device.query.filter_by(mac=__mac).first() + if device: + device.last_seen = datetime.utcnow() + else: + device = Device(mac=__mac) + # add the new device to the database + db.session.add(device) + db.session.commit() + + log_event("INFO: Device type: " + __dev + "Ver: " + __ver) + __dev = __dev.lower() + # platform = Platform.query.join(Device).filter(Device.mac).first() + platform = Platform.query.filter_by(name = __dev).first() + if platform: # device is known for a platform + device_whitelisted = Platform.query.filter_by(devices = __mac).first() + # device_whitelisted = True + if device_whitelisted: + if __mac in platforms[__dev]['whitelist']: + if not platforms[__dev]['version']: + log_event("ERROR: No update available.") + return 'No update available.', 400 + if version.parse(__ver) < version.parse(platforms[__dev]['version']): + if os.path.isfile(app.config['UPLOAD_FOLDER'] + '/' + platforms[__dev]['file']): + platforms[__dev]['downloads'] += 1 + save_yaml(platforms) + return send_from_directory(directory=app.config['UPLOAD_FOLDER'], filename=platforms[__dev]['file'], + as_attachment=True, mimetype='application/octet-stream', + attachment_filename=platforms[__dev]['file']) + else: + log_event("INFO: No update needed.") + return 'No update needed.', 304 + else: + log_event("ERROR: Device not whitelisted.") + return 'Error: Device not whitelisted.', 400 + else: + log_event("ERROR: Unknown platform.") + return 'Error: Unknown platform.', 400 + else: + log_event("ERROR: Unkown platform") + return 'Error: Unkown platform', 500 + log_event("ERROR: Invalid parameters.") + return 'Error: Invalid parameters.', 400 \ No newline at end of file diff --git a/server/models.py b/server/models.py index c5e0c4e..eee3dec 100644 --- a/server/models.py +++ b/server/models.py @@ -10,6 +10,7 @@ class User(UserMixin, db.Model): email = db.Column(db.String(100), unique=True) password = db.Column(db.String(100)) name = db.Column(db.String(1000)) + admin = db.Column(db.Boolean()) class Platform(db.Model): id = db.Column(db.Integer, primary_key=True) # primary keys are required by SQLAlchemy @@ -21,9 +22,10 @@ class Platform(db.Model): class Device(db.Model): id = db.Column(db.Integer, primary_key=True) # primary keys are required by SQLAlchemy - type = db.Column(db.Integer, db.ForeignKey('platform.id'), nullable=False) + type = db.Column(db.Integer, db.ForeignKey('platform.id')) version = db.Column(db.String(100)) # last known version of the device. - IP = db.Column(db.String(100)) - first_seen = db.Column(db.DateTime,server_default=func.utcnow()) - last_seen = db.Column(db.DateTime,server_default=func.utcnow()) + IP = db.Column(db.String(100)) + first_seen = db.Column(db.DateTime,server_default=func.now()) + last_seen = db.Column(db.DateTime,server_default=func.now()) notes = db.Column(db.String(1000)) # add any notes about the platform + mac = db.Column(db.String(17),nullable = False) # aa:bb:cc:dd:de:ff diff --git a/server/templates/layout.html b/server/templates/layout.html index 2cc0b4e..f80782e 100644 --- a/server/templates/layout.html +++ b/server/templates/layout.html @@ -28,6 +28,11 @@ {% endif %} {% if current_user.is_authenticated %} + + Status + + {% endif %} + {% if current_user.is_authenticated %} Create platform diff --git a/server/templates/status.html b/server/templates/status.html index 83fcca8..3ebb27a 100644 --- a/server/templates/status.html +++ b/server/templates/status.html @@ -1,61 +1,115 @@ + + {% extends "layout.html" %} -{% block body %} + +{% block content %} +

+ Status +

+

+ Easy system to manage OTA updates for Espressif-based devices +

+{% with platforms = platforms %} + {% if platforms %} + {% for platform in platforms %} + {{ platform.name }}: {{platform.notes}} + {% if platform.devices %} +
- K. Stobbe / M. van Noord + K. Stobbe / M. van Noord
MAC Address First seen Last seen
{{ format_mac(key.upper()) }} {{ '✔️' if value['platform'] else '❓' }}{{ format_mac(key.upper()) }} {{ moment(value['first_seen_dt']).format('DD-MM-YYYY HH:mm') }} {{ moment(value['last_seen_dt']).format('DD-MM-YYYY HH:mm') }} ({{ moment(value['last_seen_dt']).fromNow()}})
+ + + + + + + + + + + + {% for device in platform.devices %} + + + + + + + + + {% endfor %} + +
MACVersionIPFirst seenLast seenNotes
{{ format_mac(device.mac.upper()) }}{{device.version}}{{device.IP}}{{device.first_seen}}{{device.last_seen}}
+ {% else %} +
+ No devices for platform {{ platform.name }} +
+ {% endif %} + {% endfor %} + {% else %} +
  • No platforms created. + {% endif %} +{% endwith %} + +{% endblock %} + + \ No newline at end of file From 0ca60396420fb11f3e2c9751ba672eede38300bd Mon Sep 17 00:00:00 2001 From: Marco Date: Fri, 31 Dec 2021 15:21:50 +0100 Subject: [PATCH 30/73] allow sending uploaded data to client, add filename and downloadcounter --- server/__init__.py | 1 + server/auth.py | 5 +++++ server/main.py | 41 ++++++++++++++++++++--------------------- server/models.py | 2 ++ 4 files changed, 28 insertions(+), 21 deletions(-) diff --git a/server/__init__.py b/server/__init__.py index c5c0f59..61c49da 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -13,6 +13,7 @@ def create_app(): app.config['SECRET_KEY'] = 'SECRET_KEY_HERE' app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///bin/db.sqlite' app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = True + app.config['UPLOAD_FOLDER'] = './bin' db.init_app(app) login_manager = LoginManager() diff --git a/server/auth.py b/server/auth.py index c2d049c..cc4aad9 100644 --- a/server/auth.py +++ b/server/auth.py @@ -26,6 +26,11 @@ def login_post(): flash('Please check your login details and try again.') return redirect(url_for('auth.login')) # if user doesn't exist or password is wrong, reload the page + # if not user.admin: + # flash('Only admins are allowed to log in') + # return redirect(url_for('auth.login')) + + # if the above check passes, then we know the user has the right credentials login_user(user, remember=remember) return redirect(url_for('main.profile')) diff --git a/server/main.py b/server/main.py index 530e6e3..1a18942 100644 --- a/server/main.py +++ b/server/main.py @@ -1,13 +1,14 @@ # main.py -from flask import Blueprint, render_template, redirect, url_for, request, flash +from flask import Blueprint, render_template, redirect, url_for, request, flash, send_from_directory, current_app from flask_login import login_required, current_user from .models import User, Platform, Device from . import db from datetime import datetime import time import re - +from packaging import version # for semver support +import os main = Blueprint('main', __name__) @@ -71,9 +72,7 @@ def status(): @main.route('/update', methods=['GET']) def update(): - __error = 400 - __dev = request.args.get('dev', default=None) # get requested device version if 'X_ESP8266_STA_MAC' in request.headers: __mac = request.headers['X_ESP8266_STA_MAC'] @@ -86,46 +85,46 @@ def update(): else: __mac = '' log_event("WARN: Update called without known headers.") - __ver = request.args.get('ver', default=None) + __ver = version.parse(request.args.get('ver', default=None)) # parse version, brings a bit extra safety if __dev and __mac and __ver: # If we know this device already device = Device.query.filter_by(mac=__mac).first() if device: device.last_seen = datetime.utcnow() + device.version = str(__ver) else: - device = Device(mac=__mac) + device = Device(mac=__mac, version = str(__ver)) # add the new device to the database db.session.add(device) db.session.commit() - log_event("INFO: Device type: " + __dev + "Ver: " + __ver) + log_event("INFO: Device type: " + __dev + " Ver: " + str(__ver)) __dev = __dev.lower() # platform = Platform.query.join(Device).filter(Device.mac).first() platform = Platform.query.filter_by(name = __dev).first() if platform: # device is known for a platform - device_whitelisted = Platform.query.filter_by(devices = __mac).first() + device_whitelisted = Platform.query.join(Device).filter(Device.mac== __mac).first() # device_whitelisted = True if device_whitelisted: - if __mac in platforms[__dev]['whitelist']: - if not platforms[__dev]['version']: + if not platform.version: # when no file has been uploaded log_event("ERROR: No update available.") return 'No update available.', 400 - if version.parse(__ver) < version.parse(platforms[__dev]['version']): - if os.path.isfile(app.config['UPLOAD_FOLDER'] + '/' + platforms[__dev]['file']): - platforms[__dev]['downloads'] += 1 - save_yaml(platforms) - return send_from_directory(directory=app.config['UPLOAD_FOLDER'], filename=platforms[__dev]['file'], + if __ver < version.parse(platform.version): + if os.path.isfile(current_app.config['UPLOAD_FOLDER'] + '/' + platform.file): + platform.downloads += 1 + db.session.commit() + return send_from_directory(directory=current_app.config['UPLOAD_FOLDER'], filename=platform.file, as_attachment=True, mimetype='application/octet-stream', - attachment_filename=platforms[__dev]['file']) + attachment_filename=platform.file) else: log_event("INFO: No update needed.") return 'No update needed.', 304 - else: - log_event("ERROR: Device not whitelisted.") - return 'Error: Device not whitelisted.', 400 else: - log_event("ERROR: Unknown platform.") - return 'Error: Unknown platform.', 400 + log_event("ERROR: Device not whitelisted.") + # Temporarily whitelist immediately! TODO: REMOVE THIS IN PROD! + device.type = platform.id + db.session.commit() + return 'Error: Device not whitelisted.', 400 else: log_event("ERROR: Unkown platform") return 'Error: Unkown platform', 500 diff --git a/server/models.py b/server/models.py index eee3dec..a681bff 100644 --- a/server/models.py +++ b/server/models.py @@ -19,6 +19,8 @@ class Platform(db.Model): uploaded = db.Column(db.DateTime) notes = db.Column(db.String(1000)) # add any notes about the platform devices = db.relationship('Device', backref='platform', lazy=True) + file = db.Column(db.String(100)) + downloads = db.Column(db.Integer, default = 0) class Device(db.Model): id = db.Column(db.Integer, primary_key=True) # primary keys are required by SQLAlchemy From 86c796bf765d8189949274c06c94807971c62598 Mon Sep 17 00:00:00 2001 From: Marco Date: Fri, 31 Dec 2021 15:36:27 +0100 Subject: [PATCH 31/73] formatting --- server/__init__.py | 2 + server/main.py | 146 ++++++++++++++++++++--------------- server/templates/status.html | 2 + 3 files changed, 89 insertions(+), 61 deletions(-) diff --git a/server/__init__.py b/server/__init__.py index 61c49da..5f2fe75 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -3,6 +3,7 @@ from flask import Flask from flask_sqlalchemy import SQLAlchemy from flask_login import LoginManager +from flask_moment import Moment # init SQLAlchemy so we can use it later in our models db = SQLAlchemy() @@ -15,6 +16,7 @@ def create_app(): app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = True app.config['UPLOAD_FOLDER'] = './bin' db.init_app(app) + moment = Moment(app) login_manager = LoginManager() login_manager.login_view = 'auth.login' diff --git a/server/main.py b/server/main.py index 1a18942..556d135 100644 --- a/server/main.py +++ b/server/main.py @@ -1,91 +1,107 @@ # main.py -from flask import Blueprint, render_template, redirect, url_for, request, flash, send_from_directory, current_app +from flask import ( + Blueprint, + render_template, + redirect, + url_for, + request, + flash, + send_from_directory, + current_app, +) from flask_login import login_required, current_user from .models import User, Platform, Device from . import db from datetime import datetime import time import re -from packaging import version # for semver support +from packaging import version # for semver support import os -main = Blueprint('main', __name__) +main = Blueprint("main", __name__) + def log_event(msg): - st = datetime.fromtimestamp(time.time()).strftime('%Y-%m-%d %H:%M:%S') - print(st + ' ' + msg) + st = datetime.fromtimestamp(time.time()).strftime("%Y-%m-%d %H:%M:%S") + print(st + " " + msg) + -@main.route('/') +@main.route("/") def index(): - db.create_all() # a bit dirty, but create all tables on load of the main. Doesn't re-create any already existing tables - return render_template('index.html') + db.create_all() # a bit dirty, but create all tables on load of the main. Doesn't re-create any already existing tables + return render_template("index.html") + -@main.route('/profile') +@main.route("/profile") @login_required def profile(): - return render_template('profile.html', name=current_user.name) + return render_template("profile.html", name=current_user.name) - -@main.route('/create') +@main.route("/create") def create(): - return render_template('create.html') + return render_template("create.html") -@main.route('/create', methods=['POST']) +@main.route("/create", methods=["POST"]) @login_required def create_post(): - - platform_name = request.form.get('name') + + platform_name = request.form.get("name") if not platform_name: flash("No platform name entered") - return redirect(url_for('main.create')) - - platform = Platform.query.filter_by(name=platform_name).first() # if this returns a user, then the email already exists in database + return redirect(url_for("main.create")) + + platform = Platform.query.filter_by( + name=platform_name + ).first() # if this returns a user, then the email already exists in database if platform: - flash('Platform already exists') - return redirect(url_for('main.create')) + flash("Platform already exists") + return redirect(url_for("main.create")) - notes = request.form.get('notes') - # Create a new platform using this information - new_platform = Platform(name = platform_name, notes = notes) + notes = request.form.get("notes") + # Create a new platform using this information + new_platform = Platform(name=platform_name, notes=notes) # add the new user to the database db.session.add(new_platform) db.session.commit() - return redirect(url_for('main.index')) + return redirect(url_for("main.index")) @main.context_processor def utility_processor(): def format_mac(mac): - return ':'.join(mac[i:i+2] for i in range(0,12,2)) + return ":".join(mac[i : i + 2] for i in range(0, 12, 2)) + return dict(format_mac=format_mac) -@main.route('/status') + +@main.route("/status") @login_required def status(): platforms = Platform.query.all() - return render_template('status.html', platforms=platforms) + return render_template("status.html", platforms=platforms) - -@main.route('/update', methods=['GET']) +@main.route("/update", methods=["GET"]) def update(): __error = 400 - __dev = request.args.get('dev', default=None) # get requested device version - if 'X_ESP8266_STA_MAC' in request.headers: - __mac = request.headers['X_ESP8266_STA_MAC'] - __mac = str(re.sub(r'[^0-9A-fa-f]+', '', __mac.lower())) + __dev = request.args.get("dev", default=None) # get requested device version + if "X_ESP8266_STA_MAC" in request.headers: + __mac = request.headers["X_ESP8266_STA_MAC"] + __mac = str(re.sub(r"[^0-9A-fa-f]+", "", __mac.lower())) log_event("INFO: Update called by ESP8266 with MAC " + __mac) - elif 'x_ESP32_STA_MAC' in request.headers: - __mac = request.headers['x_ESP32_STA_MAC'] - __mac = str(re.sub(r'[^0-9A-fa-f]+', '', __mac.lower())) + elif "x_ESP32_STA_MAC" in request.headers: + __mac = request.headers["x_ESP32_STA_MAC"] + __mac = str(re.sub(r"[^0-9A-fa-f]+", "", __mac.lower())) log_event("INFO: Update called by ESP32 with MAC " + __mac) else: - __mac = '' + __mac = "" log_event("WARN: Update called without known headers.") - __ver = version.parse(request.args.get('ver', default=None)) # parse version, brings a bit extra safety + __ver = version.parse( + request.args.get("ver", default=None) + ) # parse version, brings a bit extra safety if __dev and __mac and __ver: # If we know this device already device = Device.query.filter_by(mac=__mac).first() @@ -93,40 +109,48 @@ def update(): device.last_seen = datetime.utcnow() device.version = str(__ver) else: - device = Device(mac=__mac, version = str(__ver)) + device = Device(mac=__mac, version=str(__ver)) # add the new device to the database db.session.add(device) db.session.commit() - + log_event("INFO: Device type: " + __dev + " Ver: " + str(__ver)) __dev = __dev.lower() # platform = Platform.query.join(Device).filter(Device.mac).first() - platform = Platform.query.filter_by(name = __dev).first() - if platform: # device is known for a platform - device_whitelisted = Platform.query.join(Device).filter(Device.mac== __mac).first() + platform = Platform.query.filter_by(name=__dev).first() + if platform: # device is known for a platform + device_whitelisted = ( + Platform.query.join(Device).filter(Device.mac == __mac).first() + ) # device_whitelisted = True - if device_whitelisted: - if not platform.version: # when no file has been uploaded - log_event("ERROR: No update available.") - return 'No update available.', 400 - if __ver < version.parse(platform.version): - if os.path.isfile(current_app.config['UPLOAD_FOLDER'] + '/' + platform.file): - platform.downloads += 1 - db.session.commit() - return send_from_directory(directory=current_app.config['UPLOAD_FOLDER'], filename=platform.file, - as_attachment=True, mimetype='application/octet-stream', - attachment_filename=platform.file) - else: - log_event("INFO: No update needed.") - return 'No update needed.', 304 + if device_whitelisted: + if not platform.version: # when no file has been uploaded + log_event("ERROR: No update available.") + return "No update available.", 400 + if __ver < version.parse(platform.version): + if os.path.isfile( + current_app.config["UPLOAD_FOLDER"] + "/" + platform.file + ): + platform.downloads += 1 + db.session.commit() + return send_from_directory( + directory=current_app.config["UPLOAD_FOLDER"], + filename=platform.file, + as_attachment=True, + mimetype="application/octet-stream", + attachment_filename=platform.file, + ) + else: + log_event("INFO: No update needed.") + return "No update needed.", 304 else: log_event("ERROR: Device not whitelisted.") # Temporarily whitelist immediately! TODO: REMOVE THIS IN PROD! device.type = platform.id db.session.commit() - return 'Error: Device not whitelisted.', 400 + return "Error: Device not whitelisted.", 400 else: log_event("ERROR: Unkown platform") - return 'Error: Unkown platform', 500 + return "Error: Unkown platform", 500 log_event("ERROR: Invalid parameters.") - return 'Error: Invalid parameters.', 400 \ No newline at end of file + return "Error: Invalid parameters.", 400 diff --git a/server/templates/status.html b/server/templates/status.html index 3ebb27a..d2af8e0 100644 --- a/server/templates/status.html +++ b/server/templates/status.html @@ -31,6 +31,8 @@

    {{ format_mac(device.mac.upper()) }} {{device.version}} {{device.IP}} + {{device.first_seen}} {{device.last_seen}} From b654c1f8b5adfdc189f4eb63c75b424d9abbf581 Mon Sep 17 00:00:00 2001 From: Marco Date: Fri, 31 Dec 2021 16:18:44 +0100 Subject: [PATCH 32/73] add upload ability --- server/__init__.py | 4 ++- server/main.py | 65 ++++++++++++++++++++++++++++++++++++ server/templates/create.html | 2 +- server/templates/layout.html | 5 +++ server/templates/upload.html | 33 ++++++++++++------ 5 files changed, 97 insertions(+), 12 deletions(-) diff --git a/server/__init__.py b/server/__init__.py index 5f2fe75..58b53aa 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -14,7 +14,9 @@ def create_app(): app.config['SECRET_KEY'] = 'SECRET_KEY_HERE' app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///bin/db.sqlite' app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = True - app.config['UPLOAD_FOLDER'] = './bin' + app.config['UPLOAD_FOLDER'] = 'bin' # where to store the uploaded firmware-files + app.config['ALLOWED_EXTENSIONS'] = set(['bin']) # set the file-extensions that users are allowed to upload here + app.config['DELETE_OLD_FILES'] = True # Do we delete old binaries after a new one has been uploaded db.init_app(app) moment = Moment(app) diff --git a/server/main.py b/server/main.py index 556d135..01e4992 100644 --- a/server/main.py +++ b/server/main.py @@ -21,6 +21,10 @@ main = Blueprint("main", __name__) +# Returns true if the extension of `filename` is allowed +def allowed_ext(filename): + return '.' in filename and \ + filename.rsplit('.', 1)[1].lower() in current_app.config["ALLOWED_EXTENSIONS"] def log_event(msg): st = datetime.fromtimestamp(time.time()).strftime("%Y-%m-%d %H:%M:%S") @@ -40,6 +44,7 @@ def profile(): @main.route("/create") +@login_required def create(): return render_template("create.html") @@ -154,3 +159,63 @@ def update(): return "Error: Unkown platform", 500 log_event("ERROR: Invalid parameters.") return "Error: Invalid parameters.", 400 + + +@main.route("/upload") +@login_required +def upload(): + return render_template("upload.html") + + +@main.route("/upload", methods=["POST"]) +@login_required +def upload_post(): + if 'file' not in request.files: + flash('Error: No file selected.') + return redirect(request.url) + file = request.files['file'] + if file.filename == '' or not allowed_ext(file.filename): + flash('Error: File upload error or wrong extension. Make sure you upload a file with the extension(s): {}'.format(str(current_app.config["ALLOWED_EXTENSIONS"]))) + return redirect(request.url) + if file and allowed_ext(file.filename): + data = file.read() + platforms = Platform.query.all() + # for every platform that we have, we search if this platform is named in the binary and try to extract a version-number + for platform in platforms: + m = re.search(b"update\?dev=" + platform.name.encode('UTF-8') + b"&ver=(v\d+\.\d+\.\d+)\x00", data, re.IGNORECASE) + if m: # platform found! + __ver = m.groups()[0][1:].decode('utf-8') + # check if the uploaded file is an update to the version that we have in the database + if (platform.version is None) or (platform.version and version.parse(platform.version ) < version.parse(__ver)): + old_file = platform.file + filename = platform.name + '_' + __ver.replace('.', '_') + '.bin' + platform.version = __ver + platform.downloads = 0 # reset download-counter + platform.file = filename.lower() + platform.uploaded = datetime.utcnow() + file.seek(0) + file.save(os.path.join(os.path.dirname(__file__), current_app.config['UPLOAD_FOLDER'], filename)) + file.close() + db.session.commit() + # Only delete old file after db is updated; so the old file will not be deleted + if old_file and current_app.config['DELETE_OLD_FILES']: + try: + os.remove(os.path.join(current_app.config['UPLOAD_FOLDER'], old_file)) + except: + flash('Error: Removing old file failed.') + flash('Success: File uploaded for platform {} with version {}.'.format(platform.name, __ver)) + return redirect(url_for('main.status')) + else: + flash('Error: Version must increase. File not uploaded.') + return redirect(request.url) + m = re.search(b"update\?dev=" + platform.name.encode('UTF-8')+ b"&ver=$", data, re.IGNORECASE) + if m: # only a platform was found, meaning no version was found + flash('Error: No version found in file. File not uploaded.') + return redirect(request.url) + else: + flash('Error: No known platform name found in file. File not uploaded.') + return redirect(request.url) + else: + flash('Error: File type not allowed.') + return redirect(request.url) + diff --git a/server/templates/create.html b/server/templates/create.html index cc42773..c44ee4a 100644 --- a/server/templates/create.html +++ b/server/templates/create.html @@ -26,7 +26,7 @@

    Add platform

    - + diff --git a/server/templates/layout.html b/server/templates/layout.html index f80782e..692f12d 100644 --- a/server/templates/layout.html +++ b/server/templates/layout.html @@ -37,6 +37,11 @@ Create platform {% endif %} + {% if current_user.is_authenticated %} + + Upload file + + {% endif %} {% if not current_user.is_authenticated %} Login diff --git a/server/templates/upload.html b/server/templates/upload.html index f08597d..530f080 100644 --- a/server/templates/upload.html +++ b/server/templates/upload.html @@ -1,12 +1,25 @@ + + {% extends "layout.html" %} -{% block body %} -

    Upload Platform Image

    -
    -
    -
    Binary file upload: -
    -
    -
    -
    -{% endblock %} +{% block content %} + +

    Upload Platform Image

    +

    Upload a new binary. The version and platform will be automatically extracted

    + {% with messages = get_flashed_messages() %} + {% if messages %} +
    + {{ messages[0] }} +
    + {% endif %} + {% endwith %} +
    +
    +
    + +
    +
    + +
    + +{% endblock %} \ No newline at end of file From 9280fe064630997628e332e417091154ad18b4b0 Mon Sep 17 00:00:00 2001 From: Marco Date: Fri, 31 Dec 2021 17:01:20 +0100 Subject: [PATCH 33/73] add page to whitelist devices --- server/main.py | 47 +++++++++++++- server/models.py | 2 + server/templates/layout.html | 5 ++ server/templates/status.html | 3 +- server/templates/whitelist.html | 107 ++++++++++++++++++-------------- 5 files changed, 114 insertions(+), 50 deletions(-) diff --git a/server/main.py b/server/main.py index 01e4992..0c7e462 100644 --- a/server/main.py +++ b/server/main.py @@ -113,8 +113,9 @@ def update(): if device: device.last_seen = datetime.utcnow() device.version = str(__ver) + device.requested_platform = __dev else: - device = Device(mac=__mac, version=str(__ver)) + device = Device(mac=__mac, version=str(__ver), requested_platform=__dev) # add the new device to the database db.session.add(device) db.session.commit() @@ -219,3 +220,47 @@ def upload_post(): flash('Error: File type not allowed.') return redirect(request.url) + +@main.route("/whitelist") +@login_required +def whitelist(): + devices = Device.query.filter_by(type=None) + platforms = Platform.query.all() + return render_template("whitelist.html", devices=devices, platforms=platforms) + +@main.route('/whitelist', methods=['POST']) +@login_required +def whitelist_post(): + devices = Device.query.filter_by(type=None) + platforms = Platform.query.all() + if 'Add' in request.form['action']: + # Ensure valid data. + if request.form['device'] and request.form['device'] != '--' and request.form['macaddr']: + # Remove all unwanted characters. + __mac = str(re.sub(r'[^0-9A-fa-f]+', '', request.form['macaddr']).lower()) + # Check length after clean-up makes up a full address. + if len(__mac) == 12: + # Check that address is not already on a whitelist. + known_device = Device.query.filter_by(mac=__mac).first() + if not known_device: + flash('Error: Unknown device. Let the device connect to the OTA server before adding') + return render_template("whitelist.html", devices=devices, platforms=platforms) + if known_device.type: + flash('Error: Address already on a whitelist.') + return render_template("whitelist.html", devices=devices, platforms=platforms) + # All looks good - add to whitelist. + known_platform = Platform.query.filter_by(name=request.form['device']).first() + if known_device and known_platform: + known_device.type = known_platform.id + known_device.notes = request.form.get('notes') + db.session.commit() + flash('Success: Address added.') + else: + flash('Error: Platform unkown') + else: + flash('Error: MAC address malformed.') + else: + flash('Error: No data entered.') + else: + flash('Error: Unknown action.') + return redirect(url_for('main.whitelist')) diff --git a/server/models.py b/server/models.py index a681bff..0972076 100644 --- a/server/models.py +++ b/server/models.py @@ -31,3 +31,5 @@ class Device(db.Model): last_seen = db.Column(db.DateTime,server_default=func.now()) notes = db.Column(db.String(1000)) # add any notes about the platform mac = db.Column(db.String(17),nullable = False) # aa:bb:cc:dd:de:ff + requested_platform = db.Column(db.String(100)) # the name of the platform that the device thinks it is + diff --git a/server/templates/layout.html b/server/templates/layout.html index 692f12d..6af77c0 100644 --- a/server/templates/layout.html +++ b/server/templates/layout.html @@ -33,6 +33,11 @@
    {% endif %} {% if current_user.is_authenticated %} + + Whitelist + + {% endif %} + {% if current_user.is_authenticated %} Create platform diff --git a/server/templates/status.html b/server/templates/status.html index d2af8e0..ae797f6 100644 --- a/server/templates/status.html +++ b/server/templates/status.html @@ -12,7 +12,7 @@

    {% with platforms = platforms %} {% if platforms %} {% for platform in platforms %} - {{ platform.name }}: {{platform.notes}} + Platform: {{ platform.name }} - {{platform.notes}} {% if platform.devices %} @@ -35,6 +35,7 @@

    --> + {% endfor %} diff --git a/server/templates/whitelist.html b/server/templates/whitelist.html index 725b77c..a101deb 100644 --- a/server/templates/whitelist.html +++ b/server/templates/whitelist.html @@ -1,68 +1,79 @@ + + {% extends "layout.html" %} -{% block body %} -

    Manage Whitelists

    - + +{% block content %} +

    + Whitelist +

    +

    + Add new devices to the whitelist +

    +
    + {% with messages = get_flashed_messages() %} + {% if messages %} +
    + {{ messages[0] }} +
    + {% endif %} + {% endwith %} +
    MAC Address:
    +
    Notes: +
    Platform:
    - -
    {{ moment(device.last_seen).format('DD-MM-YYYY HH:mm') }} ({{ moment(device.last_seen).fromNow()}}){{device.first_seen}} {{device.last_seen}}{{device.notes}}
    - - - - - - {% for key, value in platforms.items(): %} - {% if value['whitelist']: %} - {% for mac in value['whitelist']: %} - - - - - - {% endfor %} - {% endif %} - {% endfor %} -
    PlatformMAC Address
    {{ key.title() }}{{ format_mac(mac.upper()) }} -
    - - - -
    -
    - -

    - + +{% with devices = devices %} +{% if devices %} +
    + - - + + + + - - {% for key, value in known_macs.items(): %} - {% if value['first_seen']: %} - - - - - - - {% endif %} + + + {% for device in devices %} + + + + + + + + + + + {% endfor %} -
    MAC AddressMACVersion + Expected Platform + IP First seen Last seen
    {{ '✔️' if value['platform'] else '❓' }}{{ format_mac(key.upper()) }} {{ moment(value['first_seen_dt']).format('DD-MM-YYYY HH:mm') }}{{ moment(value['last_seen_dt']).format('DD-MM-YYYY HH:mm') }} ({{ moment(value['last_seen_dt']).fromNow()}})
    {{ format_mac(device.mac.upper()) }}{{device.version}}{{device.requested_platform}}{{device.IP}}{{device.first_seen}}{{device.last_seen}}
    + + -{% endblock %} +{% else %} +
  • No new devices. +{% endif %} +{% endwith %} +{% endblock %} \ No newline at end of file From a654beb443a327a712ab4ab6e01958dedab4148a Mon Sep 17 00:00:00 2001 From: Marco Date: Fri, 31 Dec 2021 17:12:24 +0100 Subject: [PATCH 34/73] more meaningful stuff in status-screen --- server/templates/status.html | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/server/templates/status.html b/server/templates/status.html index ae797f6..42b0ec5 100644 --- a/server/templates/status.html +++ b/server/templates/status.html @@ -12,8 +12,13 @@

    {% with platforms = platforms %} {% if platforms %} {% for platform in platforms %} - Platform: {{ platform.name }} - {{platform.notes}} +
    + Platform: {{ platform.name }} - {{platform.notes}}
    + Downloads: {{platform.downloads}}
    + Latest firmware: {{platform.version}}
    {% if platform.devices %} +
    +
    @@ -41,6 +46,7 @@

    {% endfor %}

    +
    {% else %}
    No devices for platform {{ platform.name }} From 7da94c01a384f0f8b618467b8defc47b6ff7d2c6 Mon Sep 17 00:00:00 2001 From: Marco Date: Fri, 31 Dec 2021 21:15:07 +0100 Subject: [PATCH 35/73] update Bulma, make stuff prettier --- server/templates/layout.html | 2 +- server/templates/status.html | 96 +++++++++++++++++---------------- server/templates/whitelist.html | 2 +- 3 files changed, 51 insertions(+), 49 deletions(-) diff --git a/server/templates/layout.html b/server/templates/layout.html index 6af77c0..09b3cff 100644 --- a/server/templates/layout.html +++ b/server/templates/layout.html @@ -7,7 +7,7 @@ ESP update server - + diff --git a/server/templates/status.html b/server/templates/status.html index 42b0ec5..ab0ffe6 100644 --- a/server/templates/status.html +++ b/server/templates/status.html @@ -10,55 +10,57 @@

    Easy system to manage OTA updates for Espressif-based devices

    {% with platforms = platforms %} - {% if platforms %} - {% for platform in platforms %} -
    - Platform: {{ platform.name }} - {{platform.notes}}
    - Downloads: {{platform.downloads}}
    - Latest firmware: {{platform.version}}
    - {% if platform.devices %} -
    -
    - - - - - - - - - - - - - {% for device in platform.devices %} - - - - - - - - - - - {% endfor %} - -
    MACVersionIPFirst seenLast seenNotes
    {{ format_mac(device.mac.upper()) }}{{device.version}}{{device.IP}}{{device.first_seen}}{{device.last_seen}}{{device.notes}}
    -
    - {% else %} -
    - No devices for platform {{ platform.name }} -
    - {% endif %} - {% endfor %} - {% else %} -
  • No platforms created. - {% endif %} -{% endwith %} + {{device.first_seen}} + {{device.last_seen}} + {{device.notes}} + + + {% endfor %} + + +
  • +{% else %} +
    + No devices for platform {{ platform.name }} +
    +{% endif %} + +{% endfor %} +{% else %} +
  • No platforms created. + {% endif %} + {% endwith %} -{% endblock %} + {% endblock %} + {{device.first_seen}} + {{device.last_seen}} + + + + + + + + + {% endfor %} - {% else %} -
  • No platforms created. - {% endif %} - - {% endblock %} --> \ No newline at end of file + + + + {% else %} +
  • No new devices. + {% endif %} + + + {% endwith %} + {% endblock %} From 598c8a3074ec49bbb370d154caf81f42b21317bc Mon Sep 17 00:00:00 2001 From: Marco Date: Sat, 1 Jan 2022 21:35:27 +0100 Subject: [PATCH 37/73] Finished merging the status and whitelist page Allow editing notes --- server/main.py | 43 +++---- server/static/note-edit.svg | 1 + server/templates/layout.html | 5 - server/templates/status.html | 152 ----------------------- server/templates/whitelist.html | 209 ++++++++++++++++++++++++-------- 5 files changed, 184 insertions(+), 226 deletions(-) create mode 100644 server/static/note-edit.svg delete mode 100644 server/templates/status.html diff --git a/server/main.py b/server/main.py index 9770b6c..01bf363 100644 --- a/server/main.py +++ b/server/main.py @@ -82,18 +82,19 @@ def format_mac(mac): return dict(format_mac=format_mac) -@main.route("/status") +@main.route("/whitelist") @login_required -def status(): +def whitelist(): platforms = Platform.query.all() unbound_devices = Device.query.filter_by(type=None) - return render_template("status.html", platforms=platforms,unbound_devices=unbound_devices) + return render_template("whitelist.html", platforms=platforms,unbound_devices=unbound_devices) -@main.route('/status', methods=['POST']) +@main.route('/whitelist', methods=['POST']) @login_required -def status_post(): +def whitelist_post(): platforms = Platform.query.all() unbound_devices = Device.query.filter_by(type=None) + # Delete platform binding if request.form.get('_method') and 'DELETE' in request.form.get('_method'): if request.form['_device']: device_id = request.form.get('_device',type=int) @@ -102,8 +103,16 @@ def status_post(): db.session.commit() flash("Deleted device from platform") - if request.form.get('_method') and 'ADD' in request.form.get('_method'): - devices = Device.query.filter_by(type=None) + # Edit notes + if request.form.get('_method') and 'NOTES' in request.form.get('_method'): + if request.form['_device']: + device_id = request.form.get('_device',type=int) + device = Device.query.filter_by(id=device_id).first() + device.notes = request.form.get('_notes') # update the note + db.session.commit() + flash("Updated note") + + elif request.form.get('action') and 'ADD' in request.form.get('action'): # Ensure valid data. if request.form['device'] and request.form['device'] != '--' and request.form['macaddr']: # Remove all unwanted characters. @@ -114,17 +123,17 @@ def status_post(): known_device = Device.query.filter_by(mac=__mac).first() if not known_device: flash('Error: Unknown device. Let the device connect to the OTA server before adding') - return render_template("status.html", platforms=platforms, unbound_devices=unbound_devices) + return render_template("whitelist.html", platforms=platforms, unbound_devices=unbound_devices) if known_device.type: flash('Error: Address already on a whitelist.') - return render_template("status.html", platforms=platforms, unbound_devices=unbound_devices) + return render_template("whitelist.html", platforms=platforms, unbound_devices=unbound_devices) # All looks good - add to whitelist. known_platform = Platform.query.filter_by(name=request.form['device']).first() if known_device and known_platform: known_device.type = known_platform.id known_device.notes = request.form.get('notes') db.session.commit() - flash('Success: Address added.') + flash('Success: {} added to platform {}'.format(known_device.mac, known_platform.name)) else: flash('Error: Platform unkown') else: @@ -133,8 +142,7 @@ def status_post(): flash('Error: No data entered.') else: flash('Error: Unknown action.') - - return render_template("status.html", platforms=platforms, unbound_devices=unbound_devices) + return render_template("whitelist.html", platforms=platforms, unbound_devices=unbound_devices) @main.route("/update", methods=["GET"]) def update(): @@ -154,7 +162,7 @@ def update(): __ver = version.parse( request.args.get("ver", default=None) ) # parse version, brings a bit extra safety - if __dev and __mac and __ver: + if __dev and __mac and __ver and len(__mac) == 12: # If we know this device already device = Device.query.filter_by(mac=__mac).first() if device: @@ -252,7 +260,7 @@ def upload_post(): except: flash('Error: Removing old file failed.') flash('Success: File uploaded for platform {} with version {}.'.format(platform.name, __ver)) - return redirect(url_for('main.status')) + return redirect(url_for('main.whitelist')) else: flash('Error: Version must increase. File not uploaded.') return redirect(request.url) @@ -267,10 +275,3 @@ def upload_post(): flash('Error: File type not allowed.') return redirect(request.url) - -@main.route("/whitelist") -@login_required -def whitelist(): - devices = Device.query.filter_by(type=None) - platforms = Platform.query.all() - return render_template("whitelist.html", devices=devices, platforms=platforms) diff --git a/server/static/note-edit.svg b/server/static/note-edit.svg new file mode 100644 index 0000000..f030ab5 --- /dev/null +++ b/server/static/note-edit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/server/templates/layout.html b/server/templates/layout.html index c6ead3e..dc7b5cb 100644 --- a/server/templates/layout.html +++ b/server/templates/layout.html @@ -31,11 +31,6 @@ {% endif %} {% if current_user.is_authenticated %} - - Status - - {% endif %} - {% if current_user.is_authenticated %} Whitelist diff --git a/server/templates/status.html b/server/templates/status.html deleted file mode 100644 index 8f992ed..0000000 --- a/server/templates/status.html +++ /dev/null @@ -1,152 +0,0 @@ - - -{% extends "layout.html" %} - -{% block content %} -

    - Status -

    -

    - Easy system to manage OTA updates for Espressif-based devices -

    -
    - {% with messages = get_flashed_messages() %} - {% if messages %} -
    - {{ messages[0] }} -
    - {% endif %} - {% endwith %} -
    -
    -
    MAC Address: -
    -
    Notes: -
    -
    Platform: -
    -
    -
    -
    -
    -{% with platforms = platforms %} -{% if platforms %} -{% for platform in platforms %} -
    - Platform: {{ platform.name }} - {{platform.notes}}
    - Downloads: {{platform.downloads}}
    - Latest firmware: {{platform.version}}
    - {% if platform.devices %} -
    - - - - - - - - - - - - - - {% for device in platform.devices %} - - - - - - - - - - - - {% endfor %} - -
    MACVersion - IPFirst seenLast seenNotesDelete?
    {{ format_mac(device.mac.upper()) }}{{device.version}}{{device.IP}}{{device.first_seen}}{{device.last_seen}}{{device.notes}} - -
    - - - -
    - -
    -
    -{% else %} -
    - No devices for platform {{ platform.name }} -
    -{% endif %} -
    -{% endfor %} -{% else %} -
  • No platforms created. - {% endif %} - {% endwith %} - {% with devices = unbound_devices %} - {% if devices %} -
    -

    New devices

    -

    These devices have been seen, but have not been whitelisted to a platform yet.

    - -
    - - - - - - - - - - - - - - {% for device in devices %} - - - - - - - - - - - - - {% endfor %} - -
    MACVersion - Expected Platform - IPFirst seenLast seenAdd
    {{ format_mac(device.mac.upper()) }}{{device.version}}{{device.requested_platform}}{{device.IP}}{{device.first_seen}}{{device.last_seen}} - - - - -
    - - {% else %} -
  • No new devices. - {% endif %} -
  • -
    - {% endwith %} - {% endblock %} diff --git a/server/templates/whitelist.html b/server/templates/whitelist.html index 483326a..7885e48 100644 --- a/server/templates/whitelist.html +++ b/server/templates/whitelist.html @@ -7,7 +7,7 @@

    Whitelist

    - Add new devices to the whitelist + Easy system to manage OTA updates for Espressif-based devices

    {% with messages = get_flashed_messages() %} @@ -17,63 +17,176 @@

    {% endif %} {% endwith %} -
    +
    +
    +

    Add device

    +

    Bind devices to a platform

    +
    +
    MAC Address: -
    -
    Notes: -
    +
    +
    Notes: +
    Platform: -
    - {% if platforms %} + {% if platforms %} {% for platform in platforms %} - + {% endfor %} - {% endif %} - -
    + {% endif %} + +
    +
    -{% with devices = devices %} -{% if devices %} - - - - - - - - - - - - - {% for device in devices %} - - - - - - - - - - - - {% endfor %} - -
    MACVersion - Expected Platform - IPFirst seenLast seen
    {{ format_mac(device.mac.upper()) }}{{device.version}}{{device.requested_platform}}{{device.IP}}{{device.first_seen}}{{device.last_seen}}
    + {{device.first_seen}} + {{device.last_seen}} + {{device.notes}} + + +
    + + + + +
    +
    + +
    + + + +
    +
    + + + {% endfor %} + + + + {% else %} +
    + No devices for platform {{ platform.name }} +
    + {% endif %} + + +{% endfor %} {% else %} -
  • No new devices. -{% endif %} -{% endwith %} +
  • No platforms created. + {% endif %} + {% endwith %} + + {% with devices = unbound_devices %} + {% if devices %} +
    +
    +

    New devices

    +

    These devices have been seen, but have not been whitelisted to a platform yet.

    +
    +
    +
    + + + + + + + + + + + + + + + {% for device in devices %} + + + + + + + + + + + + + + {% endfor %} + +
    MACVersion + Platform + IPFirst seenLast seenNotesAdd
    {{ format_mac(device.mac.upper()) }}{{device.version}}{{device.requested_platform}}{{device.IP}}{{device.first_seen}}{{device.last_seen}}{{device.notes}} + +
    + + + + +
    +
    + + + +
    -{% endblock %} \ No newline at end of file + {% else %} +
    +
  • No new devices. + {% endif %} +
  • +
    + {% endwith %} + {% endblock %} \ No newline at end of file From d3eac7cd331cd858b270055f5d3eed960c22f310 Mon Sep 17 00:00:00 2001 From: Marco Date: Sat, 1 Jan 2022 22:10:05 +0100 Subject: [PATCH 38/73] added footer, made content wider Sort devices by last-seen Localize the datetimes Add request IP --- server/__init__.py | 3 +- server/main.py | 9 +++-- server/templates/layout.html | 16 +++++--- server/templates/whitelist.html | 71 +++++++++++++++++---------------- 4 files changed, 54 insertions(+), 45 deletions(-) diff --git a/server/__init__.py b/server/__init__.py index 58b53aa..b496378 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -7,6 +7,7 @@ # init SQLAlchemy so we can use it later in our models db = SQLAlchemy() +moment = Moment() def create_app(): app = Flask(__name__) @@ -18,7 +19,7 @@ def create_app(): app.config['ALLOWED_EXTENSIONS'] = set(['bin']) # set the file-extensions that users are allowed to upload here app.config['DELETE_OLD_FILES'] = True # Do we delete old binaries after a new one has been uploaded db.init_app(app) - moment = Moment(app) + moment.init_app(app) login_manager = LoginManager() login_manager.login_view = 'auth.login' diff --git a/server/main.py b/server/main.py index 01bf363..b8f42cd 100644 --- a/server/main.py +++ b/server/main.py @@ -11,6 +11,7 @@ current_app, ) from flask_login import login_required, current_user +from sqlalchemy.sql.expression import desc from .models import User, Platform, Device from . import db from datetime import datetime @@ -86,14 +87,14 @@ def format_mac(mac): @login_required def whitelist(): platforms = Platform.query.all() - unbound_devices = Device.query.filter_by(type=None) + unbound_devices = Device.query.filter_by(type=None).order_by(desc(Device.last_seen)) return render_template("whitelist.html", platforms=platforms,unbound_devices=unbound_devices) @main.route('/whitelist', methods=['POST']) @login_required def whitelist_post(): platforms = Platform.query.all() - unbound_devices = Device.query.filter_by(type=None) + unbound_devices = Device.query.filter_by(type=None).order_by(desc(Device.last_seen)) # Delete platform binding if request.form.get('_method') and 'DELETE' in request.form.get('_method'): if request.form['_device']: @@ -169,15 +170,15 @@ def update(): device.last_seen = datetime.utcnow() device.version = str(__ver) device.requested_platform = __dev + device.IP = request.remote_addr else: - device = Device(mac=__mac, version=str(__ver), requested_platform=__dev) + device = Device(mac=__mac, version=str(__ver), requested_platform=__dev, IP=request.remote_addr) # add the new device to the database db.session.add(device) db.session.commit() log_event("INFO: Device type: " + __dev + " Ver: " + str(__ver)) __dev = __dev.lower() - # platform = Platform.query.join(Device).filter(Device.mac).first() platform = Platform.query.filter_by(name=__dev).first() if platform: # device is known for a platform device_whitelisted = ( diff --git a/server/templates/layout.html b/server/templates/layout.html index dc7b5cb..838185a 100644 --- a/server/templates/layout.html +++ b/server/templates/layout.html @@ -10,6 +10,7 @@ + {{ moment.include_moment() }} @@ -26,11 +27,6 @@ Home {% if current_user.is_authenticated %} - - Profile - - {% endif %} - {% if current_user.is_authenticated %} Whitelist @@ -65,12 +61,20 @@
    -
    +
    {% block content %} {% endblock %}
    +
    +
    +

    + ESP update server by M. van Noord, original by K. Stobbe. The source code is licensed + MIT and can be found on GitHub. +

    +
    +
    \ No newline at end of file diff --git a/server/templates/whitelist.html b/server/templates/whitelist.html index 7885e48..8e7f03f 100644 --- a/server/templates/whitelist.html +++ b/server/templates/whitelist.html @@ -22,24 +22,24 @@

    Add device

    Bind devices to a platform

    -
    -
    -
    MAC Address: -
    -
    Notes: -
    -
    Platform: -
    -
    -
    -
    + {% with platforms = platforms %} @@ -65,7 +65,8 @@

    Bind devices to a platform

    First seen Last seen Notes - + Edit + Delete @@ -74,22 +75,22 @@

    Bind devices to a platform

    {{ format_mac(device.mac.upper()) }} {{device.version}} {{device.IP}} - - {{device.first_seen}} - {{device.last_seen}} + {{ moment(device.first_seen).format('DD-MM-YYYY HH:mm') }} + {{ moment(device.last_seen).format('DD-MM-YYYY HH:mm') }} ({{ moment(device.last_seen).fromNow()}}) {{device.notes}} -
    + - +
    + +
    @@ -144,6 +145,7 @@

    These devices have been seen, but have not been whitelisted to a platform ye First seen Last seen Notes + Edit Add @@ -154,22 +156,23 @@

    These devices have been seen, but have not been whitelisted to a platform ye {{device.version}} {{device.requested_platform}} {{device.IP}} - - {{device.first_seen}} - {{device.last_seen}} + {{ moment(device.first_seen).format('DD-MM-YYYY HH:mm') }} + {{ moment(device.last_seen).format('DD-MM-YYYY HH:mm') }} ({{ moment(device.last_seen).fromNow()}}) + {{device.notes}} - + - + + + @@ -183,7 +186,7 @@

    These devices have been seen, but have not been whitelisted to a platform ye {% else %} - +
  • No new devices. {% endif %} From 8c4fd7392a9e5e0fd3aa5dd63ac3894c28499152 Mon Sep 17 00:00:00 2001 From: Marco Date: Sat, 1 Jan 2022 22:30:27 +0100 Subject: [PATCH 39/73] prettier layout --- server/templates/whitelist.html | 39 ++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/server/templates/whitelist.html b/server/templates/whitelist.html index 8e7f03f..4fc230d 100644 --- a/server/templates/whitelist.html +++ b/server/templates/whitelist.html @@ -19,8 +19,8 @@

    {% endwith %}
    -

    Add device

    -

    Bind devices to a platform

    +

    Add device

    +

    Bind devices to a platform

    @@ -47,7 +47,20 @@

    Bind devices to a platform

    {% for platform in platforms %}
    - Platform: {{ platform.name }} - {{platform.notes}}
    +

    {{ platform.name }}

    + Notes: + + {{platform.notes}} + + + + + + + + +
    Downloads: {{platform.downloads}}
    Latest firmware: {{platform.version}}
    @@ -84,9 +97,9 @@

    Bind devices to a platform

    - + src="{{ url_for('static', filename='note-edit.svg') }}"> @@ -95,9 +108,9 @@

    Bind devices to a platform

    - + src="{{ url_for('static', filename='trash-can-outline.svg') }}">
    @@ -127,8 +140,8 @@

    Bind devices to a platform

    {% if devices %}
    -

    New devices

    -

    These devices have been seen, but have not been whitelisted to a platform yet.

    +

    New devices

    +

    These devices have been seen, but have not been whitelisted to a platform yet.

    @@ -166,16 +179,16 @@

    These devices have been seen, but have not been whitelisted to a platform ye - + src="{{ url_for('static', filename='note-edit.svg') }}"> - + From 6aa4a872b45c755019cab964838f27c00fbcecf7 Mon Sep 17 00:00:00 2001 From: Marco Date: Sat, 1 Jan 2022 22:34:16 +0100 Subject: [PATCH 40/73] Update README.md add todo --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 4d0a09f..a3e55df 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,10 @@ void checkForUpdates(void) } ``` +## TODO +- [ ] Some way to allow users to become admin / usermanager +- [ ] Ability to delete platforms + ## Legal Project is under the [MIT License](LICENSE.md). From 2d5ecb2fb0a307f04d673c07ea4f85d5e6f06d07 Mon Sep 17 00:00:00 2001 From: Marco Date: Sun, 2 Jan 2022 10:53:45 +0100 Subject: [PATCH 41/73] update dockerfile init database on __init__ remove pyaml from requirements remove last remains of old server --- dockerfile | 11 +- requirements.txt | 1 - server/__init__.py | 23 +++ server/main.py | 3 +- server/server.py | 388 ----------------------------------- server/templates/create.html | 2 +- 6 files changed, 33 insertions(+), 395 deletions(-) delete mode 100644 server/server.py diff --git a/dockerfile b/dockerfile index 3bd49c5..93d156f 100644 --- a/dockerfile +++ b/dockerfile @@ -1,6 +1,11 @@ -FROM python:alpine3.7 +# syntax=docker/dockerfile:1 +FROM python:3.8-slim-buster COPY . /esp-update-server WORKDIR /esp-update-server -RUN pip install -r requirements.txt + +COPY requirements.txt requirements.txt +RUN pip3 install -r requirements.txt +ENV FLASK_APP=server +ENV FLASK_ENV=development EXPOSE 5000 -CMD python3 ./server.py \ No newline at end of file +CMD [ "python3", "-m" , "flask", "run", "--host=0.0.0.0"] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 7d6a4ed..7dfd253 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ flask==2.0.2 -pyYAML==5.1 packaging==19.0 Flask-HTTPAuth==4.2.0 flask-moment>=1.0.2 diff --git a/server/__init__.py b/server/__init__.py index b496378..abe85ba 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -1,13 +1,19 @@ # init.py +import os from flask import Flask from flask_sqlalchemy import SQLAlchemy from flask_login import LoginManager from flask_moment import Moment +from werkzeug.security import generate_password_hash # init SQLAlchemy so we can use it later in our models db = SQLAlchemy() moment = Moment() +import sentry_sdk +from sentry_sdk.integrations.flask import FlaskIntegration + + def create_app(): app = Flask(__name__) @@ -26,6 +32,23 @@ def create_app(): login_manager.init_app(app) from .models import User, Platform, Device + with app.app_context(): + db.create_all() # a bit dirty, but push the app context, so sqlalchemy knows about the context, and then create all tables + + # check if we need to add an Admin-user + ADMIN_EMAIL = os.environ.get('ADMIN_EMAIL') + ADMIN_PASSWORD = os.environ.get('ADMIN_PASS') + if ADMIN_EMAIL and ADMIN_PASSWORD: + user = User.query.filter_by(email=ADMIN_EMAIL).first() # if this returns a user, then the email already exists in database + if user: # if a user is found, we want to make it an admin + user.admin = True + else: + # create new user with the supplied data. Hash the password so plaintext version isn't saved. + new_user = User(email=ADMIN_EMAIL, name="Admin", password=generate_password_hash(ADMIN_PASSWORD, method='sha256')) + # add the new user to the database + db.session.add(new_user) + # store all changes + db.session.commit() @login_manager.user_loader def load_user(user_id): diff --git a/server/main.py b/server/main.py index b8f42cd..b9a65aa 100644 --- a/server/main.py +++ b/server/main.py @@ -34,7 +34,6 @@ def log_event(msg): @main.route("/") def index(): - db.create_all() # a bit dirty, but create all tables on load of the main. Doesn't re-create any already existing tables return render_template("index.html") @@ -72,7 +71,7 @@ def create_post(): # add the new user to the database db.session.add(new_platform) db.session.commit() - return redirect(url_for("main.index")) + return redirect(url_for("main.whitelist")) @main.context_processor diff --git a/server/server.py b/server/server.py deleted file mode 100644 index 066ae4c..0000000 --- a/server/server.py +++ /dev/null @@ -1,388 +0,0 @@ -import os -import re -import time -from datetime import datetime - -import sentry_sdk -import yaml -from flask import (Flask, flash, redirect, render_template, request, - send_from_directory, url_for) -from flask_httpauth import HTTPBasicAuth -from flask_moment import Moment -from packaging import version -from sentry_sdk.integrations.flask import FlaskIntegration -from werkzeug.security import check_password_hash, generate_password_hash - -__author__ = 'Kristian Stobbe' -__copyright__ = 'Copyright 2019, K. Stobbe' -__credits__ = ['Kristian Stobbe'] -__license__ = 'MIT' -__version__ = '2.1.0' -__maintainer__ = 'Kristian Stobbe' -__email__ = 'mail@kstobbe.dk' -__status__ = 'Production' - -ALLOWED_EXTENSIONS = set(['bin']) -app = Flask(__name__) -moment = Moment(app) -app.config['UPLOAD_FOLDER'] = './bin' -app.config['SECRET_KEY'] = 'Kri57i4n570bb33r3nF1ink3rFyr' -PLATFORMS_YAML = app.config['UPLOAD_FOLDER'] + '/platforms.yml' -MACS_YAML = app.config['UPLOAD_FOLDER'] + '/macs.yml' -USERS_YAML = app.config['UPLOAD_FOLDER'] + '/users.yml' - - -auth = HTTPBasicAuth() - -users = {} - -sentry_sdk.init( - dsn="https://ccfebfa76dc645acbc16566836763e5b@o231748.ingest.sentry.io/6118097", - integrations=[FlaskIntegration()], - - # Set traces_sample_rate to 1.0 to capture 100% - # of transactions for performance monitoring. - # We recommend adjusting this value in production. - traces_sample_rate=1.0 -) - -@app.route('/debug-sentry') -def trigger_error(): - division_by_zero = 1 / 0 - -@auth.verify_password -def verify_password(username, password): - if username in users and \ - check_password_hash(users.get(username), password): - return username - -def log_event(msg): - st = datetime.fromtimestamp(time.time()).strftime('%Y-%m-%d %H:%M:%S') - print(st + ' ' + msg) - - -def allowed_ext(filename): - return '.' in filename and \ - filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS - - -def load_yaml(): - platforms = None - try: - with open(PLATFORMS_YAML, 'r') as stream: - try: - platforms = yaml.load(stream, Loader=yaml.FullLoader) - except yaml.YAMLError as err: - flash(err) - except: - flash('Error: File not found.') - if platforms: - for value in platforms.values(): - if value['whitelist']: - for i in range(0, len(value['whitelist'])): - value['whitelist'][i] = str(value['whitelist'][i]) - return platforms - -def load_users(): - users = None - try: - with open(USERS_YAML, 'r') as stream: - try: - users = yaml.load(stream, Loader=yaml.FullLoader) - except yaml.YAMLError as err: - flash(err) - except: - flash('Error: Users file not found.') - if users: - for user in users: # generate hash from the plaintext password - users[user] = generate_password_hash(users[user]) - if not users: - users = dict() - print(users) - return users - - -def save_yaml(platforms): - try: - with open(PLATFORMS_YAML, 'w') as outfile: - yaml.dump(platforms, outfile, default_flow_style=False) - return True - except: - flash('Error: Data not saved.') - return False - - -def load_known_mac_yaml(): - macs = None - try: - with open(MACS_YAML, 'r') as stream: - try: - macs = yaml.load(stream, Loader=yaml.FullLoader) - except yaml.YAMLError as err: - flash(err) - except: - flash('Error: File not found.') - if macs: - for known_mac in macs.values(): - if known_mac['first_seen']: - known_mac['first_seen'] = str(known_mac['first_seen']) - known_mac['first_seen_dt'] = datetime.strptime(known_mac['first_seen'], '%Y-%m-%d %H:%M:%S') - if known_mac['last_seen']: - known_mac['last_seen'] = str(known_mac['last_seen']) - known_mac['last_seen_dt'] = datetime.strptime(known_mac['last_seen'], '%Y-%m-%d %H:%M:%S') - if not macs: - macs = dict() - return macs - - -def save_known_mac_yaml(macs): - try: - with open(MACS_YAML, 'w') as outfile: - yaml.dump(macs, outfile, default_flow_style=False) - return True - except: - flash('Error: Known MAC data not saved.') - return False - -def detect_known_macs(known_macs, platforms): - for key, known_mac in known_macs.items(): - for platform, platform_values in platforms.items(): - if(platform_values['whitelist'] and key in platform_values['whitelist']): - # This mac is whitelisted, store the platform-name, so we can use that in the future - known_mac['platform'] = platform - return known_macs - -@app.context_processor -def utility_processor(): - def format_mac(mac): - return ':'.join(mac[i:i+2] for i in range(0,12,2)) - return dict(format_mac=format_mac) - - -@app.route('/update', methods=['GET', 'POST']) -def update(): - try: - __error = 400 - platforms = load_yaml() - known_macs = load_known_mac_yaml() - __dev = request.args.get('dev', default=None) - if 'X_ESP8266_STA_MAC' in request.headers: - __mac = request.headers['X_ESP8266_STA_MAC'] - __mac = str(re.sub(r'[^0-9A-fa-f]+', '', __mac.lower())) - log_event("INFO: Update called by ESP8266 with MAC " + __mac) - elif 'x_ESP32_STA_MAC' in request.headers: - __mac = request.headers['x_ESP32_STA_MAC'] - __mac = str(re.sub(r'[^0-9A-fa-f]+', '', __mac.lower())) - log_event("INFO: Update called by ESP32 with MAC " + __mac) - else: - __mac = '' - log_event("WARN: Update called without known headers.") - __ver = request.args.get('ver', default=None) - if __dev and __mac and __ver: - # If we know this device already - if __mac in known_macs.keys(): - known_macs[__mac]['last_seen'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S') - else: - known_macs[__mac] = {'first_seen': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), - 'last_seen': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), - 'IP': None, - 'type': None} - save_known_mac_yaml(known_macs) - log_event("INFO: Dev: " + __dev + "Ver: " + __ver) - __dev = __dev.lower() - if platforms: - if __dev in platforms.keys(): - if __mac in platforms[__dev]['whitelist']: - if not platforms[__dev]['version']: - log_event("ERROR: No update available.") - return 'No update available.', 400 - if version.parse(__ver) < version.parse(platforms[__dev]['version']): - if os.path.isfile(app.config['UPLOAD_FOLDER'] + '/' + platforms[__dev]['file']): - platforms[__dev]['downloads'] += 1 - save_yaml(platforms) - return send_from_directory(directory=app.config['UPLOAD_FOLDER'], filename=platforms[__dev]['file'], - as_attachment=True, mimetype='application/octet-stream', - attachment_filename=platforms[__dev]['file']) - else: - log_event("INFO: No update needed.") - return 'No update needed.', 304 - else: - log_event("ERROR: Device not whitelisted.") - return 'Error: Device not whitelisted.', 400 - else: - log_event("ERROR: Unknown platform.") - return 'Error: Unknown platform.', 400 - else: - log_event("ERROR: Create platforms before updating.") - return 'Error: Create platforms before updating.', 500 - log_event("ERROR: Invalid parameters.") - return 'Error: Invalid parameters.', 400 - except Exception as e: - print(e) - -@app.route('/upload', methods=['GET', 'POST']) -@auth.login_required -def upload(): - platforms = load_yaml() - if platforms and request.method == 'POST': - if 'file' not in request.files: - flash('Error: No file selected.') - return redirect(request.url) - file = request.files['file'] - if file.filename == '': - flash('Error: No file selected.') - return redirect(request.url) - if file and allowed_ext(file.filename): - data = file.read() - for __dev in platforms.keys(): - m = re.search(b"update\?dev=" + __dev.encode('UTF-8') + b"&ver=(v\d+\.\d+\.\d+)\x00", data, re.IGNORECASE) - if m: - __ver = m.groups()[0][1:].decode('utf-8') - if (platforms[__dev]['version'] is None) or (platforms[__dev]['version'] and version.parse(platforms[__dev]['version']) < version.parse(__ver)): - old_file = platforms[__dev]['file'] - filename = __dev + '_' + __ver.replace('.', '_') + '.bin' - platforms[__dev]['version'] = __ver - platforms[__dev]['downloads'] = 0 - platforms[__dev]['file'] = filename - platforms[__dev]['uploaded'] = datetime.now().strftime('%Y-%m-%d') - file.seek(0) - file.save(os.path.join(app.config['UPLOAD_FOLDER'], filename)) - file.close() - if save_yaml(platforms): - # Only delete old file after YAML file is updated. - if old_file: - try: - os.remove(os.path.join(app.config['UPLOAD_FOLDER'], old_file)) - except: - flash('Error: Removing old file failed.') - flash('Success: File uploaded for platform {} with version {}.'.format(__dev, __ver)) - else: - flash('Error: Could not save file.') - return redirect(url_for('index')) - else: - flash('Error: Version must increase. File not uploaded.') - return redirect(request.url) - m = re.search(b"update\?dev=" + __dev.encode('UTF-8')+ b"&ver=$", data, re.IGNORECASE) - if m: # a platform was found, meaning no version was found - flash('Error: No version found in file. File not uploaded.') - return redirect(request.url) - else: - flash('Error: No known platform name found in file. File not uploaded.') - return redirect(request.url) - else: - flash('Error: File type not allowed.') - return redirect(request.url) - if platforms: - return render_template('upload.html') - else: - return render_template('status.html', platforms=platforms) - - -@app.route('/create', methods=['GET', 'POST']) -@auth.login_required -def create(): - if request.method == 'POST': - if not request.form['name']: - flash('Error: Invalid name.') - else: - platforms = load_yaml() - if not platforms: - platforms = dict() - platforms[request.form['name'].lower()] = {'version': None, - 'file': None, - 'uploaded': None, - 'downloads': 0, - 'whitelist': None} - if save_yaml(platforms): - flash('Success: Platform created.') - else: - flash('Error: Could not save file.') - return render_template('status.html', platforms=platforms) - return redirect(request.url) - return render_template('create.html') - - -@app.route('/delete', methods=['GET', 'POST']) -@auth.login_required -def delete(): - if request.method == 'POST': - if not request.form['name']: - flash('Error: Invalid name.') - else: - platforms = load_yaml() - if platforms and request.form['name'] in platforms.keys(): - old_file = platforms[request.form['name']]['file'] - del platforms[request.form['name']] - if save_yaml(platforms): - flash('Success: Platform deleted.') - else: - flash('Error: Could not save file.') - # Only delete old file after YAML file is updated. - if old_file: - try: - os.remove(os.path.join(app.config['UPLOAD_FOLDER'], old_file)) - except: - flash('Error: Removing old file failed.') - return render_template('status.html', platforms=platforms) - return redirect(request.url) - platforms = load_yaml() - if platforms: - return render_template('delete.html', names=platforms.keys()) - else: - return render_template('status.html', platforms=platforms) - - -@app.route('/whitelist', methods=['GET', 'POST']) -@auth.login_required -def whitelist(): - platforms = load_yaml() - known_macs = load_known_mac_yaml() - if platforms and request.method == 'POST': - if 'Add' in request.form['action']: - # Ensure valid data. - if request.form['device'] and request.form['device'] != '--' and request.form['macaddr']: - # Remove all unwanted characters. - __mac = str(re.sub(r'[^0-9A-fa-f]+', '', request.form['macaddr']).lower()) - # Check length after clean-up makes up a full address. - if len(__mac) == 12: - # Check that address is not already on a whitelist. - for value in platforms.values(): - if value['whitelist'] and __mac in value['whitelist']: - flash('Error: Address already on a whitelist.') - return render_template('whitelist.html', platforms=platforms) - # All looks good - add to whitelist. - if not platforms[request.form['device']]['whitelist']: - platforms[request.form['device']]['whitelist'] = [] - platforms[request.form['device']]['whitelist'].append(__mac) - if save_yaml(platforms): - flash('Success: Address added.') - else: - flash('Error: Could not save file.') - else: - flash('Error: Address malformed.') - else: - flash('Error: No data entered.') - elif 'Remove' in request.form['action']: - platforms[request.form['device']]['whitelist'].remove(str(request.form['macaddr'])) - if save_yaml(platforms): - flash('Success: Address removed.') - else: - flash('Error: Could not save file.') - else: - flash('Error: Unknown action.') - if platforms: - known_macs_with_platform = detect_known_macs(known_macs, platforms) - return render_template('whitelist.html', platforms=platforms, known_macs = known_macs_with_platform) - else: - return render_template('status.html', platforms=platforms) - -@app.route('/') -@auth.login_required -def index(): - platforms = load_yaml() - return render_template('status.html', platforms=platforms) - - -if __name__ == '__main__': - users = load_users() - app.run(host='0.0.0.0', port=int('5000'), debug=False) diff --git a/server/templates/create.html b/server/templates/create.html index c44ee4a..5b9b128 100644 --- a/server/templates/create.html +++ b/server/templates/create.html @@ -16,7 +16,7 @@

    Add platform

    - +
    From 645334e65b8d91ab339586efd9bff1f4e1ff5e6f Mon Sep 17 00:00:00 2001 From: Marco Date: Sun, 2 Jan 2022 11:42:58 +0100 Subject: [PATCH 42/73] restrict login to admins only fix issue where no admin would be created when supplying environment variables prevent long platform notes from being cut off --- dockerfile | 2 +- server/__init__.py | 23 +++++++++++++---------- server/auth.py | 6 +++--- server/templates/whitelist.html | 24 +++++++++++------------- 4 files changed, 28 insertions(+), 27 deletions(-) diff --git a/dockerfile b/dockerfile index 93d156f..8a94c97 100644 --- a/dockerfile +++ b/dockerfile @@ -6,6 +6,6 @@ WORKDIR /esp-update-server COPY requirements.txt requirements.txt RUN pip3 install -r requirements.txt ENV FLASK_APP=server -ENV FLASK_ENV=development +ENV FLASK_ENV=production EXPOSE 5000 CMD [ "python3", "-m" , "flask", "run", "--host=0.0.0.0"] \ No newline at end of file diff --git a/server/__init__.py b/server/__init__.py index abe85ba..381f755 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -37,18 +37,21 @@ def create_app(): # check if we need to add an Admin-user ADMIN_EMAIL = os.environ.get('ADMIN_EMAIL') - ADMIN_PASSWORD = os.environ.get('ADMIN_PASS') + ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD') if ADMIN_EMAIL and ADMIN_PASSWORD: - user = User.query.filter_by(email=ADMIN_EMAIL).first() # if this returns a user, then the email already exists in database - if user: # if a user is found, we want to make it an admin + with app.app_context(): + user = User.query.filter_by(email=ADMIN_EMAIL).first() # if this returns a user, then the email already exists in database + if user: # if a user is found, we want to make it an admin + user.admin = True user.admin = True - else: - # create new user with the supplied data. Hash the password so plaintext version isn't saved. - new_user = User(email=ADMIN_EMAIL, name="Admin", password=generate_password_hash(ADMIN_PASSWORD, method='sha256')) - # add the new user to the database - db.session.add(new_user) - # store all changes - db.session.commit() + user.admin = True + else: + # create new user with the supplied data. Hash the password so plaintext version isn't saved. + new_user = User(email=ADMIN_EMAIL, name="Admin", password=generate_password_hash(ADMIN_PASSWORD, method='sha256'), admin=True) + # add the new user to the database + db.session.add(new_user) + # store all changes + db.session.commit() @login_manager.user_loader def load_user(user_id): diff --git a/server/auth.py b/server/auth.py index cc4aad9..6559b2e 100644 --- a/server/auth.py +++ b/server/auth.py @@ -26,9 +26,9 @@ def login_post(): flash('Please check your login details and try again.') return redirect(url_for('auth.login')) # if user doesn't exist or password is wrong, reload the page - # if not user.admin: - # flash('Only admins are allowed to log in') - # return redirect(url_for('auth.login')) + if not user.admin: + flash('Only admins are allowed to log in') + return redirect(url_for('auth.login')) # if the above check passes, then we know the user has the right credentials diff --git a/server/templates/whitelist.html b/server/templates/whitelist.html index 4fc230d..d54b044 100644 --- a/server/templates/whitelist.html +++ b/server/templates/whitelist.html @@ -46,20 +46,18 @@

    Bind devices to a platform

    {% if platforms %} {% for platform in platforms %}
    -
    +

    {{ platform.name }}

    - Notes: - - {{platform.notes}} - - - - - - - - + Notes: + {{platform.notes}} +
    + + + + +

    Downloads: {{platform.downloads}}
    Latest firmware: {{platform.version}}
    From b6782419e41b7ab4dfdb73e7e9e374edc7da492f Mon Sep 17 00:00:00 2001 From: Marco Date: Sun, 2 Jan 2022 11:47:05 +0100 Subject: [PATCH 43/73] update readme to reflect changes in the rewrite --- README.md | 82 +++++++++++++++++++++++++-------------- img/whitelist.png | Bin 0 -> 121617 bytes server/__init__.py | 12 +++++- server/img/status.png | Bin 19122 -> 0 bytes server/img/whitelist.png | Bin 68302 -> 0 bytes 5 files changed, 62 insertions(+), 32 deletions(-) create mode 100644 img/whitelist.png delete mode 100644 server/img/status.png delete mode 100644 server/img/whitelist.png diff --git a/README.md b/README.md index a3e55df..3aa15ac 100644 --- a/README.md +++ b/README.md @@ -17,49 +17,67 @@ The main feature are: ## How Do I Use It? -The server is _intended_ to run on internal network where it cannot be accessed from the internet. As such it does only offer very basic security. In the users.yml, you can create additional users that are allowed to access the server - -### Start Server From Code - -To run the server directly from code start it with the following command: +You need to create a new admin-user to be able to access it. This can be done by running setting environment variables before running the server. You only need to do this once. If the user already exists (eg: the user used the registration form) it will be promoted to an admin-user +Linux: ``` -python3 server.py +ENV ADMIN_EMAIL=desired_login_email@yahoo.com +ENV ADMIN_PASSWORD=verysecurepassword ``` - -### Build docker file yourself -From the root-directory of this app, run: `docker build -t esp-update-server:latest . ` to re-build the docker-image from source -To directly run this app, run +Windows: ``` -docker run -d --restart unless-stopped --name esp-update-server -v $PWD/bin:/esp-update-server/bin -p 5000:5000 esp-update-server:latest +SET ADMIN_EMAIL=desired_login_email@yahoo.com +SET ADMIN_PASSWORD=verysecurepassword +``` +Docker: +``` +docker run -d --restart unless-stopped --name esp-update-server -v $PWD/bin:/esp-update-server/bin -p 5000:5000 --env ADMIN_EMAIL=desired_login_email@yahoo.com --env ADMIN_PASSWORD=verysecurepassword marcovannoord/esp-update-server:latest ``` -### Start Server From Docker? - -Ready-made Docker images are available on [Docker Hub](https://hub.docker.com/r/kstobbe/esp-update-server/) which support running on Linux on both AMD64 and ARM32V6 architectures - i.e. desktops, laptops, and Raspberry Pis. +### Start server from source -To run the server in a Docker container create a directory `bin` for storing binaries. Then run following command from the directory where you have the `server.py` +To run the server directly from sourcecode start it with the following command: ``` -docker run -d --restart unless-stopped --name esp-update-server -v $PWD/bin:/esp-update-server/bin -p 5000:5000 kstobbe/esp-update-server:latest +python -m pip install -r requirements.txt # To install the required dependencies +ENV FLASK_APP=server +ENV FLASK_ENV=development +python3 -m flask run --host=0.0.0.0 ``` +### Running with Docker + +Ready-made Docker images are available on [Docker Hub](https://hub.docker.com/r/marcovannoord/esp-update-server/) + +To run the server in a Docker container create a directory `bin` where you want to store the database and binaries. Then run following command from the directory where you have the `bin`-directory. +``` +docker run -d --restart unless-stopped --name esp-update-server -v $PWD/bin:/esp-update-server/bin -p 5000:5000 marcovannoord/esp-update-server:latest +``` Using the `-v` option ensures files are stored outside the Docker container and are thus persisted even if the container is terminated. -### Access Server For Management +### Build docker file yourself +From the root-directory of this app, run: +``` +docker build -t esp-update-server:latest . +``` +to re-build the docker-image from source +To directly run this app, run +``` +docker run -d --restart unless-stopped --name esp-update-server -v $PWD/bin:/esp-update-server/bin -p 5000:5000 esp-update-server:latest +``` + +### Device and platform management -In a web browser, when the server is running, enter the IP address of the machine running the server and port 5000, e.g. `http://192.168.0.10:5000`. Now platforms can be created and deleted. Whitelists can be managed and binaries uploaded. -**Status overview** -![alt text](img/status.png "Status overview") +In a web browser, when the server is running, enter the IP address of the machine running the server and port 5000, e.g. `http://192.168.0.10:5000`. Now platforms can be created Devices can be added to platforms and binaries uploaded. **Whitelisting devices, and assigning them to a platform** ![alt text](img/whitelist.png "Whitelist page") ### Access Server For Update -Devices requesting download of a binary file for upgrade must access path `update` and include _device name_ and current _version number_ in a query like below - substitute the IP address with your own. +ESP32 and ESP8266 devices requesting download of a binary file for upgrade must access path `update` and include _device name_ and current _version number_ in a query like below - substitute the IP address with your own. ``` -http://192.168.0.10:5000/update?dev=chase&ver=v1.0.2 +http://192.168.0.10:5000/update?dev=smart-lamp&ver=v1.0.2 ``` The server will respond with _HTTP Error Code_: @@ -69,22 +87,22 @@ The server will respond with _HTTP Error Code_: ### ESP32 Implementation -Below if an implementation for _ESP32_ that works with the server. Remember to change the IP address in the code to match your own. +Below if an implementation for _ESP32_ that works with the server. Remember to change the IP address in the code to match your own. You can find a slightly more elaborate example in the `/examples` directory ``` #include #include #include -#define VERSION "v1.0.2 -#define DEVICE_PLATFORM "Chase" +#define FW_VERSION "v1.0.2" # define your firmware-version here. You NEED to increase this every time +#define DEVICE_PLATFORM "SMART-LAMP" # make sure you do not change this name once chosen. It may not contain an underscore const char* ota_update_server = "http://192.168.0.10:5000/"; /***************************************************/ void checkForUpdates(void) { - String checkUrl = String( ota_update_server) + String("update?dev=" DEVICE_PLATFORM "&ver=" VERSION ); + String checkUrl = String( ota_update_server) + String("update?dev=" DEVICE_PLATFORM "&ver=" FW_VERSION ); Serial.println("INFO: Checking for updates at URL: " + String( checkUrl ) ); @@ -115,15 +133,15 @@ For _ESP8266_ the implementation is very similar with a few changes. Remember to #include #include -#define VERSION "v1.0.2 -#define DEVICE_PLATFORM "Chase" +#define FW_VERSION "v1.0.2 +#define DEVICE_PLATFORM "SMART_LAMP" const char* ota_update_server = "http://192.168.0.10:5000/"; /***************************************************/ void checkForUpdates(void) { - String checkUrl = String( ota_update_server) + String("update?dev=" DEVICE_PLATFORM "&ver=" VERSION ); + String checkUrl = String( ota_update_server) + String("update?dev=" DEVICE_PLATFORM "&ver=" FW_VERSION ); Serial.println("INFO: Checking for updates at URL: " + String( checkUrl ) ); @@ -145,8 +163,12 @@ void checkForUpdates(void) ``` ## TODO -- [ ] Some way to allow users to become admin / usermanager +- [ ] Usermanager - [ ] Ability to delete platforms +- [ ] Better input handling/checking +- [ ] Getting it production-ready and safe +- [ ] Prevent underscores in platform-name +- [ ] Make compatible with AutoConnect ## Legal diff --git a/img/whitelist.png b/img/whitelist.png new file mode 100644 index 0000000000000000000000000000000000000000..8d19591bf9dfdbe600a940e82b41e1a9925d8dfd GIT binary patch literal 121617 zcmeFZbzGDE_Xn&9f;37fFhWd_5Xm7q=~CL!f)WF%QKKZMG^ilW=pNl6po9`5hae>a z8;zvobD`e%SH1suUa#l*_xH~j+pg=J&pGFF&ilmYdU0P(o`RH-^wgG)|o& z1f4oXbVza*_#{n;EeH5>+DSuR=2TG+Gyc>mwo{7tq_y0Pmd43m(a!qr9JBIu@x8To zeHAjpMzFxo&bb&kwfLnvT9E6Q`UUgVCs*xDZAK#9$X_tM8jn>lqWE@wA^Ncs-`6m9 zmargZmCsXWzgs}oN>kZe)1I|F7wI6RzG(7h|5(da+g00H+tpd!xmRV!wZ}oYOG|QV zaNa%7VgcKm8S7%ZKy6II_UshFA1_iJ1Pvr08Tw2SLTaf~r+Gb1 z-QkVX4Pq;P`eivDtybxf!Scih&yjmG^qWj|^@>rz{zS(UFaKmNM<2>pgK4@8+sX{1 z6KDKlqPiDTmlDrWgTIExUts^K11Z-F3fqzJdq?7m7QzvJg;mulv#!^OAY0hHnuvpK@QL)x|+*3r9TSIUkC@gL@^pVd==G{)_cYGTUkO!L7YILNe1TL{a**JvSLpCXc(j|{^^ zb~sq1r4+T#36C|-OF6$Dce!QyReF2$t%P9<^9pNCj5lIBKw$E=%-cgY(D4qL7dkXF z^tq?#f}M{l9NgC3=IOQg(WStHv1M#q9WRcB7e^Qs7ZOq56dRQwM#{_Eyld(b>SVDj z|EiU;4V|wF57DE7k339QO+(i1C0v>B&3qG^-Xv>mTcx61RP_n#eNJQY>EN|KWtH_KJFp1)I5b(wjf&!ajKeXd#OfZ%gp8)QZd8?pqrdd< zC`Rsk0Hu|Q!$14&YpfI$<^R3CW@%%so49VIHF1mBb1CBSn1A%XN4PBA1<9!J!NMD^ z&HiJT8?yopAe-aPVa8?$sA15fE8bsL1^d~jJ+kI$xZD(Iz*me~;o~ldeA~Xx&uVCr zgU(z!>I}qlS9HnkFK3r8X`#6ao!QS1Ppz@wBk!ZN$Q;{PRFP zVu2wElZ~ct%U+%KF5g2fHiMgox2G;{Y?F+a9_YmOC?H251%*EM{7=1ysP8n;d_Ckm z54~!aj&qN<8Hm0m8F8xhX$+>aXy;h~2DRTneC@0q$E(f5Nz&8Ht79b|?ey@oRtZ`t zhmK?T%>7+-V4LQ?7`S*DX^dZG_Vm359($?EE8F zd)n77xJYWLYd+7ZHoa#Z9Mm*Z@S@Ta3H{`t+Exz}u{O#+v|z$fng0BzECU?3&bPNDvpij) zxgH*P#XknNv*XbL_nKVv{>EZ$$D_x{2VbMGV?G_M&NjmUuBKP(whW6>`|N|iJ9U0I-rbM&x2eolst+}Vu!-LDtTm=Vl!u!#rQ=sM0x7f9 zJt=FsJ#l^*_BHS z_OvrWM0M81Jy7>3rIgsSSanEFloC|CCmxAm_h>Q!BDjTe=dS>U=_c|+QXY{tWilHr z?%#Cr+=NUa2X1*lx7;$F1BzT!$oTvNhwo@8zLT`>Bx!Q2G!0f>H1EflCRVE4!YX@o zqxM%Y?6VNhm^a;c^6;B9ZMmU)p;Hn>OGqCTg zGN3|^(1w2vnZ7FV>{Axq!^b205f%wTP@F`67Gkk|<);3`id_-CH@7R!_i5E%YV~-oB|O|iXsY3Y7gaGa{Y1!mo6F-D z1^7r$hF7;5(jRNxLP6wg>i-Qf(rks8X2gBC4SpUf#x{UyE?fHC{W!cTv*OIN$#{}8 zuh-U=%KQ*{f}n!eSPp%QQI>lb=3K^DKLB=CR44)(vjtbicJ;-?-PYUD@o1y6SzZ() z<#0&oFkcj{_xchkP$1(C6RB<`*{{rev20z}%I(^&Q~W}I(_M|M*cP>?HDl84T9`>~ zXP2*fAkm;&VQAV59bnH(7-@72q~0d}n5|_xwkP=3Lw(5?YyI(^_iFXzX!uQcrc1MW z`UzPVxnIsHAQdPsEF_HWNUb_wwQ(kyio;~Jd5q@*{*7$^@ssMilWz2Xi{B;<8ry>8~$-#K=|(n1Yji3E3aN@947O?D(S9+;q%W$ z%27Ixo!t0P5Xur!y1x-);1wy{eTBmAir-lLfyN>s0LEl~`q;c{ccsjNv)xZ(90^_)&mN{!bylEB^l! z@_#$zH+$&#za8>_F6-ao%>UmQk}5^mX7$n7AU9$EP3)M9%H(c}ZUyH|{wg|wcnjM+ z&<^LD;cxMnu6+L|GHtT>Ln9#>UITA`30<4RgG6r!laLh=vjeeu3_Hr`bkS9@ymbxDKuTBLi98Db zM-cXmVY9tbR&+Snh|>qmZjUCX)?Ncl1~eJ8hgiUnHhB;Bk! zs5;^>%#bVv!cE;G%jt?;0*2~9)-fa+BR%)UAgWK`gSfiq`U=QWx`OW@5 z>g<5(QBEr+qH20d=^r{^n*?<5ts$^R;)f1+Xxw&7do8VwcQJwHi{L9N#KqQ$=7z*O zunF*XT2WD%(VH=9wyr2TX-2NcIO>c@l?ZlthgLZD2bQ7D0RVxo(B0Uu6Vws;*!RJ) zGKk}ByZRSlZt0$iOA^+${j##d2tw~g@Fq6s#Zc{7qrbn|Au&)1tl&XGhAnSiJV769 zk|#`+8P@y&ZoWnIHXKXm?3mZqc)MH1?OU zAmh4=CASyf^@i`Mh7}xQbc)q+_wPcfBABzkpCMjMlbD+Zaqrs{>klbluwa3+~!)YohJ%!NcCqZREeBoVZjXoI#2DjtFiI zJPp{>Udc7|QZ`d45qm=;xCfWJQ)TVz5!GQ6K>0p>3mvOv^}uQ}gTkjV(}=2Zr&gz? zl7fN*UTr&g+#fXUKVyp2RehAM`I*drPd5R>F1h;nrzqK+0Ks-GyIuY1PdbSV#8hsW z8X}c!Yf6=b6QrM2jAS?wN`I-PT)g(V==*RbUGkViEd;5F$Tzcx6O+#5ryDNJj-=e# zJIsRQP1Gr)N6?&#xZ<2rXaq&5PKL3ah5P*2*i5DXNCSVwxG^~k6<+5RYy**B8bxK2YH-6IU@2YI;Suj(g(nZTh9Q)e4Q`z!RHi?)23F*=+{XL*EzWO{pj zH@-KP8*6(nz{Fc8@~EPyk_tS{@^d4kzCCe9^=IsV`iW1p_Js{oMp)A+T2M7S zqfIVlcV>W>*)-6@5c#pftKV(hM7#_joCy$4o#C&SjFX&%-b$kVHftWIi%p=z)YI2;u=?$lxP{tX zlBk{_%5aPvH)2sQY$azOV=Ce@CBh9dO4FT~Oi%e_>#sK6@uVuv=EMe06CxskEk|X2 zEAK~Mf*By(gN?tl?v7aIcTj5*DVBA8L$!6{$WOx>2<6$P$+?&p!Q>5U@;p3P*=vD2 zI*BYO$N+M$YeE>;sM%e74c zh{O5bWnFyT>YIy2#VwUZ{v0{Q5#jcHwLmS*kT2t%^Yt%evU0)Xx8e4~6DOZ-|#(neGDlc=7<>?}Fls*}*He_$~Se*nky4CmHX;0+- zhJa;jBM55BGFF49Tfbl=_^5O^^kO&`*jl%>yx$L>>J90Lg6HXkC+2~WRK6T z0uj^rSHv76NI!}N%aMmC%@&%W7p78~Hfz4%stzQ$T?=3)1&j8zO_lFHC{|vR*&zop zW7D1XK8fiIREc^W6w44Vc9{|DSRSn@b@MV6gfM^ix0R<^G?77WGKK2?@Nu-|z!If} zB^iEHCn7KSe(4-_at|!xQ+N5+bsKa^q~)OM_;Q39Z_%(X>abfksgKJ-3BDoa6r$YQ zM>9MG@5MAXSY~=~b*0+UxZtQIS53GZHqVONT#_e1F;a-%XOaxSuv^}7VE*a1Xim!z zg9(1Vf5>nV#lRC0VRK(UL~R?N&mOhW#_&QVC&-NE1AUUsVm`Z9n*3E`<uDJ}yPBIE@wyuNo5jd6H_74`3CShF$8JL`<&`o>WC-H7n#EK!S? zX1ctKi|lSsA)+!&*CtZymKTL9llpoWV4G7Z5u%W1{f^uq3F!^M*K~`aVXrB&olua$ zxrG=GsAiKq6_`F`30=qAD9!XJT{$B{ttmIEg0-zz!amr{kit2!uUEp&(5ZnwX$hYc zv-cAcQu_g#AbC#f`@;mJlzhnmD8eECCY|Yv{PnwowWQxBp7hrwKt2?=xWc-NUC@y$ zO`8LVE+KAqjqGrjBwf2Lm_&w=hpTM9C;*-eG*DcK8f#nTF;j0e@Lier%7Yy~8Dv5? z4Tl^^y)tp&yTqYjM%8kC)Lu%TCUKQ@^Cb^MFF&>oD3$j?TG8FaQepcatehes;sEMB ze5&0)`=0Q^x33Xp)`>-qcWtV0uWKiB_Vl9TYv@s-%yDpdfP++#Jow7F3!w`FpJa#? z&5&C7z&-#lA=cD~`3kpA;2|t*`OSU61v$AU4)87*4T0J(RCU+ecEKC?Y*P2!>xE1| z_7yF|6G1;JBfdNgfRFojE%m1c1KBagi`R5ujb7H$?KcbazVmuNT3!b?_oZ6wMtv@3%rC(>SWTa+IT zvWp+|u{X>j9?#1VaGJ|0Tem?&M^{SphyXL$!_Ux19CY zK7S6VpZaR}&*1n2D*k-&EeGOc|Fs#S|J~%*JL-Et0qObq_Mc$o->e4!wX4(`L;uVI zi~?}i>g77Y&+zpdT@s)>O?~f$*>4~HV#OBVzP!`z*OvK>E+NqWCP{mD`?rsN5|69| zxF0pI|BdfoRV_cDy-fJv%fNb(Lf zq!;y{@h7&7%ownk?3+bO;9M%Ysz}EL=dKlUa`hj*w8Lj{T(^mhRrD~(oiw}+Wk?>& z{y6E=)c%0G-n=$uE4C6eo+q|Ui#O;jhHdr)wr1R7+^u>Drkm9RDy*)6JU#79i>OXl zJ^L>Id>sk@qO0yDl^REbQ}X2>7jyKRqhQVmPP_d2jkcAw-SANmlv{GUu~IN$QU!7R z9&LJ@dpxM>TnVe4f`XWD>s?*GUhcW7R=E7(BZb>aGBa4qz^%bsxIo2PJd@i(UqGQ9 zZedsnK`L*4es?Z@c#KH|rA}yrOoorN1Sgc+NIk+uKJ=gl-ylZ$8z*dKc*`5oNU`k$ zBK#ygM@|CgJ-rbT89ir)$SoPtu9CCO_t5QyqZ#LmggCA(R+vJ{k4IMF>RBnb9+y>) zH+X-l-(G>btY1a!MSs*XG{1YZa^e2K!)3|IH1brFA>(tALC2fh1S0m!{YMZYnG4;fE zbGq#8J>D3td;E!`-1@Wmxc04~6!BDEW^m4UI3mV+I46Jen35$my7B_FvDYlX&s~zm zIl`tm_D-r2TH7EaOLHGyZsV6#;hDNfRi6$S@4r%jL2&?#3#&zCwBw}w#zV$O%iB6a~_ZC2^o_!{ZuDD`ny132)lW&hre z(LjHq7eUIyR+pz45M2pAm!Vc4-6Y|%?e6h>erNm#i9vUe46=vmgaJ6~iGT*^2MK*Y zlMqsZlmr)BK5lI_w+b{yh;~+|YwxQ!<$Ekh*xxnLjy2AF3`uNXme{I%Dz1^oz}uvP z-L%GxRv8aCV(H{qvy>|KXKZV?(&bhcc^ma$a>jWJ(T^zjgYuX{YAaXjGW(Q1fB+zM!mp~uC#2-}pz*)M4@RgxM%)U-z@-;356N|DT3^}*E^ z5!RVPojwW14^=&2qOu)GD^s)&;l+NO;Tg_|L5zQ2;jt}ajpv77EX=-ckh`Gc1aFPZ z-)xWKfKPC}UCWjEI^EQJ?LT>Gd#^-G3alh}?Ga*pyA# zzct)W#X9hw!KlE>%R{-F)q2pw!xPH6+v@xt!&%65TV}=#_Pjjr$fi)j9cNO$BrtYc z+pzT;rO_(x%ZuDGj!y~DdY?%I;{H3B#)ER(hJ9tsPMpah=bN}Ii^;_e#Ft>r2Wje= zjdX78osp9}at8VS4CFD4lS3oWGWGX4hE4ijt@in9XS627j)d=R+MQY4aW0OTq}&gGrVPT8_n)feSCIax$UT8^%CfA?@_L)}oo* z^Nd3om%~{3RA#$Z7hbM5q3bw9)t`h^?7#C_7*5|_6hL-uOJvGVxMBY#tctIMnUU0oWV#d9jmDAl){Ka<>lOm=^dbipN4CD2 zV%-@q%ZqHB3A1ge?9?e}g9C47*@2RrB-$awgb2B=AaVuIb zr`FZKGSOB`^Fdr5F*znR{A{84T71mDw<9Rkn+|LJ>Di;)!-C2rTTIGWru5ZTfkNXF zC2-F|997lgQAV@>_qPMyI{W*t6z0k8^1Cw+DYp9G(lB}+y*a{sW~s}{%Gz-r@bxNr z#m!tKmi+Mi`tI4IB zZ;h>=L0Uzx3)`V5q8^+pdA$_E+voK9f^Dk*A@QpedUS824M_`~W%Vrne9 z`rb>ZME}<6aK@X3`+0(~ssja$EMyf~q@b=8)?gy1IdS#~3D!rr;(ddcZL!;7JxnO3rydxKNWjLiBT zS+auJ6Mds)8e2KJP+_-cU)Lwf_usjUBL+!MD{Fq70(S&mK8`|aN*e{%QsYh z(qp$D8bUVI(kF()DLThEhE{=L3=tn0dF3EW`+;+ZsN5mXRbEYZw2p1QkE$z+Fl0t* z^-lAiHs2k^lATnSp%m9)yW))V>ESnB(a(>)NmspkJyY;rN)b&PK`a%T2_d5_6@KbK zCKitv)?Ww9PRh4c4K3_-fVri8&{?};Y}&$8^FRX2eNxVny>tH@q2rB6^DWQzGoOk( z5hcgxxWP6|L-&Oq`Y>x2Iga`3zrtj?57f7~Xfx&<5s}&z9y5Viw$s*Ggk^@k7?aN;0i_-8 z7np(H7lhdL$NM^NxgYODZpwqnBSRlZ5Zg}sP4hpAPv+s$_B4+gLK{-qMRNujWvViS z-LEk~LEav*f8T{3mdtXDZ#-44M>A2`nO(PpBI())>*vKkd9oatg{+;e>n)HlN?8)` zaX7kCr#m8NIIe%&a1zAUerQg6C)YU6B}Mq<`wkCTj7#Wb#5re*J~6k3@x=u~a6PZr;jK*rj75P zG_Lti`sCE%vFsQ#qUa?fhf+L*TRhojEa;u&Q5uzO{}E7d^H`{t=;Rv8a~Oc@me8FV zVFl|)Q>j)Fgj8NYY+}^Jy(b{!^~oFCl1m}z!PT}f9a$U898qR1i;9vJZf@}Mo!71N zC<(yzWW-Y14iL2SAvP{7Nvxw5*ClaZs7B-pv%})D=07Mo=a^;}N!r|D@eZ4~$(ps1 zta;A>n^5k(GW8x&IGbWErqv|5gtQuejKx(phMmLOd4X#)S6pl7wW7sZJWrL{eV?Dd zLIjeI)SJ}vvf)sFRKcG#vp-~<#lXv&4}*$l-KEvVS2!?ODfBliJJEpO(679;T+`m0 z$)RCf65SLn0r8&$o!_&{(9<1zqjt_W?UK;<@D+RAzI316K+3-ToO@yswtdS!F7&hs zmrX+bG~RkhgkjkCB&ZK0Hdbf6ca);XxgMn#U4cPI@lLDO+xsi;mAgw-2c(uSlAXTb zAIjI@j}`lhX@)^xX5d=O#Q0pR!uGwcIX96$eG)T1EuP1Ti>G|(F}bn#_AR5KrH{JO zZo{jC+FR3an{@pTKp3-Qw)8I~wBZ22oGusC4VKbk!)=nzyewmn#j~De+AvXun@(PS zy}1B-n8Nn$CeFy|>QU*(t#3IY>8)6?_=^25B-CfgP~41+q=0B=J7qL0VIh>*dxNSf z1^`VS9?|5gyhAM^=)S76F>*by0`;}Um&9AU>lZ4qZ&IqqbmAk99LBJs#0P1M6;CW5 znooGYETM1BrP@5)qe`ly*u6XG(R;hfHD~us!$xzvOX$46He#73B^#8qYU3rlfW?|! zEO3`9mNc4yfve{e_qj=M51+2{aWlKTkQivvVxWD!&@$0RJ&p66c!xpVTz~1~j%1bI zd!&I3@Lnxb3u<`yvZrb1`@4Y+ueNxj>5@vt=qSozua_c%o#cMO2VT1ZlB0BMp&N8Ei;^!}08 zr)-WZRA(C|n*8c}GZu84yVJJvE$`-(RIFZuhnlR8X>3pIoR{RwxhSZOrr>c_^HJVy zh&!ldZmK#z{B(V~>oQYe4h6-W7eN0hYx}hrpi_PnLG2)EvUL0tC5)s)g3@@h7 zQ#qQ8gwyEkxB85dDT4jE<#8sero}Qy%`v#w6&1MmRiNUr8Sxe6U%tP2dwF%O^ac8m z`ny%0$HYtQKD%%krkP7`12*ss1^GIg8839e?*+q=7!F|dm`lb*?@8j-gxA&63M=dn zS~M1$h3~GaEG*Ax+*PT%wD?{`WNd4`4PhJdxnEia=^`s{P2`iJIE3~~Aj(bAD8ff} z?=p4=+gTdpy}oXBwCx@ZbSs) z`fLg~6E*dVtI5L48#l{A=KaN;P*>9$PG&L54ukups!UYNgPmmAR)B{ky5jXbgtx!6 zPuJ44>GFGd)r%nxvaZAz?Z|~CMX$VFIC@;`+GXxlRWMjnoZ8E}FwFP)9iHyR)>3d3 zWpj|1$12I{}ce8W}>sx19 z-}~Ff;V)4G9_$Q|{0BQ4Ues6UGt9-wgtYB(MzQfKCV5pAZ6bJ|2bvA%T+wfSlJp4n z=3SP%o0kSm^`k=s0e{fi82#!UmTPzSkdtT3;K}Y$^P9n6yXTyGvuVH?9x2}&8FqC9 zk}&K>&r3WyYNKK;HCD@_Y_(T~S*i!D+OL>e2{jxpg1&8%qKnmajs+$e2Ha8{gp3~n zelO-g{Kn|ysAdqQqK~BWo>y;T(34Q5gB)~n{*BnrV)IuHCIrwQVZIhjM&Kr0(44mq zF`?(=93PM_Fapvqa+oh5fhS2tVw&gQj9m7qlxi+=75eJVm9c`!G2Xg|=C*b*=In}QrgHl{7hY7Xdg7)Ua&RPtbxWM%!@{k2 zI;&0DN$S+y%Z2$l!Gr9Lkr@>uIqjF7d{=vbVh*9%PKXQ0dL)|&DQT~UynhTSsf}L)OL2!f1m6>cq zTIC{Qzlhz_BV!e^o|(3Vnva1VYkc2AIJm_r*^nt_4M-MXOg#sX>!3^IyfET`0I9)o z%hRjh1!R!$)iRqvO1i@j85^{?`_3`eyU)FN|}gX|kNXa^y{l_u{R6s+nGyxSYRm zntR>kW3B6XpnK8q-mTY_JJPCdvfu%&B13{eIMsox)Rgd3%6q4Ld8mQ1Facccg;Xr* zm4^!bIarjFAYwD4W=@6|tK)aLhtrZT`CvQ16{`L~5?d!d*26ev%~?5{%3LRWFS#fU zawlXo(@3?GA+r40WoJ-E`rLL%uy?&)=QH9H@wahGI1n&;> zHg3I{kBM8cy3)x~4wcl(f`Rp@4!3Q`LTz=3T?O*%)w6)+^v?T^_%l(|Qk8|OA%a$K z`3y%!Z_veZ0tS#VV(&BN;v9Vzh%R|4<+C6&=@we!#}_ugbST4BQ*iOu_7i$6eKhmW zz9z>6MB@aKvxdzOn}=0j##BZ_HpxK+?-!!cpG?GUGCqUYE6u_xwruPSF+Ojya%H!? zl!tmH$id%EJ;)od?N2odE8)JBzN2g>Skv;-iX(dg&wfVhQNjG9Nu#n1S>)(hOV0by}n+E&Mj~Q=hD393pL+U>y2AF*wT*fkwLpJl#W|$vco&%^(fMG8jXi zp0QKKLmMs(4yrQ7m@s5+vpj0|oD*8eedo-Pk?Q>FGp$wZ0rP3XS{0xX^bsd|o51i& zr1?jcbh%;b-C&gVN#!SO^@1T`aqiYDZ7ET*7-4DCvJBChTf@TWyvat&*M?J7Y zJ1}ai-39M(=TxUPF0#OzbEttq+iH4>UwbpW@?i!yV}#{llSgpFz78YZ+ z;%sxerK+OICT*p1u1h~0xqbCBc4$`D>Qkg?$xiSP+Ph^$>cY&VFc~W5+!Wv z<);J{z#MhE4MyOjf!GCOb5Yh>UR%_GtOMCL z`%8}iEMU{LdUb!?n#f=!wL?#R_EF&__uM??U^5@qdqvALuJGQJ?$?q22S%!ktIu8~ z`JJJbN)0*5$4Qb0F5iDn&?B4cSy|Y1vF)H$VPY)evB53gE~?Y8k6mS}xNi{FukC`5 zXE>vIq~3&G(0HOlXxaYHQ9h|BK;0{@W*r#aQP6)IZxq!`5G~#F*c}fuZ;2!k^wD~8wx-y^s{l{VFa8i-+Ulv2SK!z zZZDLU@9lP=RctpIqW)Qhi$7!e_FEV<*)dNsqUlQ)?=$33*K+Lu7jU0J#zCT!`?#Il zXO-X-Tcn|{HXRmb?@@P+g1I0#`~uY5K6}Rx5vOG~V%{K9YS(P^8Yi&ZUK-NO`p5>Tes>*%ZWAy$hJ%?gmpvyY6VL50Lpii z>wfXSUe(WcH*Nt!Y_Ya0qW`Oz<@?vk$A52lUk@N2I5c6v{ddPq^a7BcUlP3jpPA5N zfH-M>Lk;xrwwxnyoG|i^wc5`?gnvmqlQKOSW7qL;s`z__UjawTZZVkn&pD5O*5li9 zGR9u8J!k!&G4?n>COtW#G=H-j2+Td)*IVu$`p>z7JAh0QA2?n9o8A96g$St+MlD`& z{4s0yKXLlrCqe{5p+*eA5y|ekFe36R6raqqehv8i;y-nfu#3=-t)=W738-Z$E`Z$B zTI0vW;J-4#oFO7%Uka1MQOhxe2T+7QA@Psq|7Z0-i1{AS;I7i`l8^wsmCZSo*0bMU z^yF;(DRY1hKPe5VuqU9YjQqVoisfYud88<=S%v;Q!P)ajl@51`Kjeh*A~fiPETufD z#^lEi)>X+nlkoxjDNbOBlM4R>U@h8YtVNGtXOMg!ppwE7fZk)5Qt2j00h>o^jwGy}9TfA6~b-lZFH zU&)wbb~9Y9NaV#$`2s^8#5)NZeWO_*U6t$hBnur5e^`o?FUiH`)>lmqL z?gD*b{F1jXM87WPRm8wfn-4?C$858uRcM1WS4jGM|L!lRN)aKJ-K04= z`Oy>CP53H5b+z)!5I4zfG0sjM87b$3kD@W`RI6k>=l-Onnu_&lV%w?!MiunXQ|yxR zV$|T>Aj+Z#lq+wWcU@s2;3G#DOHh>GA2L5p_)Q(SP?DE!%efFgMZ^Df+LBm?7=4sz z{mOsK$t5NVXyciEV^9o*f2IX(q z<%A_o$)#(n(uWQ@cx}rN69mEQ?zI1$&-rIgA?}>{o#(i%Q6~*!*hiI!Vs>`(j+C49 z0hFCG#2zH3ta$3UN)w`fNQA$A4J&} zPbu8hS#5ei5U{=Na_RVz)|bWWgKE@G$*3#=TAa%-E+hn1ib{6o(Oh#$Qv-ejn;i;>a5k0=!QIwtZQRU$u?K2>yG?a4Jd3ktNM4t@) z`N)F~4&S-CxeQhU3;r+c^x%8^#3>l|^Kv6>=Rn_bbT7qz0c3k<5v9rnPCLG2d1F$)gYN+q+u#d#cO&4%c43_g$O3^U zRW}9jo54#+O1V>}p>Tk_khov)@AE@azN8mX{v}y}g8%|j!5zN-X2*|L;Fea}?SrB> zXEnndi@l$Q!f$YMt3G5Q{{2dTZFBY1l9Jm({p_JrDD1XHW~1zcnC?55T$2pQHnd#q z)e-}EBwM>WTi5&|cmzVS=%dd80uV67B9-!YuXDeHKn`cZijTbIMp9Y{>Bq2FDifg~ z-a9q70x3J?L`TjLpTWK{lEy_&j)&%-;Ie;K@4Lcl5=behT6n+t@OW7_iqj2P;*o9g zA>-T)_!`r!Nf;bA+2pg0etqXJ)^|x0#awxW`kT3I?2+UFlt)P~1VOhFn30q#4*)k= zKl6uAB^ZaCC`V995b!vF&vEYU2Dq_#<^Mq1jJ@;pnX?&1T=IWWFjt*g`cwuS=GF{z zVFN_3Fn+0w8eENaT4NkH(dyc56zo5_lcodVA5Jb(^)v@i^NikPnvLwCB^!o!Jb`V! zDy_Q#FD9mGQ$YHjA>O9Fz3|WDAb!g6Yv$XyI~JEYKuf+5i!43BydDcHZlu-n!G8pJ zfw9omKMnO3i5|-TK{<_(-toiZ%446!&CZj{%u!JYgD7?+rMD&*IRgwFc<7@)z;zLi znL$v}{XA3hQ~JJlFYp1|0w~eZP~i!wfCCo8()_iF%z>2Kc-oa?TfkztxIX6W{d*et zm&VwbeS?C6MmB@_PRIoe@QByZ0^`l&-h(Xv(<<}JD;4a9eHChB0N1D5V5~Bav z0Y9YXt3p%{V2fKje$m4_OY`ectS_D}uJWEl15e1wj{BCIZqNPuj*DCcGKlF4J`T_= zWf|leg6`^{<}R*x!dV*!V74S^;RpYmY5Hm5k;Eq#3tmQ@0iHC{@Zv94?gCXh5;V;@ zfNaa6>Z?EVim$#vCg2vPfWxTDAc5FZQC@C|NtK?i;Q7hGrh)ICYVGPa-RM)J=0T)U z|3VWed3|f}u6q*w!64$hL7|6NNgh8B_ZFFxSL2|dlH)zIGA-!m+{^U8#rc-m(e~0FvJ5X}Xi2oF*G=D3&T~)V;uflhlU^@%FtcPI-6^ zR66-_bfNq^ir4y6%vq}rTJEDG>+XijOF>;nkL}N%XvTL9n9OXsbM-f=MD7qcBfO7p z6@IpD!aVBsb_|IzVEWd|2^_WY&}@x)Tsu3KFf(7{P-BF!3F2o}kKW>R`Z1a5%M9dK z0mP~RXVgY^KQs@V6xuGt7I+Nj^>`&!Nu*7BThxANsrQya@(GH6R05uo&=>x&iB#}EXZQjf) zRMXex9tdWz&QL6?*qpu^FTuZ`Jxl(>KE{nt6fbo7_a*ic*m_hPh4Wf3)686!x0;TK z&7bBXE1x#lOw0C}uR?qsrfvrVF2~94%jgfYtUVc{ccZxX``RLi;wSZT7tj*uCo_!l zJcH^+Z?flDKj#ohhIz`z*J|+=CSRIBoB|xl=Y(p3AJSgoIgt$+4RG%OY{HEbOx>aH z`(#;9yHK`Q_Ds+h-hzSECg{~=gp9DaJ4E35{ns(|#cE~kK=B$!eg4Y(U*_5faI^Qs z8-3!0|FPysGy#Qv#ZnA0?f87Eo|Dh62Y2$VTz8~Sk6y-n_f7f>t3EoCHD&y7WLk}E zy`T6C*?uYlRZv7ZBGq?aNr+n%%^j(}4q~0m*net_T7F`s$%;_SKOXim4xF*Oi)L*o zR@9#y$)Az)3evLvKygRVwRv99S*64}xn7OT+gjVUXt<$KPrKQA!Cfn79wt^4f4p1c z-RNHDvLv)P=~I18Ej|qx%o_T<@Pc#k0y@ZS@I{8!3I(rZsy@r8HNA;S*ryzKT?HlK z5vwB2?vDwHTd%ZqL?37t#pTAi_J-DXJ4OQxzW0TaVi@U;+96W^ZCk#4FC`21 zirb)bVC==mlkx{5;KT^K|1d&cqC~2~l3cpE3b3Sel~?eJSzrC)x$%uziah7T`s#X( z#}?M<>9nDloY`vHpSG9hRxU=*De9Og zON0yw3tNwap4_Fm0k_oYez{}WtUk_74^rhXsaEq?R6PbxQLI~0x=dL!(w7!hh zDfp!Rv@aTF8xdG^lRo|E==$jH)szy-u(677mHUqdZf7WUds^1eABWlQz3ps39C?J4 zJBDmHIiNA9?HZ!^GEQij!t%A=fraD4tE!i*hS7x&oyV{XzK@v<(mDCbKr-T!orO@L z#nZMzkeCl*x{JidDS6YC>U&Jv;^@adO&Vi`Y;8~V_v;%)EFOQmFBk1(9SDOB0Qwn6 zuM8xv+6ZHpBL|z5Hew`mYBafKG$qLA$ALkP-h12)ToU~#(~tb)+soZ^FfPIU5?0I3 zu*#eKwiKiLE&SYik6)*N%RjX1hr&*jUYmZR^g@~6O__>l!ALLl;p83GrTyOF_-#A`1CCcUS9ot+`*npFh8vl>XXIp0{AP zM{v~f_*KC4YWJA#Lfd|_FnYpF;NI8k?G^aOvGSJqFZS;riSG{@Xzz7TyDo)J-)?Bx z+K7udG*Y(PWO}m-cD**h)pFNZKP>lJz?%;+ z3EKJTKEI=_Y-~^%H(RNOdjp;B2g$aYbv5WsL$xEf+`c+xA$oQ`y5|$Ap}pQZI6PmaBxjX_SkDXg0$WOeaPz zY1-ZVdd7CqUcW0=-9>4Y#(A=+iC^s7Io!CRii=(AC26uLotD!_E1R>eEcKBV6+Uw# zySa=d{6V8tr!sc;DC$Kk5pk4aMvM!OB>5Zy~HKEkXGfunEdj~gxa)4dcg;XEye znJz1fG%dUrRA*H;1=sn$x>Pk`Z}}B*cWvBQwH8hP{Iiy^fy0-R+sQD_ctDTp3X4e+ zEZn#`VLU%Ieg8bW`E+DD&MkoF`+V=V>b{*^+ico4=V;TgjP-%mVsHdfov_|)jp zlfliGqEiV1=7BS{uck|ao^`x$%WEfIPXI7_h!wBrnSRa{_XU`|-Q5d~21-cPwwXui zQqlXKy@fl`gaLt(#gs0wt3xB^P7YBZOj4zWe?=PEGE%J6q{~56?JGA}EK`OKNZpa|Y7E!8~T-ZrvT2bQo zeo*vE@vm5IvCG%koDL+GIxW$Ua}}Zd(-u|t$xD&;~hNa$0@h0>2Gv} zqvB6 zZ3uVu(t80XZ*23j0;Dz8WU31#4L78oJG*AfBrL#*HzTtuOVk#YK1>r8!~7Il4#zsR zZuJ@U`qVQxkLSts+pxL6_u}T&eSL#+r1(Lb_2RgSo&!;`xKy{SMMJ_1z z2Hg87%>O>=d47+?#s`64yb>jXD$%4O8Om*K38~>_6NOpjZkZA2iMvYlkKIv=#pdyv znfvjBS?A=YcmYBb*C!>&{@wzDuyZC=YJC-oe07zVjDJOMnID=|WfX>nq&>2)T5+=6 zF@q<4{{w zS=36L)2iGhYx%%9`9?3i zOz3?j`jB&LC;IW?+3ibZ5;ZE1P>H$9F^qffA3n1U67!9`pEieHBNwx4As%|M?++@U zQheep3TNpS3VXG-qQRw~-M1y86U}E~(p62e(36*P}w89Mz zj_{PD&ccITIlek;>X&6h0T(ZG|ENzl-e1Nh1FM-P~CYVPe*j7YUEH_a5)w6_FqEH%vfP%Eq|w+FKqS5+9n4*z&&g z4LYJ0wenBXXzL2d3=dscLEE9oLyLEeYb`o&2)wf>vRJo|DKJZ)y84Y;eeK(oXic&E zo!RW>#VQQJ%l#<{)`K~VqG$>x8Zw&h8xO-1V)IlG&wbJ!Mx=h8f%NPtk66&?=G6eX zmVY{MfL}383#v3BB9iV7D1L;rNP)Mhj58wVr zc%BjZXs_>ao$$veAw4DunO|A9W9Ap*EBak_(j& zQpc~i=~ms@9;%dfp1qWNG%!XJp_9y>r=3Uyxgb?q;f6F<^m;yP&@JgEZc<5Pm;m?} z)x#$m-jseX+0yC5{ll7(iJjI>=N*0bl*P@K=sg}Dv(1tAu-z_~VoDIRio(QS=#J~P zR{USi(!Fj@p80QC!lUnrX}m@ord^KcbAM2!Niw+pVIE%?ZkqRFZ&&}vIgnr6DyKlT zfu0EKis`}phox?QmCr4rX!&G#^De;~?L6<@f^6|Uzz~D`wRO&SY zAL7ocO4vL zdC#G4gsd|>&DpHtGw6M;%Olb%Nz+U~kKHP{WbW>mOnGbFDR+PIvHIqn?0fV97%U(I z@v_ZvEC7F4O~?R1oC9|}Jx`9)40-f=I8oN#l67HuzxZHMK06z{F)T4q&6$@A$FlgK;Si||G_^ke{J zbM2r7JL8@_7f=g{!LWj6(-^eua6VWiffXf@kqWxx)6IJ=rm~3owFVN$*Em}a=Iyvk z93Ds$1y_BP3Puei=9UFzm!NHw4hKE8ZZ5ogI85fs^3BIBrtm0}$*dEt0m`^C=dCH{ zEw(kx7r0;Fq6&NFZk^wAblu%`O24Hmva(#2x1PpcZhqt7KvOI8QvXiZvl0uP?OLps zyUQeAGI?(6k>3=-_&>dL)?*4KWNzeN?5aC3A{J6LQc>szWXego$BmaaW|SQjTD zl$gk+aJONhaBND#)Y{Fy%dz!5_iou0TC2O^*Xy#e{Xq0a(FieV$j9ID!X!d&Zm?HF zM_etT)FF@Z+PkFh_vldF%ZS}X*~5l9t*wpc4CoY^CtsHQ*d%iwM#8oE?}_r&*yjR zEG*O)`YUQh^bm!Q^h;TtTcgu8%opkE3RmV#Sd$d$nn-3Nkn;tTeF>(|QyB^$wvC9& z1#9H~h}cih_-TUg=@3+k$6K%x9~CY1wX@E}blQPjs7d0R7F!wG`^K4rVz& z`gssC3nana0lW76_O&*dnaTqRF4ATNpmT6^P8Ik?n_42_s`Z*(`za_C`*HIj<7+x5^n{k! z!Gl2A7X9?}DxK8vX_4-OkID`M3GJUuWX)FJ2HqYnb0ZBozB(Tard+ z`IQQC2NN;D?OP``fa9WG(pXkBI*~C>f`4z$_E!@R{Ac1gUF3Y9oQe5c2iJ+`x#f%E zViJpFhg+hZ$q5@V2|$VLKr$ET&#SfgV#$Jr;9?W-$ ztP<=kU!k6}vCEEVahB9Z-O0J16PviH8rY+(pSX!`qpdjFyfW^z`svSFfK}&$C^YX} zEz&*tdbIlbBt7?zOlri@vqIybf}+gTe3ydJbfcC~`a2>I4j+o4m)QASvKCL5*_J&# z=yvOeM`l;zeSPOJAg*AKSjnN(HzMHZH{U#noH3nR_1^o&Gb01TazYYo>;KGFaqSuA zi77lBqjMN}ZC_}-|5)jUTSvTJXo~fG|j+xXqX>*SUPVSR$l`^H@(1C=Ja`Mde! zq7N;a1k>yHbe{1kq;ggJ25@S{lx^M@*^jST0O>1_tC*0v;;ez@b80l}8sfcvPgTVc zLJs-lptmC{DAWGDR&w1=)MC?-^#np2Eu(C7R8~=;%gxPA%V(pqa~hdcnp>q=oU*FqMSd(DP^sm+do_>bf&%w0AZCo#tB9W%AC=bi5<~ z*vlQ$TMmIQ<^&adzoj(F%lb~8O8fd)6=vrjLPkUBL$3O|TAmX>pGSJ6=7+=FjLP>f z)@1yz%!Ym3sog84;4Ko7h^oSE;r-Z(?Y{k3w^pYF`?kLQZ?uuML;G%9?ykd;##Ku* z>wOlCoq@|+Bd+dsm6h5K=;$j;Vcsx_Vm*zc^~PD`z|7~lJ(7{UAcdql@@$9Ia>kaD z8TXcQW-qsA!4F@Yjnp9grI(~Qz5M!J&8TkM&jVx6GyVB%H+9jCS1n7e)9R<0^hll+ z-{bwPw0fvH=jpa&xHkETWbqq{wW@0(b#Cwb9vy#Dme?(jgZ3EGvi;_|800MZ9Jk_- zuW|*JyJzs}DW%MAiVNFh@cx9fWr;}^)|)%APl-rI(we9?=X#i2FERPEjmY1eK~>-M zGlwCEms{Hl!jp{}Y(|D6O}DnwEkND=VEx;VZu-*}725np{rF}zl$Lw%RG3GK#Kx+# zhmNj|IYTEd0<`x$_C}oYEGaP!NP?BVR)CJM5X;s@#F6(j@lXc0cG?Wn`KIN$(cIVD z*9=F7s4TWBIti>vZ4l_&8!(fOKrVZdH_3(bq2BYHQ7!tVp-%?&aAYozL?2aTJA88x zi=H}5+puM}IhDWpT4e;aso|tZqQLa4X;|aPGpi)XgI&<#$V9`U?RBf(1z!Gz{wwqI zcf-WLfXW+H!;LJtf)5nA*Rw}m60}fqdD$yjN;BbE=;h@8^^Mbu`%b96{rHx}+;(*{ zBUx)FndSiBF!2C9hM>*tZ9hh_y>&f?pEJMWR$s+CY**kewWwOos+tj27#Mvc(J*`e zrfD)#l>&BtL=X%?dsDrA`zILB>wcqqx0B zGf#Dm{E27#*thX!WOXXLtBd#c^|=HE{Ty;Ck=PtM4W;g%hH8(#@*j#km!v~QCt2RN zyFZQNUMd|q)Smuz>0H=w$&Dn~pxaq#q*w1)tbL9?4&!i6i}q*p8J>OqwNID&H&Xq+ zPH}}!4F3exkuy>zMVIw@GVMj;4lnK%&ctgMtWJ7?7cATw694rhwzfW%tYT|WdB|?j za$YUHlxN}LOv#LC?K`!3^semC+GbFxu}BxC$fSTO5z?|m^5VFK7xCti& zoU+rGfk*RU;W8%#@HX-JZoq|B0=f8&lr%Q3dLMMHt6hlDm`oenmc16K>P}Pha3r4i zX>jld`}96kT;e84iyHd7B79xd`pmbTc9s|V6Ale9#V!V3mZbiL6@9IdybIB8>pj>p z&w7f=A5VTl7EFG6N7vZ4H@jQbK1?l=V!58zwRi`He5av49U%zcBUFUpyzr~5W9rtZ zbW6UxGt3sM{rNtag>LHfwJzV^MfuBFg4Sv>k*@us^E0e3C1n8<|2h{B0BKELOa_z? z?cyRWD}S#MgNjM(=IP@`*)Y~$E3wMI?7HNhyZ2#P39n(V&}HgEG-5<@Dv)hmf_te# z{p3(_7-74cXL^fg>p$Rb%fIn{_-5NFi8+ok7cm@1WGSMQ+HS6 zvipm;cro-D+Ts`XfIR$(Zu(`i`BlT&$|GFY<9e-@ZrQ)8A1>1zkVZ8(SESh&DQ{Ka z|8UZs^kCYat={>nb^hnAkNToix_J(5@OkfE@x;warAzi*Kl1V3iyMvZitQDlh0<5P zH$Un9sFKa@uF0db{$tRBn?G?F**n)HG23qPIfta8H6Ok*)OTt-sHdxq=fO7JR_1ab zFaIh-iH31r<@3)15)uOA;Ze!bsO$jqtb=orRWztM0^`huW?~{Cl*d7h#XNn+{iQ)xwfeMc#}U=+DsW(YUK;6)o*r`rIsw!#dx$})z4q#@ z_k0k*NtNv4;*|^N*r|%k6JAfVsEs;u-dvm;eJIkCE;e%XsmF5eN97C--=}=-`{Mj^ z_DAznrMs{q#qeRhF-jWMWzU6zrGqZ$q|AKfT-Z{F}lOi_Zc`X&gb$ojGGS zr$gh-Uk(BcZ)Kkcb$g-3)jK6_$V|X^p3NdwD0Po^83U`nRe7Y$kZx;zKQM8%k9m$R z&^VlL&sZNxC(#j=v%j`*wNl{YxWk6o_7GRWQjMHF9bXW2>PITK2liI)^74w_{)*KU z`H7-$pKfdGEtT8jv)Oax*2&kqU!i#xMc=Vxjp`}ge_a@78_&O={LOrO@khO=hUxmX znWI>xYcsRF1f?6lEWt%H@1Bx>CDKLUJmoiiq$%kVXb^0C+uh z1=W66u6le1RUD?T4aYA+R#=JxsNXvFV@mks0YCge2L-y0E%A?k4#9%7w-gkQ z`cr%V&x7Ece;@Y$5a<6<&UuQxl^2yFqm{<6el0*^e&sp$yYA#AA};>jt3to4^r-29 z;E29BE>tf)s`YZ?3gid)HJhF{s}D}>j^R+6_LLq*m&1|MmDw9cLpTQvQx@{WCcAAY zxbo4@gxv0wuLDLB68Nv$YZApcFvX_r6wsTEPrmsjIrg)T&WWvsaurz|{0>7){p*Sr z+gy7|5)#^yPt7N#3CyfaWow4{3E(SP3i*U~2_wn2{fMQ2HC`&$i(bU%7NzijsyXG6 z2BOnHZ;=0O*H2>;V%z1D#4`#i?|0`Caj|DV3HRV_8r3uV^jVRt#*2=FJ~oNSNWiQo z&zE-T>e=wPJ8E09gQ^pW#?PpemDI0#GsCZ&k|RbhIqmG;0o$tfM7|Nes6fU-nE-TO$d+i2%3zu8Ph{}iiZcWOsW_(QCC`>#l%&D$U zR7Q}vPIp-uwV!&6iP)Q-#%MUGvgj`jM~&;4R7dPK`J#k|z$1{VLi3!c_1CS_bUtVBYzO>m-?fV2KWq+4-s*~9&Y7NX`o zo3_oi@kXW>dhWrmSKOG~4RRT)O!LZwpC0t$G7!?fI}g-}Bqvb#zkHyS!Jh~dP-fI6 zgDFnGesn;!!|7+`eL=G+?7ftNDBs%L1LazMgp>Yr*d42{;qW@)du9M=52jE2V z>@a;kY~m;n4s@m_5gY6k?qo3+mJyou)wUxOx_0frmykgk)xKdYaofy=_sW`eug&vH z4krTn5)@4U^Ce6sqaReD)mRapFj(iX$XIIPI+R*{!dmOrg3H#LxD{Z?6-FG7*H^Bp z_(mCqOnz??`z5-6V{-ehsq(CiD)0C5dtGI5@9&uHs4iLD5>9!CV2Y+(_}{$~lP6 z#v*ry5cyJ|Rk0K2g+A7DVbs?^wx+9eHi%DXI5Wkgjk`9%UTjp1Mb9o44H|~Vw^~=v zwwiKeCXAG?4j#l4hc;a<7rW@+tMzHHwxZqK^Nn4DhBlWU#qUv#1!cg(4&g%ioI@RL^UnJ%Ga6n>yw`i^H@bi(ndPkrT1!kO?J6K9YSqa$k5 z!R7u+EokCtOdqSh_Ptrhaa27f=P6yiX=jeFhH-tlNP%NsN^K!h-){~PXIz3J6QSn2 z4xOdP14tbZv7SH{f2PRfK_#f?5c3I|V9pAuOpvO5dNq@bCC_~{6 zHt7n%RsBkOsJ7tL=>*8J0nI&sAGFbFcAHVdM(S`o+}pS-6|&lpDwBe=M$mDsQIB;C zm&WrRu?O|(%3f0<@G=10)qVr_jN$j!AmCnfibUFh{N5>#+gPrsOOMByK|N&1DPG`P z#vGG;YJwH!Cxi;Z*pIPy^pg1@awbBnk~_I7My~*=E67txXT%^h!UkIRsIXPOiv!b|Lg;fUsm>r{JxY^e)P?(Xa;q3{)`g*?Ir?88 z4KoL%`N)n%UaB9x#9=W~{SI~@iMgcI4`}4`H!g|zf|FrU`(0^tof>T4GpW_YSncI< z;ng6KZIC3Jf>igO=l??oD0v1u7Rlny@>0_1Yy^+~@K2IN5y0NURU(%yf>x-gy&<`45(=s{2U|GE!)Y@HV>*CjB{?LM-#FEpHb5_h3=ua4wu zdyei_IT}ddR-Dv)@6*V#y~w`d1Tpj^sanuDso_P9d*&|DD$b6*lP;u7+R#K!*F&u8M+#&I|tfvA@Bm z>f*MQgh(G^<;0J7jHH_c4`#l0Tk)v=?z3j*hPa0H&T+0W z@}}kz*%Fbc;nxZyeCrb5nC?-12ep^cZ%J<8$7t_9onu95#Zm8xQLAX!`jp#I|kle*rTva--i>{_9<8kQZJ>Vd*@iUP9A7^+GhBI=L zU?!Kc%%7KfUR&>@JiaA8O>j7Ykn=Tew?Gq@(|-~f6A8Ba2a$aV0IH>IjbU@OPEh?N z8mtYtFNa^QFDW3#<_fLF?eKz3_s;RRrLE~8s0lF zQs@7V_i}&>9h z4?;X4k255pG_*FJ+Nmalq!`T12iloxh@+2b`6u!(aT*DUKL5z=;FS$|3rzu!hRBVC z3X1-O)i>Fy&ph!tH!%Bfj)qnKI|Ln+AUeX12gGU4kDk5>^R$F#Zv>FBJ%tSZWe7j9 zoLtq)si>qHfl_tndfc#^kVzzmEKkTWIg<3jc6`~~Za_E0DQ1LyhX!mg5f74*#$z17 z48yu5X>9r-&?-Fs#;X))W_LR_V1Cgj$Bs6HUdHxq>+R~&xwj?iRp0}%q1r&fRz1fj24I@F2hZ}pX$TrLN*2}NY2y3 zV1yCrzX>>enpakRoKhWVGdkFjxv*1_SxfDXAB5absi>SRJ-ErJS z<22>cd$;2~Ls)%Fw@>&Rhlg>SLVw)AQ9}V3q4PM0;}!%}k1c9lx5(YX$uo_ewtAHL zP4A)sg$TX70qDt_zx>QJ?#GM*h8FYS=?wDr7b3^By=0=pZ%|qpQbUB(lSzH+0fPtz zT&ARo@@?yW)pjruHjR4Toc*Hm_QHzoC19dXVfA6S7;%gtUrH|E-Ub&4VW6m3q*eQO zlDJD`A_c9Ee>}nsX37`{V(R<<+=ZAKy;pbLWo8M$uWQX=- zd|X1HJ%M>b*6(oJ^f=bH{NF{9hHt@r2Oix#mpj;muZS|e7)R?Bp^OQ_SMJ;|Z}DgVPN;jrtxz0@5wtj&J0?u@s7mGHGXQ*a@lP12^C#vE+CW2R=>31Pyv1d|D@W%xIIELKo}6 z9^o<2HYqqj7&aHmfcgX<1K-T;(0x6VIr=-bRPe>Z-mLt5UYmQ?)?k!<|CBrT0l~;* zB|VfIeFCK%tWu7F76rQr5e|uR`)IR`FL>(v&gJbXD9_8xh4E+q@g>PF*y?hXTX^fymu>rm z?H?`9OJxb|F^Fw$U4G+IJH#HDrMP2)_-cfY&OJq{O&pR|?+5|_OthRiN}$SVgLcT! z&;E1&?5o}R&NZI)#$93f2pQMx?I}WN35%E|9Cu)TZ?^EGvy12h5xNltX;Rf(0LGljvrzrLofwA21mJmj(dJ#`!!r%tO4=AAag&gdo=iPeQ_qwD`dEjG7L?c*a|9^gm%aFsC z=_&aXi28R~dXT#6BQtRF9(r_~?~H(sf#SBRvM<{`wxI3!L)#fTxgA`F|NUUO`e|d4 zCk~S()_{}%n}1jU7nA`#_Pcz;05W7yaQ-_KnP!AIMh$3~Ji24H5Cpiv;2#iBXND)@ zK$_q-Sdrhm^O&yHkPI~;Xz(62A=p)o>I`o_XZ2lVPSCAwa3ic5v~;(VarfU#m!tvv z?1y6I9GF=7nPUuk9bl~M|J$+ohiBi-g>;9`RDv6%6riQ^{JW)dfD7a#T_DQ{IzYhY zkAseSuu3R?4+zc(5&S#|ez84PfRlnX!QRV`fwYFC!F8Z_&cWi^nzKNH&u)t5`IH}<~7 z934q3Ip3e_Jr_iOe`K9!E~x@pa8xb%?Q~&$jkH&UjTu75NeS-MU%vmlD#*O72q(J0;3 zNM(;CAQ?-w(cc0P%l1;7&r#**AwkSjnNQObJY5zj9g2yGq zZd-4tX1|l|_7SC{y?fAyG|mh5RlVq)rLnMlE+3q*!BpC~x{U>Ng+@N&EtlGY(*>S{ zGxI>6GF}J20j&2FV)Zi$CzKAv>RjQXZUF)p8#5(T(aORA(t%=CH!E>jCD?3aeb5uKo;T>t+M|v8v9#Ba#fd5sA9?N`gjT* z<<+8@1v!M9zx&}jJAWDb&Sn4GNp*9G%;jd+WZ8#wBDZ6Ebpn66(I&yR>SIO0TfwMQ zoU)~V6@;fv6>3eWeZ>lmu|QTii$jy6nBlE-3G<_RbQgN7Tb@Eh$0leAU2QfG@UE{A zDvDO~;Hot8kMxl(M2Cuh75S!cmwn+<^kMp)s$CV`T?sU`klj|pNTx-r_Qe(7N9`GL zIT6IBM4ct2FscUi^ zVg6?7pLW(g(JBe4MI;BBB!?z8BR+#yV4I)Ul{>XJO>)t_>6hnKTt=ecT4ii+In9c%~OcYXk(Fy^9dSB?R78dn+_Y5 zpz4u045bIw4YPYM; zV9Krk&@7(@GxrXf?HzXK&RlX4z$R|1+FR>FufGy^Uq+|T;3w6o9nODq-`*BUg;|R_ z6wAg9C*4QhabCPIgtkE~iI8sgyq<5f@R5y>@N8#kN^YwF!E`4za$PLrx6u`E6@E$$E-Wn7V2j!PzZHV0mk+?9%o(#X_?@Jc1515wDkDJ&S<1w zJBn4DSFkf_Z#_CObS@YKjW$;7*KwIiAsc0Z_b+PT|JX*gUreg=BWLHkem${MS(N)n zwJkJdA>=Gj3s(bfZvW)OYKEJJhmMK>WqkoVwmm6183ywqymBz1qp`C+oUn`P6TW<4 z(YVCjv0|v~0pn+@p>qEV zd-}Ig0{+7+5JzdG?8XItsN{+q{2#fjKXV8sFHH{8(UP**@8%2y+yCXf0YZ5TAX1_j zWSXJUNnOEzVe5aJ2u{LF@?0lt9k^1CK%xhB#jH{CT_`9KO#=<>{rS?q%oDQZQd2Wv zR$Jm%?Dx_*%efT8bXQ{)gIw>QwMO9vT!u^}s86yN{$r;eR+5QMKn1kqPrm{5+=M)x zdMM}oXC2UQSp{_kH^Hw8aKK5ZLVAAzbda9&B38a*jVF_ItBLDZX#^+@4FM--g7g0w-E_H!V_5Sj3dT$V;b>QnUPEz@g)!*urJ3IvgR8|@6 zEFcOvU>zPF&d<(rc;OFEhk6lr3zyaeY*{r`JY-p@(mBiIy}h4-5LLVsiwL6c_RBL1 zwVR62aAbk)a8t?!(f?S`+ZG|B6S%sNTyqG`{LHI$OFdv(l~7ypjpDGA*jSfDuDXN#kFp( zaO4|Sh(q9bw>A&B_BJBYqZUK*45zHL?p8-lt)MzvI|1nKc&t%B1;L~iDx3HQJ@?(G z+fL7B+rbUl=U~7a26zAL3R#ojpb;*J=@fHiPtp5Mu{0#M7Ox2F<1v+A+PFeBbz&02 zIbv0d?5?J-!Gx{>nQA<`%jFI!d2Y*^*u;xBO?B8oYUOUV6Qh0n*Vs$Ttq7O4$7)TS z2W_U!@Qhn*Uc^NB4CRtF64*ym6l2vjXcv=u&-J#|i3Zj)_=rWDmJAjWp3RIwN(e&( zvj6#~04Z6*)K$M&Lt6N3JmrnDor&O=7MlzQ2^_i5B8>t$9{w%Pz(|b;Ey z!mx?G2m>uIWF4QcD!G9FrA6EqpJ?3p=G&Mt(!7Z~Z;2!vpBgU+-0jSJ79x#<7!6@_ z#bwgJHfS2nz;tq+T!F7-$t9900iO*VqB{b?cS{Ru4Gq&^ulX7h^<>XTBW;*R6Ff-W z?PY{)IJWog91eY&9jUv5#!pN`O!ULDqbPd|p~} zCkyWvV5}l#a^DhvG=+#)osU9QFg`0#3W&JpO!#?BCmzqxNbDFU*2^HJy7ty(U1q1B zv-Pt<8myu&>eV_Og~)C1)3(3^$7ilB9FI`?5PR|LVI%k72MHue39EZ)k(8L}`{6K} ze|enqEb)@C7cqa}(^6%B0tP#ky|8VEnUSSs6?wU!`P;n({={l5lKeHW2h?O2jCXjv zMw7-r{CRuyZx%_poA}Fui&elqVBpD3PDfvS`thTy%~~L4r_)QCSuB`JuI}YN67R`> z+oh-^xHAf^t~hJ6k)eE`G}X>>jamNuEw8rRr%X6lzRfQLPaR{sl!wgU?t0n?g*-ta z?PeU``AO_IXr~y~#VTD~Ll<}3Ct)SQ%w2dLjXdc^cIv!~4U#$n`~m_ficXHqd~k{M z0x=sA*`e!)OBZ@d)t*@P>Dy3G_XiQ&E?dx3}M8+sngiVO#sA`FsFxE+eKTM1m zt+{ApV@~K7H#e#;U9;wI-cV2x#FWy`!WqCpb^D7q)uQ)5C!2e+sxIYHT1k!$?6b^o zPrdRj7QG0kj{RtrtC9;2*+f`K12HK-4(d^3xQnW!Q$MhnjaD-f=XPu9oEQqur^5U# z45^UM_}!Z*ypqx(-D>u_tK*WQ+P=tSWwmTri^UtMJ+_%Z3U7F|+n2V(Yx6A^S`(Or zvwDX`)fr&QS@W;5@fZh#xAir?u&eJmt5Y@993wGc2!Cu&Z_+zhxDs-y&W5EPw!hv{R_?kE?e-8@nENQyc<#?#Fc$9GsEIwP4wzL*<`wZZj}sy< zb`}bz4)RK0;iNyP@Q|*UF|qhiqJ8|0^7)cth8`35mUop$uPD6qg%Q7+;~y^kWWiCdEm#IM>^O{1-s)uWV?6co8S^8KDHSw+o}9&7=1wbln? zdCAfIvQc8QtOj;H)|U`SIk6fqcgImn+u`qCn?K*q&8}*kCdKT?;e+d4UBe`>V}U_4Vw=6yenuQzd~lbtVIO9U;v6wmhG&5 zu5!;np}2M1ijRhu z^&+Q@??L|7!>4Jm!JY7!Mb$>8i+X-Eo6&cdc`t6;n=YRlpFhbrT?Dr)hA!S>WjPQa z5DW%EY-vMpoOhoziA&0!s32#TgB9`_-oHYcpFv6oRwGi5_QUC*xZmI8?uRko%4UIg z`$79)G0$&=TQX}ls^KzxFL3_V?6u-fSV=>2G+M*Gh}N3TceIdkIS{K;KRMjoHlMUS zknOcf-|$jcYRuizhA&)p0!SztY#olXGkC*O zY%G^s*!rn74C0e#{4y$7?$HZPE=9K|t!>}y7_EsEW+R|=w3K=38gyLbPGf6HG2gtw zao5V1gzgg^$j3kEEPCpRlhX(MfvOko$h4$I66#?X}qBk%*&{ z^TF}mzlLS|XDvV!ad*KpofT4Cnu}px{$tW#&t#P-{9{rv>2-2nB|5tV(xN99w=8yf z9&lJZ-}Th-ZBOby%|UewyA~NX@?iVUBx$#kE!1TAH<46PlDbloUBkp`RBr=mh==3I zMY=lPhQGo=89%X+j8-KOo;mhw@LVn{X{a(A{?$(xgoGw!W>r;sFB37X*!uIF&G7a(tAf%nC? zD#wN#==0!F%hQ{)%$yig4<*p0zj-1gaAvBIgrUkJSH|t~sl)*f3>2z))UA?ttWIb- z_6+bXDp~*7xvzY`Atd8m_EsI#D*m_Z04Grw`y5d7l^RDW+=!jHhxA7d*Bphe+~U8e zT>~h@KSW0^6f1h*(7%Sz&3O?lR1s}Xe{$tutdIx0p1WG7`}(_h;WhI#%-tD8j72Hq zvo{t?5@V`aGFcl=DmEH2n^#CYl`Z)?AK5`$0b_6(2%FhfarM|wTkc!yIzzh=t9P<& zplY(eS?8r>J+{@}ka(`+5sjItz&4oep3$`e%3RiG8iJ-xgv&qPyHE*b6OTOu;03$h zk&)Hsx^}~RlQBFUViSP6I-QXqSEliRUM#J5_|Q-wuz6IUe(>hWCvR8x>8wq%yNBpM zCZoEZA_zXm0pfV&!yQbw>BX|h$@3ICOz1HgjK9|yqUOQkvh>D>sA8+G!p8;R!Ce@L}70)Km9psJX9p@(?6?d_H4mMmWr$BJ*a)zZgoB8s&nay{tJm{-9 zGlH#a{;%;E`k&{N*qqo4C@q9B?(3O4$&`$;=+Y_N>|_}Mqt{R?Sl+*ypM#2iS_nHkTBC}0j9PZYIbA`T{(i|W zxb^YxN9B_w^F94pg53lkJIOpa7206s$$ZI78(hSadT~UHEWe<3cY;+8K3)+>!hlN~ z_L=t|9Ndk^$NG|b?ai`6U4@3RtQXNG7MODc7-IjHfZ>3}3z6noro&Q|4m$(lN~;~0HD|K%o2eUpEUS@orOn{ zB`mPyo<+hCdxs2m?sdb4y{6vSOpX9_zB@5y4n*=gzM}`bz}cTVEXyMqdvf9xr#$36 z-YARQQT{M`C_10$?1^NN7!DihWVw~kau{>xyvSQ3hKmBB8(a1(0%19NH+Mbvait<@fiKi%3gS4nh!qQlTNI%A+};ZGH}M$h z`_J068~Km)#)za++iR+FYIm)d7cZz^Lva)#J$dVDG!@3~&W%CCg8tS6FD2K{#-=W{ z+H04jE_Gk?04G&Dz;ytej(oaW>m2z8x~m|RKn<9MKDNmwx@?g4{WPL%)$imxK4X`Q zZIv^fYdA~T4PC0xH=i_H#x2@e6V*2w8-acSDvE$-uIyk!+Xlt1WTAxK-y9Cr5$d^Z zstY#_SLkn^BUw%IM0dYyhEsoGv96UPk)9nTxmd2G2-b1E$Mxi;6RNV+cnq~M`*LSV z?m()j-Rf%;VZ6-f;)NT$48LP%7dGD;3wxcxN%zga560WeC=(z1G7wN&&QWjTR^*PS zw-(@%vLg99qzs_^N(6|J@xfJbDjWyz;-EkIudMmgf}~4zP0XCl_bcgyB4??Nkr8O~ zCz_+-8hUumZ~!dWq83#_#GpJCTzfMlCN~?hv^uqPe9Naiyg+ObFX=pe{Q%dklyl(x zQZLx%Lqrw`qrdU^_AA4Q->q4HQOZLWul!)G$vcp(B8qUc`8kM_B+F*S%?k*_S88$4 zRdHfKr}k%>6+isz5kO}7RmjE4yJ0r3CQbaqDFzV2ETv zt+rmxU1S%WLJ%sT`is*oIY+@=DN`5Jpp}I@v+3)su{XDwB`r*;-8G$Qt3ZJDvC0MA z6|kc}d6f|;x$!tUI;vjtXM-Lk6{Em}9fAHMY_nOx0nARcyZ`F_-(Ld%C8>@z)?OoA z5`T&EDk{TYA{B?O4HztWvjiLM<39{>hH`5x#%Z1=!tzO;2&bu^Br6!*H@`Sd68h~V;Uj(0K{iLZ};Ct zKLek8%X9%srx?49tU;*>z%gv+Gj>7{ze1k7yZczV6zhw)y|OB|2v5^*FJ4I+-()N0 zBE}#hQ+DX=en5SBf7MQc=4I76mMZkv0DSj-+*g&-Cw}fbJmmb}NAr^v9+bwPbG!N! zqr%TY#S_E{;CeD?$-HbJo#MqciiOI|O2TqXL$qJL25vdlOi1vQUBK7>>|6 z%?_s)cP&;HnM$0d$E>2RBq| zPStI8#x_f$VL)k$kJ~gUW zx$6;{*tc!@$232P#WCl^|91i%3Xue zZ!OX2zV7}vB(xW;dCJKQzMNTIyD`%FtIev*Ba)Rfb~yLa_F$5qi6Zi-mpgjZn{m6Q zctayS4Y3$gx>_q;t|pWM%|*BCEM4oJqaPBMc`@i4z?s$*540DkPV7x-|t>%7O-`X8%oXVd||Qu zW_KZA1y8>%)+yM0C|W0MY|y@8o#p>y@2#Vv?Amx?2?YfQkWyekS{kH>?iLW~?goKD zx&$PY?gnX5Ktj5eE&*v6k?v+_&OP|NuRPEDu5->G-}=`1*7^RMweEZFz4x`ReZ}vJ zZM=+;1@Qt};+??wf2QL;jKRs~IQnFc7sae!ppapbCw8dY_6U$mzMlYjns&3!PCBZ& zu|)U%g#Lg~;s-fyZ>NyFjBR~10CtZ&+FVsPAJf2ZeBkcuA_K{{=-LZR9#4|PIL(;+ zFm-7d$TGO1)Dety-{NB)i)Jn{OP5fgP?I);U1G_n#*7g~-NRslSGQHrxS`b^!cGJ% zBO}Gn!VXxnTyxd6g?z%)_Y7;-D3_bRAa6C;Tvc*#`{w%!=9aVsTb?N&ImyzlTo_j2I664@B3mDBv@aPvuM=aJ zqkjXc*_YD$qNIN^J<>Xq*eIoDF)MUx{^s3S>)ELFnszG;FD}6;XJsl_8<7}33EV=} zVz+W5*+AF|bgjRx!)DaFO7{WTKq~NnN*12)H{#(0!I> z<_d0%@(mzL{-slgH57=@_ecG1>bL0K&+P1WChw|B@jAMAV5UTsj8xZKT$>YmJfqgv zXcc(0SkgEjtzBt)5-Xh{CX`sha5YgiCK^4MwXQaE#gcmnLbNGtTnmfkaF`I&a@2fM z@EqI_m%Pnlcb#|dh0q;rH+2WygK?*^Z?%_Gv-h2?PLmI7cW36%I)jNpLe2VL#IcxC zG*a)*rR_~~=>poNvg7Px!l`1lF^Py61Pb5$zE-^cmY93A?7KlhdjK%sl4F#>9nKD2 zx#L&bK-A+7#MpVhip%n}hON)dCPPhMP&=i;^dY`j5J;in%#f+R0BV0OK+?Uav$03`)M}@hLFW3!u&V1d*OTcd-nfOLemmzVHef znevbx5In>HLq>{bt{k3J2l+bb@KmD*fj*BtsI3QzXczC}$-x|zx6?s>&7F6ZyjfLR zYRE6o3P_MqOU&PIxmtwOX7A-UrX~A)v(2?&wV z@WvFCMNS4Yx+>cV3ZssfeK!jGURPYAu1;W1wH) z3am~wh0G&M-aIu6t}CEA@5PQrFSwAvNdvTkt|Esc@DdJf;2bXg5;R06VzX}%9(GYK;w$QyR= z26;P!efQh_EwAde0|~9X=YqafJ)`pWt{f1XuS~i0Yv6G!RA4G zeh8rZEkjO4(db;gtKv86#c&&xo_LC|Y>hDC+B5VclS2zkJ9*F$4ow=uwv4=yXXrPA zeozEYLUK+gcK6D4@yV(ZK^@)V<^&j@klH@CQ{TSJnjDBG%U?@2iTWQ|-Hip@&=sF! zF5Sig?9UblKlH!H4?I&^jk|9#pgRn`sCrvc^P*fV2!p_?M?eL?@`pv74aqfPXMo7l z47yo;{hadW2F8w|ZJj|4f&NTx|6t0!Q=eKbcDr%LZDr?Q*KHZkMHKFXMdeT+`Bp!Y z1K0M-q5-4L%QevM@|+8%%|qYRK(Q+s{ag5eJ0mnSKIV zY`gBZhkE{nps)t_nytjK-0ymOJHL=@&&H;pXDAIVMb@q>teG<|dhbu*Ze-fu)-{Tt z3)k3{-y|YSvkD{QAM=L2D;3M`7n0g05fTBT!3(P3nqb%2Dof*K`A=6%Bw=t;vlJqT zG+HB@0{iGOS*lZtbUNc|XB2GknksHv=tN9oHyDc_M*p@`m4GS^u}_Y<%QvbofUOUh^~HA52QMp* z?bX>a_A|Z=w{~2~R0mnio10nd0!Y9&3{tm=2ezj}7PgzopZZ3DJ14}ZmaQifLeI4E zglgof1X*3P{GGr-UtsSa>95gwtxI20IC0@tECVefHx)3!2i=075RDOm@LF{O{@L2d zh^LbSC4%*}uWeb}8>}xK)BLt!@?r;GvFYb^`pq7zfaPlVJSxqY^XN6RLcL?C;7<6C zHU)6i(97P4Lrd7zB+#JrQXv9iIl6plIXm#PHnUnXHRg1*@CSB&Hp5#P zM@ytTK>Ne-w}Kx6-n>tO;QpkGs6LO-`a7PXc0hN5QyFDpw_1A6K zhd^H1$nvt#O?d_^xb80I#%QSAlC||^EuUbeUW87^-kVp%lEs9;$t*>v3S7i3gMU0C zpgRUio%%}Ii8h9ZkVb=FcHhJ5-xcWprFI9~g4XX+Kuh4v9X5Lp_jk8bcWoKInFnLn z#RmpJcDBL%$_vD`BR7ViK=#5a9!&7)Sj$$2T5n8H{+RO#TNci6C!5N{mm9NGyksKi zH+AWs0s;p@UnI7Cd#-)Khw=yq!s~`+1N=WG3T$tt-2D%|PLlZglthia;c|9A;ulpo zCdr;vbT+(1SYwE+Ctw2Xe=9S5JV*gJ{p6Qr5RRj^x6U03xOYjkOg5?!1tvhgAYj5u zB5q*)SAH8n%OZ4=b@gpr0e9$_-R60Ox${cb`8t2vI$wi|@c>!T-2`u873THl%d4Lp zzw00};1GX7h}xSCWBz#k|DSG5^lv}^|NI_G|Nm6~g-T)nzshnJB7VAcuU6mcpTAG_ zp*=ZNygE=kZ6$1sTu=%JN@`-SjXcI1qWCx9BjV@O&nwl?&=6AmvAld(ApR#g4j~$O zq!z*i&-%NtLY)66tR|h2U)2vc$_GBw)usF-|NIb*BJvsHZHNEzwvxii%U@sba=(1+ zYSH4i5RGktNUa6`t^5r^FJSgoK;sbtbW*LCk87y@OVR!{T%)Cw89(UvGwU{5w45lA z-v^)|<2O+d1oLIyz74d}X>IW@KX|PVWUB%fsseMn*;(USMln4B=7#NjlAz~ac*cdX z3_BME;FxOx!v0^Vj1Q#n33A!J5W0>H{CG!|oR$R-5S~ z?vZ2SKegOlb%Q%)$vZA00IVqVGLhFQudZgHJ&gFr#DpcfFdaa)>oOeWKLTI4!uRZI z(0;(nc3QGtJnmn)06MlByqcHGFX2dzQU_yN(IY-WID|ZQ{dYdquQ-SXb+%AKEj$&g zfO~*;1%7$!SBt1P`QTrAhn$Z)B(J0T2PFg7z$(y41-_FJL(6Y(FqXXpDNtz10njDM zxqX?zlGp=>aFNW5>on!aKVm#i42@PBUBGh$_jft@rzsW%qMQu(jy?-%ATG#s@%)Ek zh%IR_ZXdK@t#;_?!+x(9_8Z=0n`_0ZhFnh)GHS|v94$uw>X1n(+F{n+F>BuDK`lf> zlz*|@Utp!C*qjeQ=+l@%TU&N6`e^1~P?~^>#Tmrz%>U^J=qH}#AA7ZGbQzRh83dX| z2?wf>%Hbc}4T>)Pc-BUN)QJ(sq97?8pw4P~0b;VZ%3|eyyDIU(r7qBZ8gU z#z=Var|M>R@Lt(}2~u~lta2wPQSEyTJC`9TXDS54AdHuNo4WXPe#vUC#=5dq zlSt)QRg0ZR^m`9Os0Tlf)Upr#RvM3GQoNw16uTac3T&=Ly3dlWG#<#SD-Ol@9H~RKLs`tw)bh~ZRx)CJTv2oA#*$n z!fVvO*Py#})_mf-<7R4(x7e<*c=TlV_KVQzglvvP^iwI+_-=Sd_>ks{xu!?}xxnvW z8%dG541f7BjdjVu?v1~bP)d71d~(1QbMUdAQ$j8$;6Y-Lkn~uWG{ipwop19aBRXJ| z659Yt*h5~MW!p17v)kF1oAucfC(E76X)DQUPl@JSc+Pyb&#T}OXCWsrG9hmrMEbX7 zewrC~Ef6B`8^wOYzXHL+{1)_byX7MXG}sk;PiPc^#hERJrF>Gd{QCAH7cD03gTP!c zo4yO#X1nAUS^ySf%8~Z6&Mu09NZ(qVniROj+p%Ta})jD(C%7WRQi|9QFfALk^7hRi_7h z@}L>XKVkS;`|nSmK823!LHvtavQYfQtj`r`)cyX`RC<3KIm^GwGG)K~YPv1v3>Uak zD7rDDosf*iQ$&2Et>Q|A=iIFy#oOSh^`RF`Q=jy6Q-BAG89TVuJ`Y{MAc&OX0n?+M zfF1!QDS`7qlz>*R_^HoAYldBf$y7D*(cw>ohRL_GSZ#1mN(s4u2s4$N5@=fNL5!@> zuA<;@*1;#zmdWn|DsGR&#(BF1_GtI;&$cqnV!coTu4au%e>GG^yUIO4K70+2-^dcO zT*2Pj{CRj{&VWZxvzqasJopmm1Yy)TMpO|+<%e>G0yv|F2)UpBr!Y&nY;f-5Pd&`7 z5jXvn$M#OrSByyZp=Eq=vu85)2kD^Po~P#t`1c_7EYztqx~G`neXHKips2kpF$a2& z*N+Hhi)h4z?z)t)a6!>;V}2HLI8zVfAi(X!C`TW?;)sd7iibE? z>VNEdDoilNXegig64`5SeT@h*&uTis&Uh@a?AZPpOVP2g{;PrD9Aixf#h~-&p*loA zJq-96AS5qAZ<7mgJOsXRE(Ka1bG`xba{K&@GSG6JbMNU(+|dWFrvOzZRcNgI45VqC z5RNm4EVLJL3_gK}DX%s*%pM(v`f!Qy+>*sc=j4MvnRuHu1jEJgQ{9c-A>|ZUJBrt% zPRdnTEf>>=Raz&c3X?Ot?uepY6>;7r`5?aZ67~6Y1G>yV3Rs#Hf@;RRpon3=uyRFL zw@rKY&3P=&3__K}hK(saong>3;Xz*A$oI;E7zZML8xP-|VFz1NWd-}NnTID!Y?bI_ zr~$PX2zgzsdVZpbBW=uXByH?Sq<-MH9yVo6Ud zN!cafOnpMaPf(|F-opTg6)vj-UR7{%E@j0=Xi%PhryHmLut+J$<(mjS?8bx zgN3H$_wj7J8T-bTd!RxFqpLgS?5g%oQp@w_FxoyKM!u+cISWk)a3!v8*ZAs%m2i5p3F7VzcPM)tiudiG_6@Ie7dNw-hP-ayI(Lr|j77 zUu}<9VEZ}ptdA<)imtSIVcN_1v+XKwZb0$!6(x{O*M~S;!+*0iIm9z3Gc33lD3Y}* z_K2S(yYqTJymnTXt^v`Txa8x(TYgmRXhb`o{k5V84V6w#ey3L9K*l+d{+Jhl^(lyC z-hJX_Tv1kPLm|{4SW(>6h-Dd64&-AQ##T!LM)2c4gawrS#{vSjqCn-bQOrO=AKLnP z?5I%MVAf$rFash5_rC-t5r*`&AM+n<`|l^fgs;EoYmpiMV39fw^p*T>Y4~J4Njd1f6uxFWCTHkSZk)B0~pT z?vtdCP6NHI z3XhvN1ckSQPs&@P!=~HdlNZt2XYV^uJZPa8Jo;@leEOT?gX9%DExb(CCRxXB-lfr| z_vcDY5yoo}92(qo)p~eR%*!fV>=IGipb0u*sa@V&v#VKTJme%azs|gP0iak)VQMGF zHv*f$^+0!HXP$hAzuXiMm*Sr$WPVKcq`;(H>PoDZ+q(6v{Crz=y3}F1+F`N@Hc|We zxCaC6y=ahVCl0wXrX0oV*XO%|KeMJA@I~JxLW(0i4dr_!6Sm9?pt!x&LM8T4USb4W zXNDn;U26+iqDBuG`GCeK~qRR2cG(u*8!X8QZtn_wHlxq_s{C%1~B=1qCg-@GWHQhY`Y* zt4jP`aHcA0AC{U_rPp&;=bzpCaywCccHG^LSx6NV_<5XyD9|D`Ub`U)tT2xgF39>daGNvLSQ<^W>;r45Nk}Z*sQOHWb}?RR zSp8M4epjJmgSMd89>IxRC6#znL+iNoQ(B|Wn4gl-6A9DoGVRu~jrXs=d_m(2wJ5oW z1W5-Sy$?x!4D2z2g?tek3v9P9V!I{9Z*KRW7g%p01OFnzSp`19lH7Us-+#OQLNibu z2(YI3-&pgn?*cENJVp$R6ZL=hz?pogmnRM<*@#0T6Ji&G_RL#+!Rc&aE?>g#%s*z& z9DXk8(k(0V*S>%$5kwQYxmyQmzHCZOXDa$}XtUG9_u>M?XuyJ1ROUj_GQn_9t;W+V zK6kB}F1)kWj3z0;BUK!iI{nBX*gjj7V8>UJ(kKSR32u1+aNAggG#bEq-^HIHw*T~5 zCiQA2TB+^8li)sh2H6?q1f0+++&_74$K287Y2jSP;;xWUo4`;;_D#aJTH8 z)j@c%!U;lBa8LtNmF>+Sy}=#Au)7r$;31^7d8SBd0Ij&J1*zmebWbdg9Q^k6bT@*TCt(MhFJ|We}or#Py zuRM+LyCl4>&*>WUsB#4UrmNrK;S$%obP$0!;9O!i4)~x8vdF4|`e34FB$mZY&*Qx$ zq;}l%q0EE+rXCoVa#fOHZ`pnLUZ^{{lHNTd%spo}x$?VZEnjx|E-vcRA4M#{66|8G z1#th~DOqI-SEw}eJsr_@fw$yIIMFuYmIF2xJi{%$lnE&DQ0X-2&Qes4UPomn%*|^ zvd;B4pYXr*-kX8-a=Z7IGYKS-xhMuffC}IxiE5 z4I7||B}2wu;k}m8!8c@d<~AzwP>T+IB*-Ec`*!6}OA{ld$Wr2kQs#)_5iK2f<326r zZ86J|+u!E7b1F*O$m;b-FW+{=G!VWz|Kv@*m@h$kp2)wY12Np*Rf+>qM=5bzsNW*S zoYsAvsbTh6Y_wP1>Ldo*^mc02sj5?*jPSetQD+jNX+h)OFs+Wx zxouuHW67Y~ionC6BqH}aV)SYh3F-xXbTQP#6A{4eF)>`vCo9Uf(}MS|Rn{E@IX3@6 z4y*LkBEL%?H?s`SjiPi}$PwNkT{aj00hliV?*}zeV--oS8btaFj`5e194E!#T5iQe zPZW0W2nf`b7qJ+|97Ceh5jHJdHquIpvp6F?G*0!jx?Z#)A-jw`_rq}8o{Nj%W0(Ar zt)FPuddE!o*6JWB^rp7)qQwRnWD)QEoqmL7gj1K;Gb$`r?d>*ADkBxybtf8Em`fb* zRaOsZLGXx0l6B-Qjru;a!dSD`Jh$1)SlkXRG$2%}C0>h5JnZ1-rOEkBZW|W@8;_o_ z4T&u3PtfMHlmQ&d`!T^t(!~PD`rBYX35r{$;=bav9Ng6PcLa5J_uzK`C2LvgQf!RJTQVb$Ss*jrd33&O@r zyC;1Q<;D&IKMh8&xA@log3>{cWYC;7fYkf*w!P3JvA_jltDxJwk! zE4~JK{!EX6xj)D^Y^V{vIGe1r-YZLJkhiOzdvm0f_)p3RKTd9b%hLE{K-{O?G|JaPX)7D{9*#v}WyBl{2=CHBG1TzoD5>Ps%4WylkD zFcIfBVce?vs2nD+&U=xoemtx-7y9uZ&)+80FHm=p9l1WV=v8LAX`TxkETN7Zud6qm zs_scK6%Nj`WI!!yGsGyUH1yc^U!{8?LVl8+}G(d2F!(n8MH$NhFN89CL7*>t&Wi&1{V(>8*jKg zdY9ub$5ZWX&OETg^h5aR0R)+j|8JWyh>Vnh$@wjUMK(TKeF4?13< zSDOhO&+_0f5b2bOMu|YaenT5Fd_B}cjjB_QC%PxHtWxzTx2OgO%OTD8#oyK`e04V< zUh_C!!@^IgD%EI2-PG#C#_HhJF;UYUTn#-O25>L5Q4{ovh|m7W>7fv6C(_@>UZM4i zd^u>;07q|K|3(quMqOlQ zIR+zC(U>sG6vj`|;eAsK=rAZ>&?`8!)9cOTRm<^b_^Z;};xW_@8$B(R|F+w=koV;? z?ZjkYdC^+WMws#~=t&|$^$ir8LEvbRmgq_@L0)X>?Z9=aRz1+Zr&aFO&2=Z*BWL9j zEG;V}s9Mk&J^(R&5(bREJZ7-ygBEIHqXeLEM2f!`9uhx^Q0ai|xmHgGq<2T|-pa6H zm3ZCowKX0yi<(4miLoTH!=hrHza+K40e4cTD-2j8wqowfGOh?Y zmy_tY4MqR$8^CImXt1>ivINyBM6d4l1+eCdV_N5kVdiG#C%uL~gov)8-I(+BPgP`T zwD(5u?MrU4{Cy7rX~2S%({}#FOWDZ`f_?j+Be5a@M%i8AP2G6upjpmGx8M8`zX-Ap zq5+QuD~57{N+Xh4KE)34al| z85O_^8VPD1B3gU#UzIc$O>-ZuN z)-lkzHzN+~O$k80{+uuhGNGyInYU)#F9_H9*$1mM;#(=M&vX3Ac3(Hs54J3npmh%W(y zq3pujxzG`-mYH;l`HW0qr6*(`?VHs%f;BzNQyQ0d1fjfbjC)+HIr86`)mpH*39#84 zKSqqWSq`{66g#Hnn~iUg5Fuyduo|T&lHMnWCG#WA&$b;lw>ZzFcxIXT1bi>vJi4Xh zm^7PoMn53n?!M34jY6B#wvp{z@!&C-qbfev|Ecbp`BuF8i)YL~kgV+t*^i#B8tBEo zfDGJ^g;Rka;AbBu_<8kIi+oBwzQ4-ek}@_&e6lN=7X_6Dangx!=n0tZZ~6kax+fEu zLsl-!#6Ut{80+lQ6~cJe>Twh6p`8O0xb@7v9t@zy%h8K8FD#yTXy>-bnG2JR6l9vJ z|LlXNV_^))dH$BY)I(i}MSD!@%tNB9&-F$-=GDRXD8LktC8)X-nfXjQ!Z|gy%gyQV zxR+1&t(Lj-1h&V?t?6+)3_eh?Y>4a!;%i3vUrylQs3rwb-4M zEMN0*C=2TIJk*~Cp6uXU%ym)#vnJq|MiXK&gII7$2bnc=CrpxqWry<*WXw816Q94t zGqx2-qfth*MQq0Oz;o7rbo4dOi!DE}4|q7w?uc zePA}B@)Ra9ntq>hR3SuTQSUTf@7}Kr{Nau-p>ZIZ2pOVtZ#K;PkyfLv^j&T`CQVx2 zIRh+l#ES>1FG3qgBCqJQpM2TO*S*}&7EE;e=$J+ttC;JNS3uSub8!zAB}*LYhBG!i z;`ol&483st1UE`CNshLB?__}ca^HLJ2acIUGCOIHe*rab$|q}eO-enfd+DoQU6B>1 zb1@TNvS$cg&A$9#RCpJ9Mz_U@PYwR)T@J5qgwb#1_MB|Q(v~;8mA%j3x9+D1DL_7n zKN(FY^W19|Tz*K==5WEB#z4_8^%;x`*h1(X_lC%OpU?!-VTQAGO`QF>1W9y^(Ta_Q zJU6#jynhxltKG~mLqv;h$7)2>p3v?bvo>xp;MCf0p2?FxQ22@^N4XGHZ&it%dbU1^ zBfN?-jjnwRj^E|U!h}gM?9{l>fay9#4|*Ank7!! z^i(_a&({@X>*oJ@6QXIzj@e2_2B89f_(s>U@cyF~-&{fyPLj{2ThqiLRTD_VdPqMT z=D9FgnK1!U39ow-R6gK*;>(&=Nqyu`vn)mfPk+(xI2VHI~>hoJC|Q z*i(H@Ii+Ad17|p6G1_YT+zhI2IbMuSRjhE2d(vCG<9nN<=r6YRyB(ZGkMiyNyNy^* z8-*p(=Se9%?Ks7Q*tfKc+-xNemo4>SMYCjZZ3v#R|M7aNd0uFLv%?nMRswKH?(64= z0ccUcz(XckH1Nx{%>A>Y(2XVS1KgJ$#xn5akF{0GRAD?n^C#yXr@gr6#QW{8=IN3* zZ><@omM9;Vb_p4j$+qB2(OHP3L)%-Z(kF0Tz0vci@7tbYC^^mPpt!NGqaT$cQdc8) zpa-8cz>PMkk%b(&&E`LR*US#eWBl+Fb z&gpQnDSBG4hu8wF9v+aY$*;K~@vES|wtc~oWEC5z50hDi=+|zs(^D|M)c+X;I)uk< zJ*uGlK$Aioj#YKZzv+>!(UdD@I$FkSJsLRTmd0RGSB~%KFlnMG89$6(ZYE|dJgdj% zt-tC6I1#TVOYN+@9Z|i$zmy4bpVw$a*HC=^5!W9Q^=Y=OcZz{RyK-Dg%znpG&&G?8 zk8*#UR(`av{a2w4N+(;qI!}V(yO&wP;+#KBq=Ot@2HlZX$Q4tLm#yQ70=aQ5?%|^_ z!NTmsg*f0)X*0nBnLIl;PLO8flq&Keo4(gfu1Bc5UR}4Fz(_v74!`xWS4k@ucf$VW zzMBA3Jpwn-0eePwG2681uW?(;{uF!EcNBi$}%b_v@E~ zx}E!hTB5qI9IRUwtBp9TKZT-pkM8vWFnIrBF$K7H)ov-}HW70YD>tauK5w1+n-UkU zO}-&=&A8&DkGL)tvRev`Z!7HM11?Z0h4gygC@2L@MzRs@5f7VLI;sR`;sTD!zJSRWQH$5_`UJ~vj6!VYKTPGO=Pca1I| zJ<{Z@Coav)1(bGPryOq4W%`yE>u$!Nji$9^m-JXoaG#}aPh)p^p67D5SpXso@$&2A zYmvv3pN-*ZcT^kF*LKG+NkLa!mlCiI@;yYT4Dgccm6>=CzG<@)o^e-ayNsUuX~JZbSt zbLx3k1peft;%ytoL-y?zIRGk>lQ2jJu~I(A3~fde-0{m1)IS|ch;9lx+7{s?i1Yhn z!>@24UB90Buh$2u{B<+_r!W28t_4hBAl3+IlP!YlPyF*KVAYFY?i}5_kFh%V*hc!^ zKked^myVQ=lxT!7Ffj00F|6~&zu2genfXM=B4CeYUegSC3OfS9+D6Yi_=oFaChxn5 ze|ZPt7@AEB@c!MKzvbtRi+IwC)4LZ6P~xBS`rnv!X}xHhD1-x@I9l{?raZ7@!Bi7U z8GBX)@|H?>(;q_kBLxo4&?1kQ98%c#<7` zd^xH=mH`iwtf@x;yG@jdLpPmgV2z1vNICvZNB~R}4`S}&oWE?I`-dMUoGF34ftX>? za6r5|K;9J@q`z-z=D_a-o>KrPP5K`XZz1CYerk?bwKn1${^wKapgU}Uejp+MxMuu6 z*S0seAo(;?-rLnff|nT}BU(S+KX~FX>m8(c_2W5mU}ub>OpX7+Z`Y#}mey1B2LH!@ zmB@V&TCeUlU>pADQy@TyjRM3ON+Ut{Ti@h}QSbOK7k92j40O5pTvDDa9$KfBEXi@( z#&b9AIhVmJUAorw9h{bX2fa53z-WuD-!-BG7ksSsXo=wjca*m7f- zQ@Lw4l47}Di9gNGY<~EmW4yl|9_Q2?gA3_HD6ul{zdY0>|7Ki8esU~*t`qrci%2?YO-TudD+dHiBOKIUbD6%-xGU z3E9FnGqs#Dd>p$!aWR#Z#7!nCkgv^dn!hFlz>(1X=f9_r$m;jD<4eya8EHIwPu*hP zU9z_uL5i7Oz8+p)y7BSxcW6nISzk(^4rTFO1cXsJYeQc^s0d4?;N5_ax_DGuT~kUi zwXGl5&@s@{!e@lC@&2O?jHM`d;fJxV*Lmc|>4FyBkMM|$sKvUXgcs^sHGs(=WDtAV zZOIEAP^Mef(C>&9tJEuY+(u{Z<943rwLi7drr46ZA&=SzPlO;+WnYH z7jx9ec(&1~72*$eJpODtkydXr{~=YdLM}X>Pb}R_*N3^ICMmy;RAG2s=S6RZ4ipY& z3#mMJUiT#6-wsHMyO(&Va>3^_Rp6K!`^k+ss7RBygl9n2?^JUUw=i90J{*_X9{SwAIT`W)MtG{;&5?`$?c;^b;k@^RoY}MpbKE{Ujqz>d8Rfs_mm+G4CiJq%@Ch?8_h4JMC)h zzv{$DuGn>VXso#U^dNrWIZBqpV#P=PN)^!3gE5Ex-hStAZX?f}RG*P~Jby*{B)wRz zzfid&NqAUZK+ed}=rcCXJoRUM^3(mD&iz+917Z87D>?~F+|4akj{Q+PCy^4s>e%yXC=rA?QL@cMJ*J!BFO38bz|+t z1p)JKB)xnLq|UoUntjH>Cb)pzC_^S^4|KKH-aklFnd7+FAE8b-rS^8}pyMVBX9 z>KWh}Uhag8?Pci4gO^U45?u6;-z+C?N6))lgm*SE_FRn0vCYEUvAQl3^rLWLmu2T+ z7n`2~%5uNAj*ObZj)Em>+J^2COevh>o zOY51Z|zg&ZD4u;yPEjmRmc}KCr~ySzNiiFUdvu;u05$?;1&Jp{ayu$SUd6O2_sjwUGDJ3 z;m#+iMGI5SZVPzU%!fJG+(!}+OlAJ@2U)949oB(nFv)A(JIuQpsT&aU2z03Os86K9 zi5_a1Y`7r9zlXf+`C2P^HPcK{qa06%yKQvWhF6KNt@+e$GpxdSL$X80>~*!7j>(*< z;_|zO)q%Xses>~|RH$o$=TX=|x75`fpGT zrbCatv64E8g%fj$FZ{GVi;+fH}UxG9!l_T8Yt`(_8vNS-SmN@W>(R!o5)T2+Wp{s&#T~ zve0;_`|ZK^A(vi{hWC6)Ui-9hi@EJK*mZgeW(m+tIKiP#tg46FNBj2BhS**^xtUj8 z{?$fw_NFpASmR;zV4Gdmx2AV&->9o}0T(@RtyU!v_Aqet6w9EEJ+3dS7sj8hb$F$l z(}!_kpDV{DsyJtqfCJ=hRPmcV`oMh^X5#CN^1_EwS5Pf`zJeJ{FhR1F11gfo)UXF*!{8ZYHR8(JGpSWlPL&t9e(_IF30gG_m#>V+9LXJ}(Ljd(C)CAee=v z}i`TNhs%Cvwv*glPQ45J zq<;9HBl~~8oha4fHV}U&B~V?uZz%y88NmFZik^N&a?$worK-8KH5o%blr`Rm#{9>S zODj7#nyC)F=rEiA?OsKfOM3w&&&gsBqaj*CG*-waaCoV=AnHzq+_kDA$#1mZFo3#>1Tc6QoErPh81HixBtjThdCX)F^|)oBhpeN|&z-pO)VSqsfBHqwm; zX?32Uy>>wH%n{2SBRI2)9i6WmNSb}MqcCAB19=f(V@d=)qN=1YNV%UgjASw+8Q(b1 zA!rKS!RXV#!JupxWf*Gtt_E10aY1*SvG_cPTC@a-D8U<5EESaCGTQxqZZe7b-H((F zRqg`p=C7qsHt{*J`eflM{Z3yV*JTi{LcJ=!x9X|o;Ps=+<9eFXxq^b>^ndayaIra#*+9h98nY2BT8_1?1N5u-ZL zo~~JNMm4IZl7SMt=kO2)^i-;?S$xz`Uxx4Ab9<{x?iIX2^A zWMZN|gA`1Fyv{`cMRWSF>hh@zaDD_uSkd}ME7*(7a&g@j%~TYbik`L9xo$*$+}4br z;a;cxP_&90bPhCkR|!{nTju%m2x|NO0%SWj#n4M!AH0ZvbbHK$(#@yR(|cFhdnhYX z0^DIhthZ!{Nm_5K$s+WcTMT#2O?gOU^V`e)fyIlsF_YRw;Rz%tT=187gyUy|jt9T_iDwrM7?ZKP$x- zbhh@POh-wPyTtRE}H3BXTgu{E%Y@sLYxIY%{mhNf|-)uF_^@edwMl z8X`-nE&nr4=qS!AwmVS*?3|{=VQXo)&1~)QRQI$-M1i7j;dT+_?1t#7LmR`;g zn|Yc%bl?7>jCRZ|3-4|5*cVMj$Mqq&9)zc#;ixYMf(A(t3ernU_@;MK|GeA`VkB(# zrhGf~{m{sdtb?QmvOqWAjZ{*v)Xv7yRTH%)N`kUcD+YkN^-QaovU)CCj^DD@$6t}s z&bp}`X=6#tx+&PYNjyDxb_Dd(t&#TL5+DbR!*1p=l+*>mPM)h5r`|qCPSRF(wqG@E zQ3m{}_XUBbT47P3HDLh*LI$vBtBC4;be!-9TDu)O*NmLxwA@BHv4M#hyqe=V2|5E$oMd znFuGHS5MmiP7+#c+fa;t>Oq}%;gXJ9(rYt>o~HN~y5fne+}s7^r^?5()LeW1%^c_l zCYs|MN-y8(;iAg~xnTo7wTkQkB-K=eIh8q9zG?lb-f9$Res_MVffmptUbxSHd;3bL zW)<={$JiDz&A)yb?bXV`BD_Bo>%A?6zf`geD$QyA=oIY#He#~P$jhstZz;GNxQxPE zUK$Mm8?k>RNpt8RZ+opJp6{Jj&Z_O?rf~JBZ}AUK?Ww%{ms9!}aC3LR;=T`ft;oNE zRLo0hWYH?}8Qr0jQnK$Buj7JrS;$fapz8r6y_Xz;C9qi_1H};)sb>?pv!lDk>b5bI z_Tw{u`{ZD(Z#+m052T>k3lTtIR^cMoFv}fP`&_!2ZF^Q0gs}Lc2HwoH7<5}PqqepMlen?P1;iQPaqR#lXci2|A=Sw z=>G;rYX5S_Y5yy*@V@~&)HnQ3pr~o-JF#66Cu&jWF8{>OVBLb-+~$b$3&W9Ly#Wv) zFst4~pvV0AeT35Jt@dzq{Ws}dBuV5)pn6NFx1jm@$zXnNZE- z?4n5X695H-LZ4u~29rf3fYix^sOki3Xr4!dG`*{=a15_JW{G@HK`FvSFmu==nEjhR z|4AUz%jAjbfVdN_!hfp{PS&=zW!<` zDx+_g4-$9O=#UvokIU_dH-+~({kZkw6)AePkvHj@0+<}gPsh1N9!0_sV99MDDZFBEod+8vP+8R3Y2eWU^tBv)DeccsPgde<(Ads3V zT1a{16N1S{e?p%bkrA0|zg5z!)oFgP@{ztLBzW7himAi@7AlR{Z={Fd->kJ1C;WD( z1rNit>t~8pKi+}c{ko(dXWf>XZdUmuBWE#GyG_hNhc+qlJd8vXqH_0~LX=PWoHU<* z(UI=;A_?A3s$@q4l0dY8jCf%CQSSYlI{UH);;RPp^zp_y#9|cSCC8671N@OtC_zpqCSOlm%M1o=&U~U8r9ZuZw{tF0I#e-h6QlwK!}*!zo^Oj1zM`- zuQ^vunN-{88Y%!x=4r21FGwpId|K!jMzjE{fpO zcOcQxuthRj#YP8o8wp)L^7qGBZhov#CdlMCo}W#xPb0-1B~kr+#G@Pa;|0kPsSvs2 zl{e0QWm1ysAoY~T`8yjg6ZOToq@yD4|H0l{M^)KzebL z8C&htOete!cpw15MZLxf)qd}i>y7)=9nFa5o+m-+r4QKkxO%j|;_pXaq2up--&vwS z)znCR9;jVzbAI@1cb6;l&mvy)1{x}#!Ck8%b3ZIwRSYEFuk-XQ7KOF+`Vtb@mn%K0 zzeHq@MX#-r_P14Df#lUAJH)qpW!Z0`U0Ou<^}>dW{QdR6kosrW7F6=vg8n5ffCm5= ztey^VMoE7hwY~6wgrA_^`s?q&o|8jh=&O-IUWy$i!WUUmcezfy{tebwk6rTv(YJmZ z#-CVnDv$vU0G{J_{^MWyIhnlxocXm109fmSNcx;IH;T0i)5Qi+?XGLWph&ix1!sy(HNmW(c`P}#0p*_)v=ee z!2asjn*2LZ?CaGzWa#@(Q2Br3MEe&=zcECjK$NF!LAPIR*NFtpm0mqw;XgbcP}YY+ z0SD#RQ*`+3uaq>vz^@#J79bP;|CIOvg;b-&{2FR8FSR>HW!-v=K}E6jkMc5X-BJy(5sS(<^+MDPow-(0tCtH_yzt#kIDaLWFUW{7f2ODV(Xwf*np z0k>?jweETk83rK%w9m%A2v-A&{52pT;jgCUL+9GJExsGqHj$p_ zK6?FpN=sWiEGa3e#v4lUP@@M3Go=Z2NyWx7HG@FP(rt0h{;g_M()(9vzrFIR!P4B! zWgu}Vq4Cz5d3((Hv>dOz?$9%Xrqb6~^1FVoie35fA@T5D%kxiUEfr5^PjCBrF}a15 ze$=+-#zNU~Ubk5dtP7xRTW|G!MAzE7f0l1j5|+>|x5nUU3)ibF*(|oI8R}}!msOvL z!*rdB*N-+K)<1IAL#f9>;A*Yn5@+mmCWZ0sDOO~(dLtRfPgoYO(LvFOy%+neC&utw zx1NME^MqDw`C`s4r1cra&fD3xk@uF3Lb~iaeYvFX`PPu<9qZ0LQC>^odZKB0*W75+ zY-}?AXPM`*aJ{mo%7>MK^2x*n<}D2m;h-+q!OM+D-1nu9n@~iB2zCxtHADhba=k_a|Rs#8^cZlSh9 z_pznn6v|~FMM7`SD`u@L4B{zZ!@^oZPUlfk#&UnDs$q{1NH)n2%w%aIcup|eGCs2= zl+-Om^URZ$M@L@U+RHDKyTgLAH&QYUhgsZ!>t2@?Ejh3i5%M8SS|RFK0a;#nl*bZJ ze0|?~r_L13&kuBxelhCXC7BZtFStGEh1YbfHZnQ5;~slvIZzc@0@g}Q`~>r@`E+I( zlFNLEBoWKKL7nnyE-l)I0jm*^O`yO#-TUz@C4h&D3+1tl5;e>(b;fT1f#=KybERb2 zw(86h4d8&NZE{h!Rn_SSqbF^jOb_9uRC&&neFA1QtYD9{lQdyJQls|eiJDk8$NiXu zT(NJVBRe*f)!+lvxQ(uv1 z+H#t`E!|Yl8_)NPQJP^|uKN39Xa# zkIE#K%>I;EaDgQpJQ{H)=i*pq>jDyKN3@L6tf|4_e9P*wpJG*+t*(8d;X+|@(W01> za+FXmk|zE3qBYN1vH!4zTft8!`8{8q$JwyOGYZEc5@%roO?sYKv7DVW?!an~drX3L zOpLW#c({+IqvoG_`hTt~AqYtbQKfhRl}oHcZSbH)i#T_V;CXQ6Nz`FU9vg6KXXl@@ zS|GgEQsjj(&Gq93A`^b2GR_)o#P;7wd)dFW9>*My91Tn$gwX5kTMM#1+*=)!DR5!B znzmx|WUpY*Mo?Gfmg>1I1IdE6l_@OldwxKGa%t#tGVL5BkTGpqB_QJ}Wid)6>(`>ActX4C_ zsp1J=at{5}#cX>xQs1xm9O!~5b-Xs$u>GJTzpuB(;jV#ZVR-?9zc?o{^UvCxH}#+x zpt3-2T^uLF_=y<2^SsEENOSq^$Y_X{8Fc^HQBm5uH!dn9=uQ>=0sy;yL9t{`@;K*j zflgwkKq&EvSvB{Qv|d0*&M^p958-^w&nm}i$NdIua9rE((CByB+SS5X@PG8^v=6YD z&nFDf(>y-zE|yElW<)>2{xJ06#8@pg*&K`0Lg=N$7U%b4&zUwFQ=tmIB#*Ds=@Oix zKw4rQZUyz=K$G%p3m3}#2}{H3fh-31z&_iK7{TMOD2lr}BiKT0Gk9o(_!9Ez+OJqm zSS)%^innIbMd6A)ng5~U{Q7bC53t={3-nmYWQ25-_<@tNx5#IZiHL{7GlPchw**v=1m)|p zT^EwTm=_aL+N8%?>NdpdhOBzI-C79#;R=23nt2z3{qsK*LO}x+5Rp`E`##MQv^3|g z7_`XP5^da9vN6cgWQKJ$$>wZ^ZW&S96pEL&1Qimhh&PCCD5wE{EJMxuLEl`3MNDPB zG}T^0BIWa{pdkpYg^kQYe^@l^M~W-{v$klo`>=5}zhuRLbGSYue*tP?%Zk&U`y zqU}FFwc!SiWL*+>mI6sGp1zt2Lid&Tm`waA>-V^qy~}DX6~`yRY?Tz;@jgr!fd*`MIldZ_TF3@U!-*sojzYegvHaJZ;wL z-I`J$P8&k&Hq%)!hoAg19-0UiR#QseHjK|Fc^)}HAA)Fyt9tCuzcry>3E|X5$iqO%&y+1^*XwkHBS^ofF9rWxhpfqSQ|$~#rPdF z{hjZ&3f(Q8ti~T5c1uQnEt%UzLakU3m`OQFr^_=>S>aK0%kW*e#kgYuGY$A0I7jdsmD+c#7_bUk)` z9YxC7m2f2KsaQRW(yA5;s!^?)QOr!*@FxN0MW?bQzKe`lU8*_+-T>iR{hi%rzB=#&~jVi^jt;&iwM=9|08w_CQx@%ut1?%b%W*jc39;4!9EW!tyRi0e8Q%Yq!&69O^;ewJ;xda zt%DU0`;qydfMVhYgQ3gHJNIN^l*{azuiMhXYIq%#_1|XN?Zp-2N^CdO+ac}wdgUA& z`mzVGpM27^U!kNGgU%B3?_=f~hzO8t^Ar>gZ~HKROOT)7nvc)Kd&Kc(YCrQir=_i! zB4coog?4C~LkJ>XH@)ElxQ0mn=Ny1Qb+5~~;RnbI!qZH{6SG$sKWx;LVn(RoLk^dk zWgac*AVEb59Ldr=Ly=uI)#8>f@gzv1M6se@rx?5`sTd=jea%y)Mo&&hqw*OlwNOz< zG4;Lo%WA>l+P9*5uf3bG!qpem zTkb|eodo0YOagDuHQT?YYBeqJ1}ZM^lpiPD5 zt<85pek4ft4j70Gce1;_g^)5KeRj&%q&G#gI(@qpVIcoX#WK|Z3wXEKkQ29#sf0Ml zX3ohVZWkt7{jx@O*N3Fz0V$rYdb3??l9Q3#ppY=?&>Sqgm8zBfP6h*i) zYaYY1w-KQ3C_cFyoYhi@)$Xvk8y{W%pP=bM!|P z5`+5h0F1#RJFd@lX&BudH2Hg0sNuKWv6mPi)3Lduy~O>GrTU=TrrSr2S6Or#YbCk% z;s$Mod%F8i#lBXc_nmWfc`F;T8h_|a5$1$cdQ(mr01yyGTiDnYbR3&-k7VC$@pTK; z;5)<*iFMov@QEH&HFw(|9 zoL1YTy7?X^7FIA!^Z4zqg592)WCheioa+NQhG8@qPs6330CQz!g|vWh&$k`284SKE zZjn2|n#4tLKj5{KeCv3oW@)<9D-}5hq_HiUA5pRYzC(B4i+S@CQBJVtDZ%b7SQHu~ z^K`7r2lgSf1jBcD95!UbLbfA(m;Ro8{tC3lW_E{Gx~X-Zt;$=jyn$sDw|r(>UH|JV zUyJigK#3nflW7d2FQq{cnuz|n8(${>L8~0n!wT^GX`o$I#sTH$7;GqIpHlIDkLVpg z+ckvNdX+e2u%y8Y{H3>`}tTq0ppE9w;|MELo<_8H>` z$Bhx&0BWpBa)n;c{DJz4Ox60BRk^Wb>}hexeFnV}$eo$l>YZK&u3NCDFd^~Y8iI$6 z>=7k_D{ij9*FBz7JZ{a2E2N`l$5{G&}t1d1~~HgSq}h5B>`1_Cj(0O zT7#vMzZfw+#oW*gdC3@4Oc7MaDIK(*NyBqhiZ42k}O9s>*mwjAeB>FUzh z3#cP*Bvz)r}r$tueBrA_;$4E5=1f!mcg^loL(d06IZ&&qNI#*{(2=br7G zY_>sZK7FtHFnkaQf}jAhGj-P(;+x;8*T5|K(c+IyrLXMojWYNdZ?SmPJDU3+WjHvh zx;v|a1(MFQ9!iBO4_TU}bwb#kGPd?Z5)q6e?7CL;P21*|jWCw16x(^og1~AL0zj3% z8_$O);8>(zP~~5bp~N4v%%LPwf)Ch#N2@p7SYF%`yW;vQKeKzgrD(F&&Y`rWJg^Dj>}6ZxBhS1#vKQ}^mp=#k!N$L{u~1$5NV<`srd z$Vb}d@PkhOmoPzu=3+MQ+ZXn(Hd&8959n)L<#QB$G0BNy2O_te`h4m&5Xb3I{wk{$ zU3aDhiOp49m-5K(GvHie{lW)=fVJfoDFYbTZ{{bEMJC8wypk{HHpeYI`07#>bs35e0$oy znpStrja-tf-F$skFqg5=wBxXI_yU+W*p@h?o*iJ7;k7KRoU|^uM+j1!%|JWW{9XFv16`IRX& z=<;8TEA_rGcIfAkQ3d%rn~f_Li;Z_FEzei#yle}6EH%l@VYyhqzloOudGyFxp~q0G zrf=*JF%U%hoMzllnS7{0pPB^zfxyBt%Km3CTaGlB%$zM7G~oVFR9_R%AGk!Z4O#$nYAw--zBp{HNPpJ{l&uEP5qOFG0i&L*d56u zfk(Qn;y{}%i_kRBenyK2Gw-J-sf@}Zv?ibu3x+$R2(xmBKCy#27qp9g!|ba2Qrew8 z2CH{9&sM;AJ~pub0-D?_aK8_=0j%H+KxdKB*Oa6JVWP*AUgsFj<;e~8$(ID}urY*t zYg;d{bYbtRj_=jkS!cV}DzLrFd3cMvw+0)Vy+P{J7(eG@_g$z1UeLv9{_14cMMe2Z zEgSdQil)tJ_9WU-*sJf8PE?I2698Fwu}y9Bb5uLDDzBunt_=o*TBnB)vUHe{wRfWg~Tg%z|RSZW+ z#VJE=YR(V}o5}m)t%r|9welDl!z=p)g&}@;m3ygpS?$k(iW(C9kCX&v-rc*Cv>!|$ zm{-?Y6(?eRm!~@Vvh?dz6G>^)}(w#ZvkmC=)mY=&{O>Xc7=t0 zcqN3vpKu_jR-Tx-jW{|_!Ifm8V%j{S@&ofecefKeYjgJ{<3cIV+24_G)8X`hfK8*w z=AoZTm6y#ueOitFi4?1YNGr2?WQNFMP296AjeYL{z?^)hyi&uM_+Xje%m#mws?W85 z^Kxlryem)0mi=j$V^|CQ6@A|>EfsC&(mZ;Qt#K10@DQb7(-a1Nd@ z)LPryT8#Rc2 ze&Nw7I_q$$=Ve=|DP;7rXyhQ_d}~2_hxI#PY30i zkl<@HxHfT3d@k=0JR4siOssqFbwV3gVWN}>23Wt5`@@H67FzQhRIpVpRF+Rx20twL z@MS$6vTVET-(+X0FMh0lqB4goBK(jw6W5PBr8QY&Ian`CH}AZgGSV_^nRO?k*7((9 zphSq7JxNeWYh&T_b7C?UGn(iykgBAYC0M1YBS%v-(L*Y6sXBWod~g&rPYei%J!9+s z1fSh-&SHoASt)?YGgxu~#Mm0kjTr1? z8m&P(!d_{(mWU_HN!eqyzdCIH;Q5TkC6JB-)oFun06Gya1!*pI)UG>bYvGfPp6ttT z)dO(;ku=V5{Kag-#-w5u*3qhLOeZ3CPi7Exrr1Bla29@5Zkt|j&Hmq4U4Vqck~}b# z&T;p=aBZ8M+&U6yaeCh5GI$PU@(C5VN0;EcDA=n9fiQGczqUpqj{4M_DOD*_^qV&? zaCNrh+Q8j^%}xm;8HU+AA~RnYnlES$Vn;}4cOiY#D42NDr*!6%jMx;GfkWD9UgHcB zCHSttww3DFeSF3h<9(EM1LuBYd;4E z^2HlJ-)a20QJ1*mSIdsN+#n&Vn1YVVaBuK}dcyMP8$BX!oPhsC%#6HzJ@@9)qfxu} zf0YEB+U}!FJ)V+_cnXr3J!9*;3qwl@_DE$_3+336mN~M+{i^dLk-ockg0vD}itN40 zakc4ArhE-V6_77Lz3Fg)z%uJ+Nu;qxN%)PA!5|9F5RjZqhzHxas%PZC{LF(qRX49g zyB)bT9m4iLx{00FW?T_XY0y6 zTf1~gISDviS0$X>n4|DnlpHKohoFG6;@R)n&QiT^gh0t!} zN5vP=d5xXPVp~pHdmNV`LFp4RLuCCu=e{ryuY%S{yF3;%Waih)?}n=^p{mL2V%lje z5yczwm|-2I@P!4OGe3;;?IkWIY-r)1T+uh*GR(DU)Rh7%I-tvs8WSH3%7+ZuzqXOK z1yKrmJWc6me>TCk%x?5?_>!H$bDTgq$k$D>+UIh%nu*HBZ>r(g+@nkI!H!agA~)D^ zU2+U+cxZbjBbNPJ6fAu;cHdCT9ISTxQf-|ukuJ07rCUAnGqs{KmiKTzyUuU?tj%&r zbNQE_vGLY4!Him(`fdmb)KXZQik!|ZEc~*%5^Kn{y9EzS#G?M#aoZTul8)w~DgFDo z8n?MdGG`On)!e8!IjkSoMIEP99X%|tn1@gU*RK54N{+(M26=Bs%G0tZyj1N9ECWs9 zqzASKyilA+toyBfa=)H@A{)?p$abMIq3tM@tPT>AfVu=g?Y_T?Y{sHXvTO?UBF8gK zS^rJV9B7|R?ycw*;L^<48Q42jCHi60K&HiIab!mq-VNDXDJRz&&8n&noF@E3%MddD zL(5cteEEMx%j9g?4`(G5lHr>PsD|J;<)j<7QTMv|i#u+F`{vt7&g$3%B)x@9%m-1P zapi@!NmkfOp1wPGPJi$2_-z@`oV{jaj0UqqkFL= zY%FXR>GN5+Z7;De{X%a~FVLAai0H>f=y6~X%Da^%BhBLp33v=b%$42=oSwBQl)`Pt zT*J+tO>gsY^zUVyUwAVjonoR7;sYKH!#;*&y3o>SD=!ba!PcU6QPX`ptP{ZyjR4iE zZ^?I=7i9WP;kfAV@m!00Cj)#}9m>;(41Pup$Qvh^v>$fC#z(Yu&|NF{1^gGLRjk~i zu$N+=DR5ZY2RLLxHWA@pcATvL$SGmMg8;P!fuLaai*~v$kT`U>W~zDT1(7%>1g~8( z3EVU+%YBvlX@%w$Nb=63`XK@U;KU)pdya>K;*wuS@P5$_?qXxT%x0W1sq!pRAW>({ zKq=r8Yh4Wq-=B1pE`MGiMhfAj?Ty=!61x2qC=*~%t-MS%1~4Lga@0H{u&9J|9-Jf* z8UDNgI}yGlRnLZwb|6v;5_#W7nV+%~(Cgsz(0&5|b(|(Y{HntjAwwPcVq0%^^yHoh z1(2&3UcTOYknk-%1E#mkgQqw`Oq0g2g7z?MMU5EWo=e1<3E}K|0mf3euX<~KoK!J^DD-c-K&5W?(=1F0l?D`aytm~ zo>nMSM6Jada=4sJInVCplsf*I;0T~sWq^<$z|n~Q5*c_JTVMA}o*_Z28J-;XpPWrk zD{L2tyuk0Q-EPfm5Rvohaersb0vPRosdTumkNB0hnh5s51{xdxpCl{VOMGUl)UVVy zju}pZ6D7};&$up>_8qG@E+wbV{B^`{Xa?#u-UE_cEpJ`YeU0P6bvLv+7TK;L!y1~L zUwQG@m|rTn6#%-9kQ+@piiB=dA}U>ubN=IXvbp_l5E20JsZ-{z9(maOfYvnV zkIv2N1-lOls}2LG>jn?;0!pdP%WvPlWL@S;MlN7*@2wNM^S*!<`iWZZ?Nyx1r2x6! z|A7`MM=QM&Wau!dP?iR`5!N@V62Ys7Z^|L1*Lj&NXzH9~?`KjIRo4US0Ic4(ECyDB z(knnLo%9Wlj~;$wk;BFZm<`DL>ro5+J8F#E`m!YAyf1_~$$m~nN&pm43iq&qxFf5r z-qyrRRg4|u3`U(FJ7R#cgQ}_^XZy?ky{ddyK=M@hVw{%d-jds_++BqBh-*mpy87o5 zwFPFwpb`9%^D+C_-7E-eT|7B;Z(-O57rn&f2;GT9Z_TYJFT}*U@{dt395JO{4N4e` z_$eB-D^}9y!(7s}BN`GY2Jis&ipDARrc*#7z<_tBu@%hX|I#0TPKYHY$FwKeqpl6}3)Eb$W^YfhS)lwst^szTtHP#B2^9dx zFQmlppFmM#4!_V8@P&lKUP~;SH#PnW(yL%v%mr`MY@>?4-1qKQ!w$_c79oCC<({% zw~&|O60s_JjIe0GR&eYukUjBFkX_^ph?UNnldV@*>+Unfjbri$ol+8?uwA7pSDT8= zs@b}W-vZ*r7Hd@U(lh?r8LKswk96dYD^pfOhc;;=Q_m~;Bm@M}>K6X9385o^qDbKA z{ka1{J5OZM_Bz7=nO&sxHaMTL-a`2UijWAlye|eHndUnL-0~z-%h3RBK%ebKi$XOm z-Z+ym#?6rNAkYL`3w!0{X%-WjxhOg?iFDW4clB@V`;szJ=j=y*jG@@1{tVzZa(a}~ z9T;!Hwrkji=qr_eQ7ye~M9UM~K}n1UVGKYvPB!J%NWojr$=3m>ScF#-lz{O*_ ztc-~mOT($BCfH$z_wtx}sN(_Ay?51gc6F_VS8e+3pqiK-izn6QGM_WhD{O z`NM z`IRucX|cE6gvJ)c%xWeHzirnmfU@5(3M@z5PO2mCr8vR6% z*1=~@6F^e-3h56YWy6nJ9mZ&MVWt2Y2xK}05vrNZ?ul!y7*`$lX7fpe8$NEw!@OgA z&7Qz>{a5y6>>}|kjyUX@m?PkPv#*&AMxVc#jYVwtp4ru}JIQefX;sl*Gg( z{8aAc-dPsoVwbZoITg?w00Icn9^<$C*Xk3XaN-n@%OhTj<((Wt?U5Hei#`QJ_B>7V z^YjCPEhrZa`?1(*{!MSV=8g&_iI~qbqL%`@{7Z~jf%Mcl!w75p%C8{1-susR;X_TJ zm_^X^j>_6+^7G0U_V26;iv{AQ*%33Bi zi>|li@}7mQbYLtW&}#&1PXOvBL7q~b`+&-Ppux9}Hw+h}PYG9dk0<9$u=f_l6XigE zJ@D2Y1)`~7&~mpOiGUj>HSV&%zS52|E^IDop^_&5mF@A0%2J>u1IPW>kGKJmC2lqP zghBM$P>|DL9Uy?eHt85vKF3$yIx3X>#_Fs@hJENxE&0=30aqs6@GXX#81UdOy3H-)GhfsA9W_c5Mp<(if#0l({?^ zjX22?PFF89+%gIQNqx7|$O|#vgEFr8L=Q~(%Br;r0*&~Zr*zA=hzLgzQEdLK$_3&+ z(O3)m`?MJCRZ>0OvsJE+!;YgLc()Ufwo@)@d1uKgIPM3p zPWQo(qhAaPyp*crO2dV^K>;S9F3!*Ska?GTVB4HxDH6~Va@;C28{IawS>{`_@_~oZ z+zr#oCI2A79d5jOVSnaa`6OWEhS^afeAu@~Xz?sVrZn%&9>x@BUp?9|wOR6X!}?A< z3ePayMa+@EU>RMYKM$6{4f_f*tYdNX@H}Gm#bOi6t^(#Qf~`5`V$LmORC|k1B2uEU z(|*$%9=5SUcDIenX#|&w^{?H_!0Mjt%r&$fw>~*<^qqiVUrRPpM9XE}tu^gp<06Fb ztmQzMb)3k?1IW@cwXb8C)cCv{f|TMo;O>v9&Y4Jhva2v@20gVcYO1sQ{AnfM%9Zpa z=9sHZ<4oH3!A=%-5}Toc@%GHC3*F+tihK1mGehIS4{uJY-#b&V$c}^4ya1HSCNiZJ?{_8Rw-DrD;D7HT1Q!CG6;c_{MsSdDvzHG7?mjVowSR_1G=5`8i9=q*g0I`;u@eA6}s3>i9gYXpbkuf2fK^`{ZH5 zJB`J2#6a}F?WKTNN8*0LW5zqRMY{s@8M0jows|_u?|buo1SLz2*d99Y0SS8av-(C{ z;gMqL<ZRldOZ%huh!QLQ8`z0~}5EBAX9aw)fBD{%`{6ZO*? zgx*N9DI|i|ZcQ?VTapk2m4Et*vY#hdKo!w=XFZOJ@rF<%m>iWH(Hd?psUC$7!!Tc2-!>Nn`?e7R>_UbpGlS+Ph!Ihq<=TY;d6?A zasjU9DE1>ixFv9QR`q-e9W`6onzM@6Y&uZ>hgDfkvx9#^u@X0X`+Lh|5AR&$ZSz^i zrVjCezeeKDLDb!7j~E9t=d2Kl9>cD*0*0}sZkJP%8R?Ds_-@TTDG70>8;gwk+LOOp z<ME|fp!{8N%g=_uo+SFu8P?Z#%*cjC!dX9~AV68Yg5hueBbpZ`$__Oy081grI5UZ9*>WJZ&i>v1g+CU2 zZzTLq}oU$1QhnyZ|2ppp{t` z9SG`4#Y2p2hTBa*+kv=cS0STFTS>Tdfxvm6^Yjn%?Icgy?#bmKO^9r<>&(PScYMYTQKldUwZ45`T{P zZm(UqB2J^WeTMwCbpTZ5!#i{o%QLr5AG69DxtXKzt#=SFMEh@6Z??)#w>m~=ZF4Gk zD6o2OMDg#cqtFhU1L#}7X?-WNsdHHeK8A3=T!uY$vg!?O=xpj>*0sW%l#R3zD&!JkSh=_xv2+-M&Ks9}ifOpm&e<~!OdIS1&-<_zOa zq4d_kQnOV-`x{!N6u6dP&WE|$vg9{*NEVvP;g2e`;rJxrVn|N#kpnP^sSwa04ZJ#g zs++d60PDoQbJl$loEzPq-c@fR(43Bk3A0+}?L(|ye7xOh51fiWIhfl7+_DWh$Gjof z6IAX`ZhY)@Ky|HxcBdg{c)TvJAr}!oP&;54>VXMrxm1C09uK_Px9MRpKM8(+45+0T z`Sun=?l3^tSQG5+2nfQu8y_CCEa~~O%)Np1?h3Tgg+J<@@ zL)EnM@Rzzri<77RSgXq)llgU~Pqo$CsF&;Oa#_Cs04u%@?q>;VMb4t~J4+0nbgjgH z9S(pjLl`iwpg9aR%JwcZVCb2b_lm_5WwVQ_W89-JKvwGC*F_3x;0bPaG&xp?UYV44 zM_{*?bUuddWMRZ4VJySw%ZYFui18^Vz#M<2_yKonf=4Rs2in5Li^_(a24Lam4~@L< z_r^(chNIU4lI%1!&HKP@dbe~%0diwygP#V9Lpz2esr5otfVzOmX9eu&s<3IUhi2%MJRSXTugTHzE zK><#rIDAn`-NrhO5Yde&0uZMNf9UR6#S^l&q|;@FeQp?ZWza)H@*4}WY(YYtlF7P45PX8xC?8;+(b2VF*(SFTh=8Q+haqe3{Ep2XVp?z2Pt!QUFxYc4RZj z1l9?WWx`clUbO@o`Y_%Y0}-lNH9da2vi|cvVss;!U}Aiy5v#|S-IxC`1O5^K=qVfL z?V}o}?47>4-*-V;o8;Oqzp zB|o7@ys?k~mI{O)RY|}UtsVk5q93kl_&1gU2N1|&yetlhKsI=lUuNtH5{ieuP`m0NFDN1 zdp(F0Qi*6Ax#YeI)MNC&LES>vegCPor&=X8KH8q4)Vl_;UJP;eqqRmY#k*QgFIH;G zM~tQQG#;Gx-VyJ@6NOmJ_bABiD^er1?4ZKlx+@b78_ow^-!Eb&f+t*$QyKCnxeiW` zPCI!HqBhQ=Cm9=Th?4K}-2Z!~fljLE{E%jZOuFEiCD|~;B)Pv9;?}*;$XoGsHtMmM zt)h_B`_!`VHzvbQ^j1&Yx}=(j2P%H+yGHUGA14YFy46GM-7S7d(4$MP@|E@P8v?3| z%^fe0gnv04k`N?SAmP7Q`O(vNi-B&qbd~qLK)yyVKsUc0mE_$bf67#|D z=dF0~%iMVy1#WyBLx%JlApsHLJyiT{-IV1)^q#mfmlpiLC#L!V`MbqjLF5fn*7&;> z|Hw-H-mvNXz-vE{wGAVW2d_(tc-}CwS$E*s1euC!oFtrzkqk#S_La(K;Mq!v!*@T4 zKw`0Op1=GU2t=HUw@udbg&@t*EvXED8<;ycDzIW6A?cc;LQs?c+AoSa2v{*6X6M)* zFVOCt8_R{7dgrzf^cjDLW~dkFI};AZjk7oq9#{{@T=}9EuSHq&+MA7{bc6wGQTc3$ z!u+JiUF(bNpyBB`6#`cmb5yS?lOIX)Vsl~_#HL>mn6s+;HA28$%NpY zF8gq~V+iVqdxY)19vU4%a#(Dxp<{^-@zYs9dTUEZZFVIlf#M+SCcrhN{MJwB`nS$unCDpx%`C+ z<2}iEk3?He$VCs22_GB5y?8LbH%NIRCv4puwv5qtU`%Y zrMooJ^cO^ekidScwRiq*2~yUe1251K20p}4X=ZXVtD;L%49nga=E~Lo_ASPbC#t{f z0}pr;$bD}7#k-eZq4d!G-J+GW$CI}evG;$Rnf9MGqwQ&;({d4U&Z1SJAs#;9ean7#v2b7W(Y#OT%3`osE_gOmlmtODO}9o1v)xG)d^sqb69XA-!Hyf zls$2cdUEs0sQ95CoUbl_s{(gNw`Y2&`eg)9sVYxESw7g;9uG%eS)tVFt1z@r;fd?M z@u!OCX>1b_1tr$ZQLs#3I%9=}WD7f&PQpg3`6f1#zJrwL_v%F}!_Wa^FRomMJeiea z(I|*%|4F@^`FA3f$@@|+4$;Tota+>K`E$tXGrgWSkF}uTI~gj@JWXLaFcn*1P1_6L z92mEa9^oB`l}u5N`r6lzB(t)VhbUFKH0vOp5J7MDa&%yPS!*(LAZA7aOM1QUc#X6Z z6Fcq5pVaA;8g7O?R?+n&~U_?Y5xSIN8D{v1E_bd?n7 z{f}3ujO`Q()aaKK7nHfyiX-378&NRt&&YkMWKCQ8>d58otO=W!E{Y+1#4+1mZb;F% zyStMRaXMURI6?hIXD_nIZ79BzZ(Ye~G-7Cow9QIij_k^`>Vd5u>x|mx&UX+UN0 z21T2=xXF9KCex=KqV7+W@i+}yMd_LJOt+UO$Bd+%p9Mdo;ppI#Ql89lw=6dO^rJZj zr{`1TeoF{GXXP1C>xJ?yhoQ(V+9`%cC_eYCT9u)Fr{fwYMWp9sKmTU$6Pid57*=xxQ?0r{IW+y+X>mo0T$E z^UPOc{L12fkY*`ZlyFTxx5%@6p2dCvW5ji=<>*w4=e!-0McqZg7lahy`1YH8F3Zcnr1sxj(4FSaaSB zIi2B3DBFvuh$wtiXxcS&w)}z}yEoSqHo2W)f>rk<{y2 zJK{i9h{_vR*~k)PtdhLuZHv-EP4k5=)8(R|r(T*u3MC&2RutMc9*YCY{BmsPI)iCi zzV_TqiP+26ozZ2l&TYN9bO$T>flE7Q!4QLwPJhnitM#(!9@#8#BP z7NAym9F6!5_P#&1_(#C&sWyV8*=V^vH@h4PZb9fAe$VN5C6)@lw6tLdyVmVc0Z4O; z3Yg{6JzhKGKRjK$F#HPb(cw65;f}D;XIJSkIR7=-$b!ux)Fd?g`GAeVk6cYrY~xbP z#}lL@d7U$jZiZTYU8_o62Ty%c40p@aZ;>69V`sAM0d3*wYlW(D<8{g`c({8~l)8%V zWe_NjaVQ;@`FSq42RIilohUTK^jK|SBD>wF^+x(FU#Uh6#O7}j5K8~+j-8mb9 zykUXqPYYs6~!e*zByLoe1pi$FT14O!!mb;U(O+}gYn#>TI?803(pN}6a*1(Bb zqY8$qh4)wbtF!#9v_pAA@TL#Sjr}kN#4F=jtjT#?(b*e{E@N2GwflO;0Y7dg$^yBf zKLt}gd~@fyFQt1H#>&BBlBL5J%muM{aBjzf!|N`$u>=rU@)IikH>hxXPlo$*@t-YG zA}4IGEg7fosZ*L*@5V}BZlhKp{*k5o?orr2z;%CmS6`YM6;ydiY z5@`c?<0g=h+%M3Dp)mL&i_zSfn(icbW&2cG62qxo6t?9?(J0!xS^*zD5!cjV^lXxe ze3%|{pR7IYD^-T%t#^HAMi$>N9nNsRDKQqhAK_OsYYOc@s!%R8)l}T9tA5Wx$r<&v zcM5~&iTyplYrGT4-CuD&nT;y$3d$tAfa!BeyFbPcV7M= z8kjp7qN!`$UYEGmD zKB=HzI;WNh**4GGbQEKUJ@o=r3Rf*1H$Q`yMihe;3O?&94)bI~dA-vCm0+ark8QMz z9yxA;rPckYAeA^z^t_C8-6M#Am3m~TGA~ZVNmFo}@=~?0S|05MF{jE)$CX@Gb6#1E zTV;)~Mpw+MM4OQMtd?mblhd71d&))OIi`K~&oJKgOnreoThbGjFfVRbZQ7 zBH-PcjS?w3`!U+@tN@L$e%pL2Jn_<=Ogw;)*nCLW(bRo0xBQgqGTyEZLVH2Q`C32$ zo-JdcM_#9zM-;B#DPJ3RotB`F=pvrI^w2`CsUr(blnV}bEVI*mEvc~bem*tBoA;7V z8ix|@PbEgt%4;a~OPGYLa*`Ljx07g1SiU*nq)Y95)GBh8PRo_XR)uMgy^-Ip-gFcY zin7R4jITQ~Umgj3-mO0~w_I7=K=^oj_lpRDGtm6*>vJ!Zj*;!DKqq?tk>z{?HuU>f zXbwLd5SZcU44BIcLCaWZkpW>-qN%5WN|3-k_#DYdC>7w%??yM{-A8;DI$Zg?OdQ z$U+ce{Lsof2?L0%oIL5JJplUz!r-8o5A?5yP!n)BYDOUmx-H#5I9TS38d6Ykvi>T{fr2UrWDV8ahx&2?nk& z&+&Gmk>bIXYocqHE-aBEPyk|uxw~>A8dV*Lyj-^b>=QSW8V&8@(iDnFsmMeEgMxC8 z*y1CG%u3$os?)PS=1-Q}9V6s$8? z(4sAh1v2Q4v)u)3vRhgm0l|s>joyQ(zASeh!qf|y2kNNeqR%Np=Vee4&WCa>!uI&^h=d@eNQX$tkP-p{(%mIFq!{a}D8vC(cIi>5PGZw+?Q9>~)CRc!N zg}8?mBnuMByHIT6TYPk~&LO}6d`2iq*6eosql$VL}tuKv!`m3DOYQhJd=m4O`}%= z`YiAbUz7bguu`H%MNzv%w?yxT5DtIqh3U4hiR%t?(xG54;V$#W6_o4&mO@2i^-Xp0p-JqvB=Zo*}U#-dNL4aQk z5MKpRNd@YsMD|@lNm-@O!tj@tZ+R=hF}nk88hI*^^47$R;YA^PxZn7|^z&vESi4PT z$Y*+C!`Q;byRNi;Bm%K;CdJ-G&RFeW&^F^jUHpB{IA-JISZxr%Salb@CW=(e!o{%! z_h#wo4r1>2R*SQPoCsyt^pBp&-rE$p59&&F-pHlNpy1Y8#URcuDcShzegZcJSodxgSsQ-QWoA` za51x$?RC1I$Y==7KKeN0)6}M^nBg$4F%J2{u`a(0_M*F;{kVnvIuo;%(@S>P(ONzq zdcuU@GC{7psojgjx1n^ZSy~SvV2jde&z9(zX7fHtOCMgZQnKBSlWW;W=yS{FquhLv zmbzoDt!mU@MYmSC*dgWN&B*HEr(J@wY@inbgF^~d+uhleumNx#!;c@!hb5c(^95+s zfDP~1{(k^Km*>@`BEBWMrB)*vTV<;9j!gqlp5zx7)vq#AhN*mcN(5!rD}QIvbxHqXaT0EPWdi}rc8n1lC32D4bZek~;)MIVVOj44~67F8kouiBrBwUg_J2 zpTVLUxf+&veBNr2gqIfvpaUc!AE!#wFey)N`wuV3=8hTVu`_s#z2U;M6zaTnkdPI> zt&(9j=6n>~u!gN!oU0k5qD{zFdDz!Kd~uS1v01Lm8*A~E)#J45QGP8kJdW!+S*tdM znt2BrV#_Jf)d?+hHMvXCn%TN~yH~bQ2tCl^rj40(ktN9|A9WkzCh%r)7}_*{pQGJy zMQ@fOXh?g%0zZOtzff)_bj zXowbZMnwDL4DWJ!+o={``F3JeLwI8Y<<25@tV z+}CT_v}_GaHtQe2O;l!MLv2F?o4D8u9bM|x9X71k`@?+TT~vShiuN8{3$Qb z2$2ny{he9uBboXNlRMePx#q;wtY4qnq6*`EoXm_;74ry7)rQo@t=Dr!Yf?38ZvA3d z_S)nTeA#+)ObViXy@$^TU>*ljzOa%)glQ2|=>+ZfH(ee_u75>`6wb)AagKB{*gH^`i<(y83!q*oo$@-Bt;q zOOq=?1u=o>UwPN|V(^EGS)T$U@ZI%)xlp46jkbj=asZED;x=?BDLmv_)@`q805#V; zqX$W$%fs*|>}IygjJW=~$6dHd0d{f8?$K;_s*^$xAx3BIX=tW15#l&xzbQeIsnZD+@QHr^^`3%}z0fk7)1&v0H`%%u1K=qaU>9!= zrO6!$SRg7UW+WNw>ZgQBYpPyZ8smmd9^Pv^iY4^*&G=Y?)+eh) z?zxa8rzUZYj;HKsJ=tt<%QySjgwHgqU@tmpT8@x}4K>M7ur|@TvG#J= zkafGgZ=F}2dmvWz3u$`eC7gv|SM2lRN80b#;)m5&J}GwcV}%P5$?j^7{vM9=dMtpF>HQa0ng#N;UDpRgs*Cy zT<5czb~ZKT%k^+}4s7M0aR*FS-c9 zEC+E9?&0DYLg*J--$lLVX-$#B9kI;YpM<67G(@USX8%k-sq2Cq!-o&yUW7AlGBI6c z^?ck>=B%9gOnhgu)^9$$q>P@*q)mO=oK*f|b|ykCQ0{rEKV&o1+(Ah`HFoa03$s`; zGrUVqfs@`lCrV-#V`fWa-O8ni{MXt@>D<#$}pxMBOM+^q-jG1eFo zl4NYN%RD(2exq^ymt^CR&qulNEics93>~w9_ixkP#f*M#;x~=D=L>PZdInj&=F$!= zdiI=b^-Y=G*z%B_N)->0g4bN$CQm_z21D`FtGtGJ_(~x@kk^PdnsXirA`GA1Oiv#@ z<7?SGKEK6r=Bz*jb64jf-}xEsZ(!rx`GKd3@_a;b3aeYyxI0l;dx&REbawLP&G{DA zb$br|?b~6C6LPLzjf5O+)4fW@XY;a(DIMWgRku08za|`v$^RV}kCnEjG>v+f@?us^ zkl6MdN(WDUndX>RBrh?i7~Qd!mL}zd5faL|%fdMk_g6eT7b9$BhP0!r;ErK(Y&B}? zEuYOBHKpwlznJh%*iulfKHbJ|jMZVTiJyGD141e7{)EdkRlg$6>JZA_yxcTYQt6{&|O$7k~9Q1Q2U@;A(sYztA>W@(|U(k7L>FjyUBgwj?P zQg76>;(YSjEM`3x@;ld3!n`>_bg?FKmwI}rbUNFz%`&Z@yR1T}?B~uTHRwP)A-TCW z@rn_rQT=hTJmq9knbx=74448ZAS8iRFIGhSv^eVw!E z$k-iOSNMzD?X0F(oF7u&RX;}*Mu@;uH8BJPr@eaQ8~oVTl#yRQVW6|d(Ul67(`zg# zBC4T;Z)TDvgI^+jY{E~`?!AVO`T2`bJStfcEqFo zm4jeWr#=a))9tqf8g>GlI!`=(U*p!!W_7gb7Dc3_LBCSU_y~VljG_I4+<8KITp8zO z$itM6+L?aRh-%kyouAI<-?{)etlXBO&?0=f ze1B5qom*;kBCc(NJx_X$`hnyDG@G!5c9<<)E#ZnUCUuXH%Q%eG2pH0`U5KNu6+Qn1lr9d1WEx{ZR4j=#MlZfQHPrdQoXd3_{ew?GQ_2dS^WTY9r> zi~EBvr>%#WBO=ObRD)p+!>mjJQe|`ACBdy|2uZ~Bk?+`OPGh-0?Ct$GWwJ|^d#Be> zGd|k+qGOqJ18L~P*1cvL#4ibB{Yc8s!!sk%{wX7CqAl`GKCa?s89T0BKx`mfq0~`U zrh;i_8g;K=Gkqw93+1d)a?wH;VmH4%%6isX3Xhk`P%d+AlnYbj1X;dvICIt{iQGn% zH`c7h^YeL~4sPQMxh{(=tHN&$&*)`S`al#$)?97ZD@1GRx$dU!w=Tc7M#Vv^&C=G8 z%KJLyx(;WOMYW|4pHe-ij@H~a7E#Mnf>XuRA|*DE3~;{C{q&(h;f1vpud_*xfc>fS zB--&EHa8o@q3PoRVY-5$9N#z5l?VU{jV4RSXNv|A{&)#H`JwOxq`-?qQ$PGC$L<1r zdR8Vc?OQC$$<2pLEk*Ffq{Jf-NeCEpyj-58@LqVdklO^uhTk!Ve+rFq* z)XK)r3tQJVrop%=UPqwcse&?Gxo8vhapyUobduB_oo~HfD6x?pGa=NsAWh$j9~d%= z)dH%TjrK8VEGZ*Wt+mxSPV~GZym7w*tt!r*U$z0T?2h5%*%rUkWBcKL0#TWa^$zya z5-^2bC9C<5KH;TpM%_ECL3-BM?B}CJQEMNPuj6wKgnh9b5lxyuilzKCgW(w!a&2>! z4m9B_p3Z$j1hEO9wtQs&(f#Y ztwdlCH?eSHS3SQ#o@x=)`FcpLHQ3r1bd{rva6qxY!-KBKjAbT~uhrywJji`QF_duX zn+Kig;qQAYe11%G^tn8Rg5B0$;hf6NHgm#=`ktSq_$VxPiY6jFl)o?m)Q&W5(#MAL>YXG77;wH7fQ>7SKf{!9ptg$Kc1;@h8Vu>xAW1LsRR9hHI?19 z9T*qMXvh_s=eue6tp40k(N8esNo%b)_9;?_7TLRoL9^2*{!Xsy<#Mxh9L6I_3uwMK z$bY!vxHK}q{RVIT?7)IYyFCn-0m(Z|6~CQysK6A|P`q<~^+W(X+$#3yCtJF}3cnvu z45eL@Mt-Wa=i(x7A;N)XpG(w0AD?8ZuGC1){SkcGXun0Mj)BGnXIk4h4vt@io+*-dpa%PE5hcVskC{~63 zyYG{l#Lu&QogaQrn83uf&gqIq@5sFQvU}g5B=xI>%SWiN({Z0Ek(NS1<$9D& zafprf3ueP)tVj%Xn6wZ+joNHl$#rDuY$~_uV^cdeJS)tjk*^7l(}&2axX&7gm99p8EFr;?yWFJ%DGJP*6_+LklaEe!0pJSq~&>mP3KPkrWWve0iqBijFPT$>ba zz75a(8OYdw0{y-$(tT?*`s7(uwI631bZ_@A4K#izilcbQ(=awCoXT*IX6e13?_}Ryx4WN6SkiLEf#!m;R=VYT^np^-c=*oI;IXF z2kE5O1rY`lE$TA@nY!FbR()%!K)f3Ra&EwHB`Px2Qe6Y@CfW+$iO}}6#MFw2ms16^ zLvQ5QYBn0n95lw`BCrKi8zUQ1wb)$)=s{@aFd?wW&zb z_Byk<;1I2!f>oa<>$>>%`&iPvpb~1A7~JIZfErvw)(}Djp%!K!tI2YNOlR&VI30Ow zRV3MP{epwb#O3?w?AD}lTc^T2ZPh5-mO1z6$DG#PZBm*DD$!IdvszoYjz_$2vOF1- zJ^IQXSic&0Sn4j5EQ3(!Fh(EIBBHsZTC?t+rgyKM+AJ$2Q`Z+Ba=kZuKeNwiV&}rN zp)@a?qcu}vV-ZYf+hM5gmU|Xj4_JqLo@4Os2Hu2_v6-2l@ zV0F;eT-e)|j2Iny%PZzyc&~}&p3KuNm+v&$Mm+eBHBtIyaaqrn?J{qB#*j@rvz|HA z^X9v}%&pr$$^=%4JCW|$)LV=629UkDr46g!Mk1&qV_wXdCHczOHR%yNlXD<>;tOw< zWO8pg&qElB&F|cQ4(}$~oU56q0M7K$bv@wDK!J`P%_G&a8q49dbq8AIah%ntXv@nB z>C9dYN7F|BBh9*~Z|tDdl2$)XKHnLs{wk*Na1*-j*%6;Fgj>$@dY{Xi>V--^Hk8aTcX{|TVs^Kr%Z~+)=B}?8~aKOQvAI>oUbywYC$4@@}a znU1^3dB*wRMwaAgy3yoLZHEuMeUfbN=Tk|q zm94|6x*erlaYqTEu8t?*{W+S70d|F>^wxG-x>Cnrv++Cb^Wf8q#9TuirqJM7z6s3G zYolF-^V*OsNf4A`uB*ZEQQgd<@0ybyS8EFoxVxv`>UL{;=#<){Mb>l(@VR%GN(aVH zvuQ#xMhPn$bcg(G-8fCXy<)KG5zJO!5MinOnGHd+cO0X@He!?Z#eU$nlbf<_hKIhQ zl740f#|79sX=k53{p!;O*ra{Yvm7Tqk7xeqo#75*3jd{c9d)+L{ zBGjz}%aMibc6Y1}z6;?65$+(~ixGCcOd4C~psI0?&XO!dlFm^uI$^v=-AVT#SX?X+u&u~;I-cysAGVy|lTm|ks zyE8|bg9-<4F2ydM)&-6oj=faWxkf!J;JF)aZP{(FNOe8`;SU$|@Zg+N8z<#dfq++p zHPB@1!(vG=)%r8HOYlcGDvBv5VncqZNHpK$mVAFf?OZBHXL>r2EcyZ?)+USbJv2a` zy;)Q=4Rvn{B#A0yp^$x^rXpWDy9Ti~h6GMEXR@b?M;xZdD5_$*@1pvu>9}jyDt0aF zBP)-l0XN^lf>hCNjx4lYsNZFl+&V;^Kgl$P&?hWT_}o9td;TKH=kOh3)?0fVM>sQU zbFqs(xOu*6!lVKi`Sm%3W0bx?)Gs4m?301%TBWsgdyS#l=Wu5%F@eEuc){`vmdoQ! za_>*{D8_7bLrin!G5M=o<%8DO2d3+6?4%moJoJq+%fbw%^7C+Ij^vb{{Zhr6nMURu zLH58-6-5aq-bznE+GfG1WoomuM450JILoH!YcNsZNf=A{x|+z;(L$`Ryy`F5W>D8$ ziCC?ne$XBBFvZ1G^!^b}`be3=iDgT^q-VV!gWf16J5wS`9n%eSgDNfH(Ih=29UbxH zRD4*cCA;{7lz1u7g<-{8fv$HlML6G!14z}9n(Fh}7%$kJTVEFM9Nem@Nqp-uM_Ct| zo6Z$gt_frnMhh+CD-8kVuw0W{U*YF<t9Z$K1SY|*7i?+i?FfX z%cSJCc8iQpY55FcPn=7EABP?`_K7^_v1rOi8)XKr)~+`aAmI9}G>TJuyOCDR6y76& zEG_HGx4m9u@wJ*s(GC~5a1Bp{F4MzbqObpY_lB>fC?FzJl>upbVh=gj;UkYEt=bmL z4i3(+LUqxDE3T_CD(Ete*KGI~;25mXkgU;QNQL?PMbbGl#mL^sS7cogfIwzWfCYvZ4-xk z5AJRl<4EdRN*iDHaU!)ba*6{bhI;0=K045g(A4O|v+jLSNTCXO%Zogx4!!AP ztcX8#7)(fVwq#53Jln(kt=W1Gy?dr;L#Y;#cdqpAg|lIT5milR!LR7yTKm^7;Vqp{ zJn?02LnD8&^D?3)k{XRHe-R5B*5k_+&@WJHIAYVsliM7|)8D;F-k=ef(|Xe&W~;WF zZeMeD9=6JbRBdv-YumRkqCqFOH3>VcNBEqBu(DQLH09u7E&Tj&rk*4twX&~1|GWl5js@FbPf|r2 zN}h=}xWw#Lq_wZD7r(Nf`vUWwT@vFwn|U)&fGqYM#$WZgKI zlz?a_wS4}Rjjn1VhQnV-KDi+D*H_a z{aN*6pDjKPo1@kmL6L$*8A7XLF(vDr@lS(b)x$L|t?^ZRSHr|dU0#bwZt52vve{fi zRu_Ok=Pbn@WxtD<6l+uvQTpglHkkGZAt~8G=?i;<@79t$c3RdndOr51=4PnN=&5^~ zYDk~{rZ_xi^-pfqRHAjBv=b$(@nsUt9^@QN<@JB3+!^A$qU6%R7zmeyx?q5 zSE4jaxSSIJO-7O;Oubi60wDk^&FAX%l0ft4ki)j}ycji0>W6!vm)|{O+?%C^gs(Bc zTm^Z`HkJcjkKroeL)wVCO`m98p~f*NI0fMmAwy8tp0-XsmY#P9N%i7$g}@rp2Y#_q zvWeW-qefaAWn_s>ZKxvHM&5rn{xAv?1GkjqzDo8egRQAZbm6X`^vj#atM9h z0b(AiS~TVi&e)mqX_%`YX`?Ps9(|veai)e617$WCm1+Knlj;SD$g zue&`-q*bY@O(?4XLpkb!ya750ukD#&=@b=(=bp%*H(J}uj|IN$FS)t;p7XmJ-KT-7 zq1pk@&9B!bQjc%Hk?%cCuzfUGK(|X-Yh%&aoxM}!-c_?(+Cu5+Vo0#&y5UFbDO{y` z1;PRYti!#b%1W0j=8HN|`xxDEqx`v)rt3pRiCAXTi>l8_4}+6L(e%A2%M_(})yiQq~S*L=VH zdOynQLNE8p;K9`WO#&eQOGgc4j3KG2InK3>3YWOPfAl`k`eYse$ZNFeKSqEV|4Q|K z?PD~?$--`idHKH-*WxGD1v)*A@&W()I;rlStm+g=h^t1y{T`;>s}!<*`|D9!c;@|o zD@>&m0RBW~S;!K8?r2!#>#mP`;jJECM?LnH&z3CVsn$3LaJ5E0@W+qzAoF+9|Ja__ z@9av#l1h*d`P?ECIQFGB1^4JH!GPOOM;|)lt4f!)g(JNGSCuXn)FFhlFmr*B-;?o= zbiAvQ@mQe--}Eb~z+}FG`Kj$yJ zC5?OYV;Db!;6G0RrtbM2b+sUI2=fTDtE&z{FkujZ>sXc5-G4hP>dE!7d$jkcoky<~ zYK~C?qs_+8jy^4{cxv`f`2t`w@(RWkOYNp*7=CiNNpQGPOeaV7dXzMK&!<{OK{b~D zIQxeJUFheir(pFepqycHfluY^c@Uw>^>2Z+KFnK@D3QmKibsUMbw&TBkr?H^Ca+z7 z;k|7}TXmV}L-s@z2v3~WuOMF@swqduNf^J$KBua7m#}HVCK8WEFaAplaI1BuUX{|&W^F?S1?U|~J~a}h zE6;bvi9frNU+`|5YczPXsR$fsJKY45V;0lJa}y<*BrqEtr$4N&O1k_G?(~by1KP-` zs+*Q~IYgGXHcmuqE}x@>SqgsFIrIrXVXtY?Tni5(gu`HQhO{*a(`*;GfN;~`YgJGs zPqT?EQ!Q>@k{@rEUq?coVpBSm@|q{H2KyVEmU$+9wIswUC}G&Yzj}e=&h>qwchYow zQxl^~WYMl1`nullZm})HK5Obj_V5Wc;V52~0-PqYE#pc9$FoEAdP8K?=#b@Xa1KR` zZQ-S6&GYm%8R`h6hO`!W5_H?a>z2k@Ljs9aS3{u&=D32J0pxK|&>2wYZl7dv5wvcM z3eZ6N=FZ#CyNM0S8AX0!%<{+2`)w0-ja}?8^Qml9AL!808`drNExfL@>o#D>L+9Ws39XkVs6lm%%Sa%+}M_(SWEe zKi+j69>W0Q)%-b4b7* zt7b_?`(5U~7b4@@bz!u^9=Ys$-KSnc_}Z0eZ-=WBjXwdYcZGBY%B4(AG4gT~dynXP z8Q0GT;>t#1)%8*sixuwfXtws+8{Y?E&ORHqRC7%UWj!w$fN`q$drzcsVH6DxZY559 zY?dG7a)C-a9o&Bk%JKclC79^N^6VvqIarj&i3qBU%T;R6IuAj7eTGOgl7X^zm zx9H6QlE%-NVE3)C-AoVV%?h+*6F$3tqmg!$w3x7j16lUJe{chL&& zrE5tY(jkQVLtnmF8gXkX48ziRInBXu5l1Z2xJE+LZq`=k^!ML8;|VxOAgmc^cSw6)`Q-@U%xl-5lPAk zXe^JiU+ce)Trb};ZtAK#!MWis9zyk^*&h*%v<+#$H4v&lx6V1}m>1ytLRKXQzlO&X zKlJ9CXP;BvR<{#Aw)CP%?u(dyu6B^j5~p?Wg(Xgz3ctx&ggT79q)U)!XPCy5Yvd-J zez;_Ws=D{tq6>OCUJCbyL*zT{VioFEIFakpXjVrb8WKc8?JaV=z|AM3d7FI;+sg`) zIznZw$^B|sh@gU-MG5r|bR1XJuYEHudvl-q6GhR{3`X;*t*7Y*#hFh^ zG1Fqz$GqWbEa@sLL!x>R*-DGydgZ70TkWzp6OUEf2cDYhy&VW5^lLE7VX{0EE@M{x z{#~{*Uy85MfEXwvr1yOB5+eIHmN7sJ;<3ZIAv8B~=WW`TQ^Z#tFFk-j`i5^9W2<9L z9JU_S#qMM+5xTrUvpvzNu}EK^M`18|Nf+df&l^P8m3VXUbNusIa#{+Pir7jcTB?!u zH1ixM9=;ap-s+aekD0B4YP0}HCBRrahiWOVo-B`}A|KsGRoKDmh)>^{jm$a)hTccKGbVoVJ41QyQe_DSspuh_IR1H{{`B zS6!tQY8@tLwRZ9~d|8{DAX6(MN#kRkq4G%;YI(YA+g3OCJS_|GF%%*mE%-QdT8D^CK2}WA@=E?H>5U{_o5=cJ_p%mYm%*-D?J~np z5m;qi20gmo*&M|j{3yNCDH-SN{uIcX`>obfwPz9?kC}U>U(kmphusu&mu}xH;K>Wn zpBjq4cQQ;C*XvfZ2vZ{vmpd0mjL_c;Wr|H0GGoL_jDX9yTR&d=$VWJvi%nhL2&+^P zH!qpp&!?`g5~Y_rZ$>#upr5*l_fc&>HqUr`vSOJ8(1J>WU~MkTO>}?SeAURQ1kTs@m91K zwDa+gg8d)lKKjM;XnjMkFU ztEo$rBiNeXZs5-)vUaZIeGJ*pdTjsf^xNZCG*vK#>$xIBd-aPlo~>kJ0wP2tGkfx( zh@ARYp;pex@+4A$UlObv?oofTTMz{C*`$-NAY--Q^fm;tbiKR+@-6u&0gy9%e~v2pYqU{t|wrv9b@Cq zw{wOnO)(caor#Li>K!@Wv?@5@^r0ZKTaC$#<(6mDA=T$}7Ino)^B}@n*r!uZZ@S(@ z0S)@rNwQ@7H93K34%Zr9Lr9m8p;@}-59s0d>B63}b=P~`uNuO?C>!lHtvZbLj|IGg zTu2kZO!i?+eHV+;zKbibrYzM*(RfO&ERiFMly$&11W}SUO1QiH8XciQlt&O*FF}iu zXW#!|^UCyGyF`alM&|o7)fd&J>cSQikCq<7Gg;lW2A@jwGzS< z@vs|k{T}D_jYz9Am1Px{Jby9i+SUb9SJ=smT6EXyGuS)@Y0&T?g6vwK`F9F9%d>G= z!o5ubw{7yxmLX9G{Q~w{!{Gp1h4TKWW7B5AB~#Fc-GN>usd9QX> z=G6Xp_UbW6s$=O0FM%LS@}nLrW5G+vvmHH*aKAsk^5H%RPP`Bf4bhS~I_uN!@9n(2 z_e`W%A$6qNlvxAu;05z`!P56QW+}Bj{8A`?b;OkH1?VS3R#j?&Nj(sGM#t#5^3P{NJ%C`fJt}qQhHpzU4OG10V0dHllb7puwd{vv0IQrZgVwTLiGb>U1w}=^NoAdfZaFY zWl27NqB=_IR|Z4cxnG6c3DnHwe+#YIp8_aHD)86EM?h^F z^uSfxxFWPyx6P*^Ok+zwzR~B01#aK(^0wdTNM7!btL3JljUL3n^BCtp1NRSr7KRSQ zFi!?}-Z0re3xWOtyMWrQ(?9$e477oRMgqC3VyAy1uFv(UXFZ0>Q({G$C3t>|Kre}q z{}FsWzg^z)n36J`P};ihR9-HPHRN6?#o{@*KpT-Uf2K)W1IB|ZZaP{VnQ;C`ZHqHs;a5+8&)_?t!2j!iAHq;NKy7U0apl27>uc&C{5_9<4dibV*nQ6mF7v=5 z>tcQ|{20b7It_lTNl2aM)n^!0zgk~ks2=CEhLCkz)W%Ra4_+l&>i zRB=B2kHUj@;$wFfHtvN}07j9yDL{ww&*=o9`4>w7Vgt5tPNfuR{_4*{s(;M6-@QL= zZ~8j0NE5UCgQEEJ5Kb>iYYYv*#|IAT=vn@Kp03>ncCVqG#D9(LelYGZ_MLcxgto2~ zaxuYzy!_S{DkwO=mco+-B=}mP&uaeaeetiyEAPKKVMq-A$+dtY4-@_#h4Px=<>qhs6DW)B#ANOydrYT=)6j)2&-oF z)AF*{lTVF{^&dSU%bl;r77V?5$T*?rKM!QD$1m(LT_R-_g9+OEbeB3X7b}b(a8J4a zYhb=s0>qU#M0V$c$JGbg8A88TvY~Q*v^!UNg7#U&h-Qn~?i8PR{iCb>AAYBR()`L0 zI*6|f!L;*U{pzoaWVEv*fr+P(FmwY=YDP|p_t(6M!|r*UtR$dH9ANT@U0yL?v?U;d z+G$`?98Q)stFiqN&yk#z#8M=xsHj*u`48gPf>VI^GW5d?Yb z&y18tRh7TpfHARllN)#wr=jMLIJ)&Lbwj4P1!rw~s5P z0L+W6Yn1n2pMjqR-Ib+|=IY`**b-+jEz-+lm&85D4+{J^)FgiUxl*m{td8GpQp|rh zNqJgIT1K>43GUbw^^ky(GYQm1nf}=jB|%spF$2`IJMMnq(wLs@=G|NAQhu3N-FOA} zp*Fr=>gQU!abn%a{A4daOj{HNRuCd`+r22BCH-I-2#do>A?gS;Y)0bF~rx3zT zDsDEkB5qGCe=8`tsOX`@@iz-~NPQL_ZU1AcdaL1@DMudjnbChCP(Ryt0r|#iduOfMJM`Bl`u>nbU;>u>$ z&IB2EcNI40(^~xxjdRw76blu5j<+1`^h)0(tS8tWyZZ!tD%7-r$o2g$rj$ldV*#T}_BPwhl0|0lM53#9i` zCAPgI>!6HO0m5%QrSb9&kmiqg< zOe;|+;qpN(&jyx(aAkgTvJ~jY(-k^eBmcGT=d&;ie!MLU3ijBzEO1MC^7{jO8A z9?AV!6Lx1G67(^0UAZI6zbKhj56WB~MMNT%_g=j#!Y=sNX~7M-Y3 zz`uw(bpXDfId3VYgN_)gs&(Zr*27t%a_X|DS3k}~52-$Hh80gr1^O2G8`UeH`rfJ@ zfGl*L7F!Q4chL=hORId~QN55zqN(^Z; zWjj86bzi_|qCjyufr)Av#cgJfI4XSyvJW3fXtlu~iT?I>KxgJmUyf3X1gvUjY2x=J zquZ!lL5Z>?wys!X)qZn66Pr^xa}Hw+pZ!rko<>e}aq*yssgpu&vn^puV+#lVcuk`V z`*f_?Uhvk(J*b}Mu1ec1&Aa@L z;hAW8jn@O7pa+b1cxnSBoAFEBRU9C*hXqS4ogZR}t}jpHWaRr-{ls{&Br+oX4&E<` zZ|Cqjg12w-DZAx4>%$-aa$1B8FH8r1=nQt+|MUr3;}BY%CHa#5SnvGc+?Vs*Uk*}! zGx$=+7b_5HwI=i_?&4tnjJ9!St;{od&%yPQ_M*fMl#w(dRaegm04UlC2~%q}RV%D| zPH4%7XygG%_u%fp>E_j1NJmRPYKKnZsD`r*mo!>5GgCqk@k#TH-_zy(%86SU#eoS zoaS?}q0OcDx+K&Jp^nR@0(;6~-pdQj{cGg4b_^{K<$XNJiqn}v=CB(}-?MudZrx;5 z-zT{vR;I9`ZUE`wZ!cJdC1^q%w~X)L_*&hk7Cq!^*=wqLI)w9MYtc9MLcqVyKV`he z*CX^oEA94e5;heGMZ9nfb4*kDr6C%6{$c0Fc#UA9r8qOcZB`j?P0mX@Gp*}&U%919=ZV6B=By=HE98h7Z;$pMK8PZ93Qxd#$9gVFcYWky82)7N!O zAzSQ486-dOt6*o2$T?C+O;0@H`JT}AVqEcuuXT@hE(1s3vW@8-g5~99d9BIkukmWR zN@QL&jK`^Th8RgG1X|^Mhd54-0@mdm!=ogj82WJn*Qig<=AdI_ra*z{^0UCpquTci zkBih_#q(S2LvGhTE|`^@50nnH^JBorQe_LOdqcu{94CR&0-hzz1)KsyUx-Apfo@8_ z>Y#dZN&JlPvHK@V1>cOLfu@|@#5!=m&5q>okA`yw_k4Fj4&$bsL%b3|Wnr=Fa}J+u z@cl6@9xl;vl$K9`*^4+N z@3Rig;VAeIougNy<0$>AALZI$0Cm@Rd{XpKIPye3Z1xJzhaOAar%&r&gG|p1W4Q z_5pV}1tMTEI#Vre-o_0WF2E6`VY{TUwZKGE_7jz7xLv$(*+^p&$h;JV1RT$aQz zCX|diRgJ&$R`Tneg-KQRz;)y9K;>GxPnt!VmFxFbDwcJdCX58qR4?6gnr?xfV%B>u zEoR*3&9~@J)@JxQHFD)9Iv8|*rS-s1wzkKB zmu{^yU8P)cBx;$(EXdko9-Om=S}Oje>pk>$2JqlQjhl$7S4I4Ma9_*tD3NeF9>S%^ z#r$m!#y~L3-_lUq9$5stI@EI9>vH`fJp_2_ikA0C(y`WV8=lK#AeNjXjGcQg!&TY= zoo@aqnU2dC8rvY|xFH>Qn{<{rkwI)PhLJ#S{t4(NNUblCP^V7aLtZBd(0ta}yfNSF zPUfxl(GP?q+$zqdFM(o(hBbFBYXn)fsx?DransOq0(tnivorT!P?Rw-!t%KDvTm|P z>)=JUJkJox4bb}8RQzm*3a8PTtS#L)q;Pt-&qsvSA&%DUbojgALmG`=jgcQQcyE43 zLN_*r%OF5AW72`b<>(s9iMdaYWQQRNxdq{Va?*Q?w1Uy|{KdxSSxn+kK4Qlo3K|jl zE2~2wKZ?63=Y1DNz?WIq@al~F}c&7y$dNE36>|V1lQVV3A!Fl=p1aK2Boua z$i+uYE6{^p7-mC7D#VIEzCqH))?x~p;kgH2K9Q}=-S>arVHPR!))yH60oUQ^is#l=TVY6%)C%^jZ*RLj7YngF5;KRMxhgH?@?uc%yu2|2I zEs=|dnSPCFk1!{^ZZVf`I%of>Ga{W0o9ty!4wAI@6sFvh@On3w+5$u881-mp}(VZvDv|NR&2 zBr6;9s#N-^nU_CheO-qqPSp%-mWUIB z8e7@PVEew4GW1>Kky-Y4_@#bi6?Ca(N4PEA#QFYpgC`!5?=wh|zC(z>2+Ts%20UNXqkv$Yv=OPP z_$XR*-$_2&>0&~p{etVPNp|?e$8%@tb=+$@xdd|{pBeX@{Y2cD`(|YbVJM4j#W*5& zyc;BD4HYd!Nvq2}SDjMh`gEWZvU`W&q2j}<{)H?%FZ+5P0y>J+ zhhdw)dl3RSI*RQ&(B>)~2Wa+qh`tYW{d5i0EU5GVso!CrT#W{tH%-Z_w12TbJ@|e# zw&PRX2CvTR9R^CwxMwK{HK%mvR2RXaqzeZt2}Xvof;Ri=5%sqgH|{CoD&YdWUPsm~ z9L`d%>*J|2WkjN9-~oZn?MRsZkYQuX>XsbNzpnl6UBbH>y1JVI%!}<0thl+}`;WaT zg3X^I>iWoNiWd?Q!H?qiq7rC-Bg58^-wYVr$FFsHGN_R@Xw&L6Heenbc2V3=^>0@d z7rZ$GrtrM~sL$F`MBo26w2k~1cs?%j6)q7wagec@nr=UEKAvlv$r-vJ+49!%`6t0q z?hS}jfN&m4`zMrTz8=5#?5rqbv+d%1el+&jIGEsaGXthOn{j&meMiVb3iK^fV2BI?gAM8t6r=Cp7+)RD2)Gsqyt#n^&6>%i{B(5JwL&+{{r@Z^B$240O z!PWiC`|FrG|AQ~;5%}}LY5LJ2fAT?vuZ(RjEjHmm<3R@<$U`?nrPnsoxn?)gZ1f=! z=2ryaXS|zlA`34!TXdm&A5J+-B`(Z=_tm`fCr9Hh@bH7)$iK7zQh(7*g|9xVW_A2V z@6FT|nKto{&8+{F4`hAMLS8jws5F|Z#4feiXYnJw?8Vd8;!0UU%ywi1Zb}JQzSL!9 zOqEIH+dfbNhm#%@gC6Npl{N_&`3f=MY*{}gM2yAe#W%fi+`s(Ym211e>559Q-v4Cv z$Mq~p=dyCJ6D#DB!AKSl=#*0M7ndaXU(ZI@wEkP0kn@%5+xCfiiJZB9*_Afo^XY4c zJOcdgZxS%DQzqqEU5zZQuxG<*h=g>eSjIsR`Jdo3z@9nscB?3NNBuD-l##LhdkSjw zFkAP~V%+SD@{ox`YbH8yT6NkW^Q)P63tMZyHRQ6W(Hq(;-}m-(-18t6ueP9;BT8N9 z`!5CwZz8|{)!ui8HPv+OD!m4jCek}9O+ZTMO{542s35&a69^?h=prQu3Mc}C6oX2U z-jNpRQlv<)p-2r7I#NQutvQa;@v4Cu zSvhBZ%7QBpYxYBk6&VRM<59KHjP|KXOd+#*wG_lKn#b=8+%)6e4-%NwmnBmR;?}&e zIdzWpyvl0Hywd21`H_5F^`ORhAR>Su*bQ+My6xWQpn4n_wUj%y2+&(iIfG_8(r06{o0o+?fX(*~1%1{ds4 zY?2{sq>JeNw51g2vXf7DNn4eEH?d7%{b3F@UsKs}wn)0P`L$CQxkf>m#uCvWEnT;0 zJvY+?y!mgGDy8kCmM7^mFI^+s<0mtcmZq}}0bvHRFa!Sc!yv#%XQv8?$7>3_cN)&H zIg@+&#C$J2d#P53w2q_vXPU_o<>{dQjTuqxmS*xt^(p*159MgKXY7OZlG$J0KePD! zKHm%``qrFMT~6HGXXtfI$XB(L58X!njl~HqMmnz6bhH<`Cu!L}T{WimXpi{9Sx-yO z_08CE;^vKRHwIL?T9tdAj>sML8?=v03Va&bB9r38LC3BYGxP|(weU@eSDZAx`>9R# z+pW(0k&HoCCC7-mVh+KqP}q4psdkyx!rCRqIih!$aZ z*cP6+vYvG*$GEN4l9BquTRT!ItjRm4m#7WhB5(JN~+0n415(ai}6>bE^d7ZV8 z?r-FarNz@ALZdxntESh!({;XpD_Iy!+wxjGJ3=)YbZQA?P=qUM$LI?@A$tspaB(cW zA8qc?Dt@WzNw|*DB^ScS7ySdVqP*$Xmyg6tBv&ns5Tw+f#C~eSY4pF^01S_UyhKzE z*d{tf7?ZeZBK?okB(g%wETebKZ|pvd%yslqB|d<4hrN#aV9b8~G02K4@te*wn*Q06 zEW>uvBm$Jf7Kgy6u=)}0_7Wh@hD!MDLq+p{?2*|od(_EJ%>gEujH1EcO1K1m8;E|A zt-_dPmYejlaujBKArwSpP5d^}H;%UNDk;t*3e<6*Zzw3YLGm%^mPymxYI#d$^OiRf zM!gyzal}p3Oi?Mk(Lyrd6Cvn{u(=-U{HD{=zg2(g>Loz38kvlTIVO+##5#1|1xN8!GkrsHL9nxiMOY31TWq{7}&S|^^U1LY^kn9Qqa zr4gLm>PJ#?I|r8?v<;prIo4LB+337EwisHNkQs7d64Zf`R8`F!&PY2p&ve9JinCQL zW{B z*c8Uq+<`F0#Q+v7kq)~5(p$}ZPP&nMFsL~bqrvwq7g>uU+%7rZKAg3oitezUQ-GDd zj|5UXZuebgr*3XCBVdSi&^AWiTao9`xmrS{>>?~-O{xaZES_g%Tj!F%$IIoLR-W8y zG>)O)9}}SNZIr7VWoYJ%YrFgaolA+}5>wgoe-zj)*Le*#XjcH`Tm|U3(Vv(DLik^6 zPd9*gl3z^#fD6TUq|_vpUsP>&wCJ0)liGdF1RVD+0f`DD0Y>aQpa}g1DD;Q`#3%aN z9Y|7LY)f$AO(KQYTH@2?d&L#jD*~z-)J7ElNOS1F()_Y7>FiT2;JDp8?l{ikCH*_u zLY<{AS&j2T);DG|jCBdz`MC*Q3&@Sc@$mMPell!)T>l`L|LX8eNGc6U+m~mLN&-Bl z72g9;mEa%N5jfKNe_|aue%h-l!e934cgzBC#PY?OMX}>u>vT9ERnc7ScQEl^EESNY zSLKW@+`%al^!?nC$A)0xAGBgIP!ID5?@2&M%aY$TsZ#}z5MN6*{ucrI0bn0bO|H4C z9<{XdJP35am84wUn`oU0$}kb|Zry^qt;3h&FzMPq@<9F~tAJ+_paJIFVKWk>kr}#& zRcF&Exbqas1$ug>x(O=O(TIXK;tSweCt~Hh(F} z;{Fd;^K*{8zSk8Pf9YK0Hr5ga>PaMf5F75}PnZt!64&md{o~6E=lCt4o302;e>LCT z9go}LYQ%YC(>4ZmIB$ijpxWR`OhIslv}fDt&MchmA~Lq~3Yk|itM66Py|Dkl(;owi zQ)S<1Q9O_tfMD#0Q40Gdu`BsyJ*7A!94Lq_A;|Zj^j1lXEV2sR*t8%$8ooS%qW zlzeuTr8E+7<8Ww3l0?z)&Nf~n;#9#xweI~F?*@#^mW$f-gpZW!7GCIzXC80k36KRO z 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 literal 0 HcmV?d00001 diff --git a/server/__init__.py b/server/__init__.py index 381f755..a829022 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -43,8 +43,6 @@ def create_app(): user = User.query.filter_by(email=ADMIN_EMAIL).first() # if this returns a user, then the email already exists in database if user: # if a user is found, we want to make it an admin user.admin = True - user.admin = True - user.admin = True else: # create new user with the supplied data. Hash the password so plaintext version isn't saved. new_user = User(email=ADMIN_EMAIL, name="Admin", password=generate_password_hash(ADMIN_PASSWORD, method='sha256'), admin=True) @@ -65,5 +63,15 @@ def load_user(user_id): # blueprint for non-auth parts of app from .main import main as main_blueprint app.register_blueprint(main_blueprint) + sentry_sdk.init( + dsn="https://ccfebfa76dc645acbc16566836763e5b@o231748.ingest.sentry.io/6118097", + integrations=[FlaskIntegration()], + + # Set traces_sample_rate to 1.0 to capture 100% + # of transactions for performance monitoring. + # We recommend adjusting this value in production. + traces_sample_rate=1.0 +) + return app \ No newline at end of file diff --git a/server/img/status.png b/server/img/status.png deleted file mode 100644 index a9d71cb3cb4d7793a73463df3c2b303044d26016..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 19122 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 VF}+guLm*#;Y)?B_mY93S{Renr0)GGi From 8149ffd65317aecdc4281cac1375d5f3f73c7754 Mon Sep 17 00:00:00 2001 From: Marco Date: Sun, 2 Jan 2022 11:50:00 +0100 Subject: [PATCH 44/73] removed debug-tool where any device would get auto-whitelisted --- server/main.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/server/main.py b/server/main.py index b9a65aa..c6b9712 100644 --- a/server/main.py +++ b/server/main.py @@ -206,9 +206,6 @@ def update(): return "No update needed.", 304 else: log_event("ERROR: Device not whitelisted.") - # Temporarily whitelist immediately! TODO: REMOVE THIS IN PROD! - device.type = platform.id - db.session.commit() return "Error: Device not whitelisted.", 400 else: log_event("ERROR: Unkown platform") From 1c547f31dc21df9ed4fa06aba0afcad6828d6f8b Mon Sep 17 00:00:00 2001 From: Marco Date: Sun, 2 Jan 2022 12:01:22 +0100 Subject: [PATCH 45/73] check platform-name for illegal characters --- server/main.py | 8 +++++--- server/templates/create.html | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/server/main.py b/server/main.py index c6b9712..4aa5cd2 100644 --- a/server/main.py +++ b/server/main.py @@ -57,10 +57,12 @@ def create_post(): if not platform_name: flash("No platform name entered") return redirect(url_for("main.create")) + m = re.match("^[a-zA-Z0-9\-]*$", platform_name) + if not m: # Platform has invalid characters + flash('Error: Platform name contains illegal characters. Only a-Z, 0-9 and - are allowed') + return redirect(url_for("main.create")) - platform = Platform.query.filter_by( - name=platform_name - ).first() # if this returns a user, then the email already exists in database + platform = Platform.query.filter_by(name=platform_name).first() # if this returns a result, then the platform already exists in database if platform: flash("Platform already exists") return redirect(url_for("main.create")) diff --git a/server/templates/create.html b/server/templates/create.html index 5b9b128..cec8879 100644 --- a/server/templates/create.html +++ b/server/templates/create.html @@ -9,7 +9,7 @@

    Add platform

    {% with messages = get_flashed_messages() %} {% if messages %}
    {% endif %} {% endwith %} From ebb6057d96e70d83b284e5db1c0a1d4c055e4f64 Mon Sep 17 00:00:00 2001 From: Marco Date: Sun, 2 Jan 2022 12:16:43 +0100 Subject: [PATCH 46/73] allow adding a device that was never seen to the whitelist. This can help in allowing devices to be able to update on the first try --- server/main.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/server/main.py b/server/main.py index 4aa5cd2..9bfc437 100644 --- a/server/main.py +++ b/server/main.py @@ -123,14 +123,18 @@ def whitelist_post(): if len(__mac) == 12: # Check that address is not already on a whitelist. known_device = Device.query.filter_by(mac=__mac).first() - if not known_device: - flash('Error: Unknown device. Let the device connect to the OTA server before adding') - return render_template("whitelist.html", platforms=platforms, unbound_devices=unbound_devices) - if known_device.type: + if known_device and known_device.type: flash('Error: Address already on a whitelist.') return render_template("whitelist.html", platforms=platforms, unbound_devices=unbound_devices) # All looks good - add to whitelist. known_platform = Platform.query.filter_by(name=request.form['device']).first() + if not known_device and known_platform: # device was not know, but platform is + device = Device(mac=__mac, type=known_platform.id, notes=request.form.get('notes')) + # add the new device to the database + db.session.add(device) + db.session.commit() + flash('Success: Added previously unkown device {} to whitelist of {}'.format(__mac, known_platform.name)) + return render_template("whitelist.html", platforms=platforms, unbound_devices=unbound_devices) if known_device and known_platform: known_device.type = known_platform.id known_device.notes = request.form.get('notes') From 634bdfff0967eb2e09d088c7a283cec029718b36 Mon Sep 17 00:00:00 2001 From: Marco Date: Sun, 2 Jan 2022 13:23:41 +0100 Subject: [PATCH 47/73] check if the requested update is for the correct platform Add MD5 to response headers, so the Arduino can check if the update was retrieved correct Scroll to top on clicking add button Fix delete button not working --- README.md | 1 - server/main.py | 40 ++++++++++++++++++++++----------- server/templates/whitelist.html | 5 +++-- 3 files changed, 30 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 3aa15ac..3272266 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,6 @@ void checkForUpdates(void) - [ ] Ability to delete platforms - [ ] Better input handling/checking - [ ] Getting it production-ready and safe -- [ ] Prevent underscores in platform-name - [ ] Make compatible with AutoConnect ## Legal diff --git a/server/main.py b/server/main.py index 9bfc437..3da5dae 100644 --- a/server/main.py +++ b/server/main.py @@ -10,6 +10,7 @@ send_from_directory, current_app, ) +from flask.helpers import make_response from flask_login import login_required, current_user from sqlalchemy.sql.expression import desc from .models import User, Platform, Device @@ -19,6 +20,8 @@ import re from packaging import version # for semver support import os +import hashlib + main = Blueprint("main", __name__) @@ -154,6 +157,7 @@ def whitelist_post(): def update(): __error = 400 __dev = request.args.get("dev", default=None) # get requested device version + if "X_ESP8266_STA_MAC" in request.headers: __mac = request.headers["X_ESP8266_STA_MAC"] __mac = str(re.sub(r"[^0-9A-fa-f]+", "", __mac.lower())) @@ -165,10 +169,12 @@ def update(): else: __mac = "" log_event("WARN: Update called without known headers.") - __ver = version.parse( - request.args.get("ver", default=None) - ) # parse version, brings a bit extra safety - if __dev and __mac and __ver and len(__mac) == 12: + __ver = version.parse(request.args.get("ver", default=None)) # parse version, brings a bit extra safety + platform_valid = re.match("^[a-zA-Z0-9\-]*$", __dev) # Check if the platform contains only valid characters + if not platform_valid: + log_event("ERROR: Invalid parameters.") + return "Error: Invalid parameters.", 400 + if __dev and __mac and __ver and len(__mac) == 12 : # If we know this device already device = Device.query.filter_by(mac=__mac).first() if device: @@ -187,26 +193,26 @@ def update(): platform = Platform.query.filter_by(name=__dev).first() if platform: # device is known for a platform device_whitelisted = ( - Platform.query.join(Device).filter(Device.mac == __mac).first() + Platform.query.join(Device).filter(Device.mac == __mac).filter(Platform.name == __dev).first() # check if the device is whitelisted and is requesting the correct firmware ) - # device_whitelisted = True if device_whitelisted: if not platform.version: # when no file has been uploaded log_event("ERROR: No update available.") return "No update available.", 400 if __ver < version.parse(platform.version): - if os.path.isfile( - current_app.config["UPLOAD_FOLDER"] + "/" + platform.file - ): + if os.path.isfile(os.path.join(os.path.dirname(__file__), current_app.config['UPLOAD_FOLDER'], platform.file)): platform.downloads += 1 db.session.commit() - return send_from_directory( - directory=current_app.config["UPLOAD_FOLDER"], - filename=platform.file, + response = make_response( + send_from_directory( + directory=os.path.join(os.path.dirname(__file__), current_app.config['UPLOAD_FOLDER']), + path=platform.file, as_attachment=True, mimetype="application/octet-stream", attachment_filename=platform.file, - ) + )) + response.headers['x-MD5'] = get_MD5(platform.file) + return response else: log_event("INFO: No update needed.") return "No update needed.", 304 @@ -278,3 +284,11 @@ def upload_post(): flash('Error: File type not allowed.') return redirect(request.url) +def get_MD5(filename): + path = os.path.join(os.path.dirname(__file__), current_app.config['UPLOAD_FOLDER'], filename) + if os.path.isfile(path): + f = open(path, 'rb') + bin_file = f.read() + f.close() + md5 = hashlib.md5(bin_file).hexdigest() + return md5 \ No newline at end of file diff --git a/server/templates/whitelist.html b/server/templates/whitelist.html index d54b044..3869f2f 100644 --- a/server/templates/whitelist.html +++ b/server/templates/whitelist.html @@ -103,11 +103,11 @@

    {{ platform.name }}

    -
    +
    @@ -132,6 +132,7 @@

    {{ platform.name }}

    {% endwith %} {% with devices = unbound_devices %} From d045fdc1f6967c7adf75537c4f56cf4475f67223 Mon Sep 17 00:00:00 2001 From: Marco Date: Mon, 3 Jan 2022 16:11:46 +0100 Subject: [PATCH 48/73] add venv to gitignore fix docker not working due to wrong file location of /bin fix identation issue --- .gitignore | 56 +++++++++++++++++++++++++++++++------------------- README.md | 2 +- dockerfile | 6 ++---- server/main.py | 13 ++++++------ 4 files changed, 44 insertions(+), 33 deletions(-) diff --git a/.gitignore b/.gitignore index db1d2d6..4ec2567 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,13 @@ # File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig -# Created by https://www.toptal.com/developers/gitignore/api/windows,visualstudiocode,flask -# Edit at https://www.toptal.com/developers/gitignore?templates=windows,visualstudiocode,flask +# Created by https://www.toptal.com/developers/gitignore/api/windows,visualstudiocode,flask,venv +# Edit at https://www.toptal.com/developers/gitignore?templates=windows,visualstudiocode,flask,venv ### Flask ### instance/* !instance/.gitignore .webassets-cache +.env ### Flask.Python Stack ### # Byte-compiled / optimized / DLL files @@ -25,11 +26,12 @@ dist/ downloads/ eggs/ .eggs/ +lib/ +lib64/ parts/ sdist/ var/ wheels/ -pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg @@ -59,7 +61,7 @@ coverage.xml *.py,cover .hypothesis/ .pytest_cache/ -pytestdebug.log +cover/ # Translations *.mo @@ -79,9 +81,9 @@ instance/ # Sphinx documentation docs/_build/ -doc/_build/ # PyBuilder +.pybuilder/ target/ # Jupyter Notebook @@ -92,7 +94,9 @@ profile_default/ ipython_config.py # pyenv -.python-version +# 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. @@ -101,9 +105,6 @@ ipython_config.py # install all needed dependencies. #Pipfile.lock -# poetry -#poetry.lock - # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ @@ -115,15 +116,12 @@ celerybeat.pid *.sage.py # Environments -# .env -.env/ -.venv/ +.venv env/ venv/ ENV/ env.bak/ venv.bak/ -pythonenv* # Spyder project settings .spyderproject @@ -146,25 +144,40 @@ dmypy.json # pytype static type analyzer .pytype/ -# operating system-related files -*.DS_Store #file properties cache/storage on macOS -Thumbs.db #thumbnail cache on Windows - -# profiling data -.prof - +# Cython debug symbols +cython_debug/ + +### venv ### +# Virtualenv +# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ +[Bb]in +[Ii]nclude +[Ll]ib +[Ll]ib64 +[Ll]ocal +[Ss]cripts +pyvenv.cfg +pip-selfcheck.json ### VisualStudioCode ### .vscode/* +!.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json +!.vscode/extensions.json *.code-workspace +# Local History for Visual Studio Code +.history/ + ### VisualStudioCode Patch ### # Ignore all local history of files .history .ionide +# Support for Project snippet scope +!.vscode/*.code-snippets + ### Windows ### # Windows thumbnail cache files Thumbs.db @@ -191,7 +204,8 @@ $RECYCLE.BIN/ # Windows shortcuts *.lnk -# End of https://www.toptal.com/developers/gitignore/api/windows,visualstudiocode,flask +# End of https://www.toptal.com/developers/gitignore/api/windows,visualstudiocode,flask,venv # Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) + server/bin/* diff --git a/README.md b/README.md index 3272266..d0eaf10 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ SET ADMIN_PASSWORD=verysecurepassword ``` Docker: ``` -docker run -d --restart unless-stopped --name esp-update-server -v $PWD/bin:/esp-update-server/bin -p 5000:5000 --env ADMIN_EMAIL=desired_login_email@yahoo.com --env ADMIN_PASSWORD=verysecurepassword marcovannoord/esp-update-server:latest +docker run --rm -it -v $PWD/server/bin:/server/bin --env ADMIN_EMAIL=desired_login_email@yahoo.com --env ADMIN_PASSWORD=verysecurepassword -p 5000:5000/tcp marcovannoord/esp-update-server:latest ``` ### Start server from source diff --git a/dockerfile b/dockerfile index 8a94c97..8de022a 100644 --- a/dockerfile +++ b/dockerfile @@ -1,11 +1,9 @@ # syntax=docker/dockerfile:1 FROM python:3.8-slim-buster -COPY . /esp-update-server -WORKDIR /esp-update-server - +COPY ./server /server COPY requirements.txt requirements.txt RUN pip3 install -r requirements.txt ENV FLASK_APP=server -ENV FLASK_ENV=production +ENV FLASK_ENV=develop EXPOSE 5000 CMD [ "python3", "-m" , "flask", "run", "--host=0.0.0.0"] \ No newline at end of file diff --git a/server/main.py b/server/main.py index 3da5dae..69ed941 100644 --- a/server/main.py +++ b/server/main.py @@ -273,13 +273,12 @@ def upload_post(): else: flash('Error: Version must increase. File not uploaded.') return redirect(request.url) - m = re.search(b"update\?dev=" + platform.name.encode('UTF-8')+ b"&ver=$", data, re.IGNORECASE) - if m: # only a platform was found, meaning no version was found - flash('Error: No version found in file. File not uploaded.') - return redirect(request.url) - else: - flash('Error: No known platform name found in file. File not uploaded.') - return redirect(request.url) + m = re.search(b"update\?dev=" + platform.name.encode('UTF-8')+ b"&ver=$", data, re.IGNORECASE) + if m: # only a platform was found, meaning no version was found + flash('Error: No version found in file. File not uploaded.') + return redirect(request.url) + flash('Error: No known platform name found in file. File not uploaded.') + return redirect(request.url) else: flash('Error: File type not allowed.') return redirect(request.url) From 4a988f6fe70eb63dcf36af89ef173c3dcbdf79f9 Mon Sep 17 00:00:00 2001 From: Marco Date: Mon, 3 Jan 2022 17:02:18 +0100 Subject: [PATCH 49/73] fix docker volume pointing to wrong directory fix issue where old file was not deleted --- README.md | 2 +- server/main.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d0eaf10..e08b93c 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ SET ADMIN_PASSWORD=verysecurepassword ``` Docker: ``` -docker run --rm -it -v $PWD/server/bin:/server/bin --env ADMIN_EMAIL=desired_login_email@yahoo.com --env ADMIN_PASSWORD=verysecurepassword -p 5000:5000/tcp marcovannoord/esp-update-server:latest +docker run --restart unless-stopped -d --name esp-update-server -v $PWD/bin:/server/bin --env ADMIN_EMAIL=desired_login_email@yahoo.com --env ADMIN_PASSWORD=verysecurepassword -p 5000:5000/tcp marcovannoord/esp-update-server:latest ``` ### Start server from source diff --git a/server/main.py b/server/main.py index 69ed941..9941bb7 100644 --- a/server/main.py +++ b/server/main.py @@ -265,7 +265,7 @@ def upload_post(): # Only delete old file after db is updated; so the old file will not be deleted if old_file and current_app.config['DELETE_OLD_FILES']: try: - os.remove(os.path.join(current_app.config['UPLOAD_FOLDER'], old_file)) + os.remove(os.path.join(os.path.dirname(__file__),current_app.config['UPLOAD_FOLDER'], old_file)) except: flash('Error: Removing old file failed.') flash('Success: File uploaded for platform {} with version {}.'.format(platform.name, __ver)) From 44edb6f8ade5b2ab4fcd649acf40377576a83813 Mon Sep 17 00:00:00 2001 From: Marco Date: Mon, 3 Jan 2022 17:53:39 +0100 Subject: [PATCH 50/73] added multiple colors for user-notifications --- dockerfile | 2 +- server/main.py | 11 ++++++----- server/templates/create.html | 7 ------- server/templates/layout.html | 9 +++++++++ server/templates/login.html | 7 ------- server/templates/signup.html | 7 ------- server/templates/upload.html | 7 ------- server/templates/whitelist.html | 7 ------- 8 files changed, 16 insertions(+), 41 deletions(-) diff --git a/dockerfile b/dockerfile index 8de022a..6d6867b 100644 --- a/dockerfile +++ b/dockerfile @@ -4,6 +4,6 @@ COPY ./server /server COPY requirements.txt requirements.txt RUN pip3 install -r requirements.txt ENV FLASK_APP=server -ENV FLASK_ENV=develop +ENV FLASK_ENV=production EXPOSE 5000 CMD [ "python3", "-m" , "flask", "run", "--host=0.0.0.0"] \ No newline at end of file diff --git a/server/main.py b/server/main.py index 9941bb7..d18741b 100644 --- a/server/main.py +++ b/server/main.py @@ -76,6 +76,7 @@ def create_post(): # add the new user to the database db.session.add(new_platform) db.session.commit() + flash("Success: Added new platform{}".format(new_platform.name),'success') return redirect(url_for("main.whitelist")) @@ -106,7 +107,7 @@ def whitelist_post(): device = Device.query.filter_by(id=device_id).first() device.type = None # Set the type to None, instead of deleting the device completely db.session.commit() - flash("Deleted device from platform") + flash("Deleted device from platform",'success') # Edit notes if request.form.get('_method') and 'NOTES' in request.form.get('_method'): @@ -115,7 +116,7 @@ def whitelist_post(): device = Device.query.filter_by(id=device_id).first() device.notes = request.form.get('_notes') # update the note db.session.commit() - flash("Updated note") + flash("Updated note",'success') elif request.form.get('action') and 'ADD' in request.form.get('action'): # Ensure valid data. @@ -136,13 +137,13 @@ def whitelist_post(): # add the new device to the database db.session.add(device) db.session.commit() - flash('Success: Added previously unkown device {} to whitelist of {}'.format(__mac, known_platform.name)) + flash('Success: Added previously unkown device {} to whitelist of {}'.format(__mac, known_platform.name),'success') return render_template("whitelist.html", platforms=platforms, unbound_devices=unbound_devices) if known_device and known_platform: known_device.type = known_platform.id known_device.notes = request.form.get('notes') db.session.commit() - flash('Success: {} added to platform {}'.format(known_device.mac, known_platform.name)) + flash('Success: {} added to platform {}'.format(known_device.mac, known_platform.name),'success') else: flash('Error: Platform unkown') else: @@ -268,7 +269,7 @@ def upload_post(): os.remove(os.path.join(os.path.dirname(__file__),current_app.config['UPLOAD_FOLDER'], old_file)) except: flash('Error: Removing old file failed.') - flash('Success: File uploaded for platform {} with version {}.'.format(platform.name, __ver)) + flash('Success: File uploaded for platform {} with version {}.'.format(platform.name, __ver), 'success') return redirect(url_for('main.whitelist')) else: flash('Error: Version must increase. File not uploaded.') diff --git a/server/templates/create.html b/server/templates/create.html index cec8879..8f0142d 100644 --- a/server/templates/create.html +++ b/server/templates/create.html @@ -6,13 +6,6 @@

    Add platform

    - {% with messages = get_flashed_messages() %} - {% if messages %} -
    - {{ messages[0] }} -
    - {% endif %} - {% endwith %}
    diff --git a/server/templates/layout.html b/server/templates/layout.html index 838185a..ac4c332 100644 --- a/server/templates/layout.html +++ b/server/templates/layout.html @@ -62,6 +62,15 @@
    + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
    + {{message}} +
    + {% endfor %} + {% endif %} + {% endwith %} {% block content %} {% endblock %}
    diff --git a/server/templates/login.html b/server/templates/login.html index c3e5a7d..c31fa14 100644 --- a/server/templates/login.html +++ b/server/templates/login.html @@ -6,13 +6,6 @@

    Login

    - {% with messages = get_flashed_messages() %} - {% if messages %} -
    - {{ messages[0] }} -
    - {% endif %} - {% endwith %}
    diff --git a/server/templates/signup.html b/server/templates/signup.html index 0ea40f2..7e90ed3 100644 --- a/server/templates/signup.html +++ b/server/templates/signup.html @@ -6,13 +6,6 @@

    Sign Up

    - {% with messages = get_flashed_messages() %} - {% if messages %} -
    - {{ messages[0] }}. Go to login page. -
    - {% endif %} - {% endwith %}
    diff --git a/server/templates/upload.html b/server/templates/upload.html index 530f080..dcbfcc2 100644 --- a/server/templates/upload.html +++ b/server/templates/upload.html @@ -6,13 +6,6 @@

    Upload Platform Image

    Upload a new binary. The version and platform will be automatically extracted

    - {% with messages = get_flashed_messages() %} - {% if messages %} -
    - {{ messages[0] }} -
    - {% endif %} - {% endwith %}
    diff --git a/server/templates/whitelist.html b/server/templates/whitelist.html index 3869f2f..450b54c 100644 --- a/server/templates/whitelist.html +++ b/server/templates/whitelist.html @@ -10,13 +10,6 @@

    Easy system to manage OTA updates for Espressif-based devices

    - {% with messages = get_flashed_messages() %} - {% if messages %} -
    - {{ messages[0] }} -
    - {% endif %} - {% endwith %}

    Add device

    From cf3438a47e18ee14468523cfb6f3f7804769790c Mon Sep 17 00:00:00 2001 From: Marco Date: Tue, 4 Jan 2022 09:45:12 +0100 Subject: [PATCH 51/73] Fixed issue where manually added device would have a not-Null value for first_seen and last_seen --- server/main.py | 30 ++++++++++++++++++++++-------- server/templates/whitelist.html | 8 ++++---- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/server/main.py b/server/main.py index d18741b..0b2f4dd 100644 --- a/server/main.py +++ b/server/main.py @@ -39,6 +39,17 @@ def log_event(msg): def index(): return render_template("index.html") +# Sets the last_seen dates of never-seen devices to None +@main.route("/filter_unknown") +@login_required +def filter_unknown(): + devices = Device.query.filter_by(version=None) + for device in devices: + device.first_seen = None + device.last_seen = None + db.session.commit() + return render_template("index.html") + @main.route("/profile") @login_required @@ -76,7 +87,7 @@ def create_post(): # add the new user to the database db.session.add(new_platform) db.session.commit() - flash("Success: Added new platform{}".format(new_platform.name),'success') + flash("Success: Added new platform{}".format(new_platform.name),'warning') return redirect(url_for("main.whitelist")) @@ -107,7 +118,7 @@ def whitelist_post(): device = Device.query.filter_by(id=device_id).first() device.type = None # Set the type to None, instead of deleting the device completely db.session.commit() - flash("Deleted device from platform",'success') + flash("Deleted device from platform",'warning') # Edit notes if request.form.get('_method') and 'NOTES' in request.form.get('_method'): @@ -116,7 +127,7 @@ def whitelist_post(): device = Device.query.filter_by(id=device_id).first() device.notes = request.form.get('_notes') # update the note db.session.commit() - flash("Updated note",'success') + flash("Updated note",'warning') elif request.form.get('action') and 'ADD' in request.form.get('action'): # Ensure valid data. @@ -132,18 +143,21 @@ def whitelist_post(): return render_template("whitelist.html", platforms=platforms, unbound_devices=unbound_devices) # All looks good - add to whitelist. known_platform = Platform.query.filter_by(name=request.form['device']).first() - if not known_device and known_platform: # device was not know, but platform is + if not known_device and known_platform: # device was not known before, but platform is valid device = Device(mac=__mac, type=known_platform.id, notes=request.form.get('notes')) # add the new device to the database db.session.add(device) db.session.commit() - flash('Success: Added previously unkown device {} to whitelist of {}'.format(__mac, known_platform.name),'success') + device.first_seen = None # due to some issue with sqlite, it seems impossible to create the device with None as first and last_seen. + device.last_seen = None # Therefore, first add the device and then set them to None + db.session.commit() + flash('Success: Added previously unkown device {} to whitelist of {}'.format(__mac, known_platform.name),'warning') return render_template("whitelist.html", platforms=platforms, unbound_devices=unbound_devices) - if known_device and known_platform: + if known_device and known_platform: # if the device and platform are known, whitelist the device known_device.type = known_platform.id known_device.notes = request.form.get('notes') db.session.commit() - flash('Success: {} added to platform {}'.format(known_device.mac, known_platform.name),'success') + flash('Success: {} added to platform {}'.format(known_device.mac, known_platform.name),'warning') else: flash('Error: Platform unkown') else: @@ -269,7 +283,7 @@ def upload_post(): os.remove(os.path.join(os.path.dirname(__file__),current_app.config['UPLOAD_FOLDER'], old_file)) except: flash('Error: Removing old file failed.') - flash('Success: File uploaded for platform {} with version {}.'.format(platform.name, __ver), 'success') + flash('Success: File uploaded for platform {} with version {}.'.format(platform.name, __ver), 'warning') return redirect(url_for('main.whitelist')) else: flash('Error: Version must increase. File not uploaded.') diff --git a/server/templates/whitelist.html b/server/templates/whitelist.html index 450b54c..7c09c5b 100644 --- a/server/templates/whitelist.html +++ b/server/templates/whitelist.html @@ -79,8 +79,8 @@

    {{ platform.name }}

    {{ format_mac(device.mac.upper()) }} {{device.version}} {{device.IP}} - {{ moment(device.first_seen).format('DD-MM-YYYY HH:mm') }} - {{ moment(device.last_seen).format('DD-MM-YYYY HH:mm') }} ({{ moment(device.last_seen).fromNow()}}) + {% if device.first_seen %}{{ moment(device.first_seen).format('DD-MM-YYYY HH:mm') }}{% else %}Never{% endif %} + {% if device.last_seen %}{{ moment(device.last_seen).format('DD-MM-YYYY HH:mm') }} ({{ moment(device.last_seen).fromNow()}}){% else %}Never{% endif %} {{device.notes}} @@ -161,8 +161,8 @@

    These devices have been seen, but have {{device.version}} {{device.requested_platform}} {{device.IP}} - {{ moment(device.first_seen).format('DD-MM-YYYY HH:mm') }} - {{ moment(device.last_seen).format('DD-MM-YYYY HH:mm') }} ({{ moment(device.last_seen).fromNow()}}) + {% if device.first_seen %}{{ moment(device.first_seen).format('DD-MM-YYYY HH:mm') }}{% else %}Never{% endif %} + {% if device.last_seen %}{{ moment(device.last_seen).format('DD-MM-YYYY HH:mm') }} ({{ moment(device.last_seen).fromNow()}}){% else %}Never{% endif %} {{device.notes}} From 58bb76febd98bebdad392be7da6a1e61bdb9ff44 Mon Sep 17 00:00:00 2001 From: Marco Date: Tue, 4 Jan 2022 12:18:12 +0100 Subject: [PATCH 52/73] fixed bug where manually added device would never have first_seen, even after the device comes online --- server/main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/main.py b/server/main.py index 0b2f4dd..06661d2 100644 --- a/server/main.py +++ b/server/main.py @@ -197,6 +197,8 @@ def update(): device.version = str(__ver) device.requested_platform = __dev device.IP = request.remote_addr + if device.first_seen is None: # If the device was manually added, first_seen will be None + device.first_seen = datetime.utcnow() else: device = Device(mac=__mac, version=str(__ver), requested_platform=__dev, IP=request.remote_addr) # add the new device to the database From b457ce65e341c12187091cb0edb56dc0e1863039 Mon Sep 17 00:00:00 2001 From: Marco Date: Tue, 4 Jan 2022 14:41:54 +0100 Subject: [PATCH 53/73] fix typo in environment variables Make sure platform-names are always lowercase --- README.md | 8 ++++---- server/main.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e08b93c..9986dd0 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,8 @@ You need to create a new admin-user to be able to access it. This can be done by Linux: ``` -ENV ADMIN_EMAIL=desired_login_email@yahoo.com -ENV ADMIN_PASSWORD=verysecurepassword +export ADMIN_EMAIL=desired_login_email@yahoo.com +export ADMIN_PASSWORD=verysecurepassword ``` Windows: ``` @@ -40,8 +40,8 @@ To run the server directly from sourcecode start it with the following command: ``` python -m pip install -r requirements.txt # To install the required dependencies -ENV FLASK_APP=server -ENV FLASK_ENV=development +export FLASK_APP=server +export FLASK_ENV=development python3 -m flask run --host=0.0.0.0 ``` diff --git a/server/main.py b/server/main.py index 06661d2..9c7abb2 100644 --- a/server/main.py +++ b/server/main.py @@ -83,7 +83,7 @@ def create_post(): notes = request.form.get("notes") # Create a new platform using this information - new_platform = Platform(name=platform_name, notes=notes) + new_platform = Platform(name=platform_name.lower(), notes=notes) # make sure the platform is lowercase # add the new user to the database db.session.add(new_platform) db.session.commit() From ce51ad7c4f316807401a7fc8dbe54e9597bffb70 Mon Sep 17 00:00:00 2001 From: Marco Date: Tue, 4 Jan 2022 15:41:49 +0100 Subject: [PATCH 54/73] forgot elif --- server/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/main.py b/server/main.py index 9c7abb2..5c931f0 100644 --- a/server/main.py +++ b/server/main.py @@ -121,7 +121,7 @@ def whitelist_post(): flash("Deleted device from platform",'warning') # Edit notes - if request.form.get('_method') and 'NOTES' in request.form.get('_method'): + elif request.form.get('_method') and 'NOTES' in request.form.get('_method'): if request.form['_device']: device_id = request.form.get('_device',type=int) device = Device.query.filter_by(id=device_id).first() From e916d7b05aa0d508365061f169335a3eeb4e8f51 Mon Sep 17 00:00:00 2001 From: Marco Date: Tue, 4 Jan 2022 16:41:39 +0100 Subject: [PATCH 55/73] making sure not to overwrite any existing notes when adding device to the whitelist --- server/main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/main.py b/server/main.py index 5c931f0..581f842 100644 --- a/server/main.py +++ b/server/main.py @@ -155,7 +155,8 @@ def whitelist_post(): return render_template("whitelist.html", platforms=platforms, unbound_devices=unbound_devices) if known_device and known_platform: # if the device and platform are known, whitelist the device known_device.type = known_platform.id - known_device.notes = request.form.get('notes') + if request.form.get('notes') and request.form.get('notes') != '': # Make sure we do not overwrite existing notes + known_device.notes = request.form.get('notes') db.session.commit() flash('Success: {} added to platform {}'.format(known_device.mac, known_platform.name),'warning') else: From 1b85a9229a91b69b5c6d1e2d54634b6a850fea01 Mon Sep 17 00:00:00 2001 From: Marco Date: Tue, 4 Jan 2022 16:56:39 +0100 Subject: [PATCH 56/73] titelize platform-names --- server/templates/whitelist.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/templates/whitelist.html b/server/templates/whitelist.html index 7c09c5b..15caca3 100644 --- a/server/templates/whitelist.html +++ b/server/templates/whitelist.html @@ -26,7 +26,7 @@

    Bind devices to a platform

    {% if platforms %} {% for platform in platforms %} - + {% endfor %} {% endif %} @@ -40,7 +40,7 @@

    Bind devices to a platform

    {% for platform in platforms %}
    -

    {{ platform.name }}

    +

    {{ platform.name.title() }}

    Notes: {{platform.notes}} {{ platform.name }}

    {% else %}
    - No devices for platform {{ platform.name }} + No devices for platform {{ platform.name.title() }}
    {% endif %}
    From 78a09022f1cc5f91d714ea57728732b24e49835f Mon Sep 17 00:00:00 2001 From: Marco Date: Thu, 6 Jan 2022 17:18:43 +0100 Subject: [PATCH 57/73] more verbose logging when serving files Make sure the platformname is lower-cased when a device requests an update --- server/main.py | 15 ++++++++++----- server/templates/whitelist.html | 2 +- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/server/main.py b/server/main.py index 581f842..bb38349 100644 --- a/server/main.py +++ b/server/main.py @@ -142,7 +142,7 @@ def whitelist_post(): flash('Error: Address already on a whitelist.') return render_template("whitelist.html", platforms=platforms, unbound_devices=unbound_devices) # All looks good - add to whitelist. - known_platform = Platform.query.filter_by(name=request.form['device']).first() + known_platform = Platform.query.filter_by(name=request.form['device'].lower()).first() if not known_device and known_platform: # device was not known before, but platform is valid device = Device(mac=__mac, type=known_platform.id, notes=request.form.get('notes')) # add the new device to the database @@ -188,7 +188,7 @@ def update(): __ver = version.parse(request.args.get("ver", default=None)) # parse version, brings a bit extra safety platform_valid = re.match("^[a-zA-Z0-9\-]*$", __dev) # Check if the platform contains only valid characters if not platform_valid: - log_event("ERROR: Invalid parameters.") + log_event("ERROR: Invalid platform name: {}".format(__dev)) return "Error: Invalid parameters.", 400 if __dev and __mac and __ver and len(__mac) == 12 : # If we know this device already @@ -218,7 +218,8 @@ def update(): log_event("ERROR: No update available.") return "No update available.", 400 if __ver < version.parse(platform.version): - if os.path.isfile(os.path.join(os.path.dirname(__file__), current_app.config['UPLOAD_FOLDER'], platform.file)): + path = os.path.join(os.path.dirname(__file__), current_app.config['UPLOAD_FOLDER'], platform.file) + if os.path.isfile(path): platform.downloads += 1 db.session.commit() response = make_response( @@ -231,6 +232,9 @@ def update(): )) response.headers['x-MD5'] = get_MD5(platform.file) return response + else: + log_event("ERROR: Unknown file: {}".format(path)) + return "Error: Internal error", 500 else: log_event("INFO: No update needed.") return "No update needed.", 304 @@ -240,8 +244,9 @@ def update(): else: log_event("ERROR: Unkown platform") return "Error: Unkown platform", 500 - log_event("ERROR: Invalid parameters.") - return "Error: Invalid parameters.", 400 + else: + log_event("ERROR: Invalid parameters. __dev: {} and __mac: {} and __ver:{} and len(__mac): {} ".format(__dev,__mac, __ver, len(__mac))) + return "Error: Invalid parameters.", 400 @main.route("/upload") diff --git a/server/templates/whitelist.html b/server/templates/whitelist.html index 15caca3..b9afa27 100644 --- a/server/templates/whitelist.html +++ b/server/templates/whitelist.html @@ -26,7 +26,7 @@

    Bind devices to a platform

    {% if platforms %} {% for platform in platforms %} - + {% endfor %} {% endif %} From 60d44cbde7c48dd7ad1bdb963f4eb25aadb13fc2 Mon Sep 17 00:00:00 2001 From: Marco Date: Thu, 6 Jan 2022 17:48:10 +0100 Subject: [PATCH 58/73] update docker to a more recent version of python --- dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dockerfile b/dockerfile index 6d6867b..4086950 100644 --- a/dockerfile +++ b/dockerfile @@ -1,8 +1,8 @@ # syntax=docker/dockerfile:1 -FROM python:3.8-slim-buster -COPY ./server /server +FROM python:3.10.1 COPY requirements.txt requirements.txt RUN pip3 install -r requirements.txt +COPY ./server /server ENV FLASK_APP=server ENV FLASK_ENV=production EXPOSE 5000 From 1f82f5340bda1330828077c4ff1746d753e97051 Mon Sep 17 00:00:00 2001 From: Marco Date: Fri, 7 Jan 2022 09:39:14 +0100 Subject: [PATCH 59/73] add info note when updating device --- README.md | 4 ++-- server/main.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9986dd0..056d76d 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Ready-made Docker images are available on [Docker Hub](https://hub.docker.com/r/ To run the server in a Docker container create a directory `bin` where you want to store the database and binaries. Then run following command from the directory where you have the `bin`-directory. ``` -docker run -d --restart unless-stopped --name esp-update-server -v $PWD/bin:/esp-update-server/bin -p 5000:5000 marcovannoord/esp-update-server:latest +docker run -d --restart unless-stopped --name esp-update-server -v $PWD/bin:/server/bin -p 5000:5000 marcovannoord/esp-update-server:latest ``` Using the `-v` option ensures files are stored outside the Docker container and are thus persisted even if the container is terminated. @@ -63,7 +63,7 @@ docker build -t esp-update-server:latest . to re-build the docker-image from source To directly run this app, run ``` -docker run -d --restart unless-stopped --name esp-update-server -v $PWD/bin:/esp-update-server/bin -p 5000:5000 esp-update-server:latest +docker run -d --restart unless-stopped --name esp-update-server -v $PWD/bin:/server/bin -p 5000:5000 esp-update-server:latest ``` ### Device and platform management diff --git a/server/main.py b/server/main.py index bb38349..7e9abdd 100644 --- a/server/main.py +++ b/server/main.py @@ -231,6 +231,7 @@ def update(): attachment_filename=platform.file, )) response.headers['x-MD5'] = get_MD5(platform.file) + log_event("INFO: Updating {} of type {} from {} to {} ".format(__mac, platform.name, __ver, platform.version)) return response else: log_event("ERROR: Unknown file: {}".format(path)) From c8b65f3a62e0d8e14f759b647bbb8ecf7e213f64 Mon Sep 17 00:00:00 2001 From: Marco Date: Fri, 7 Jan 2022 09:53:11 +0100 Subject: [PATCH 60/73] already select the correct platform when adding a new device; this prevents accidently clicking the wrong platform --- server/templates/whitelist.html | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/server/templates/whitelist.html b/server/templates/whitelist.html index b9afa27..d744761 100644 --- a/server/templates/whitelist.html +++ b/server/templates/whitelist.html @@ -22,7 +22,7 @@

    Bind devices to a platform

    Notes:
    Platform: -
    {% if platforms %} {% for platform in platforms %} @@ -123,9 +123,12 @@

    {{ platform.name.title() }}

  • No platforms created. {% endif %} {% endwith %} - {% with devices = unbound_devices %} @@ -179,7 +182,7 @@

    These devices have been seen, but have - From 5a9ce019ea8b8c28c82d920569e276bae1e4550c Mon Sep 17 00:00:00 2001 From: Marco Date: Fri, 7 Jan 2022 10:21:14 +0100 Subject: [PATCH 61/73] docker run command: add -dt instead of -d, so the tty output will be line-buffered. Otherwise the print() statements would not be visible --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 056d76d..e09f2b8 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ SET ADMIN_PASSWORD=verysecurepassword ``` Docker: ``` -docker run --restart unless-stopped -d --name esp-update-server -v $PWD/bin:/server/bin --env ADMIN_EMAIL=desired_login_email@yahoo.com --env ADMIN_PASSWORD=verysecurepassword -p 5000:5000/tcp marcovannoord/esp-update-server:latest +docker run --restart unless-stopped -dt --name esp-update-server -v $PWD/bin:/server/bin --env ADMIN_EMAIL=desired_login_email@yahoo.com --env ADMIN_PASSWORD=verysecurepassword -p 5000:5000/tcp marcovannoord/esp-update-server:latest ``` ### Start server from source @@ -51,7 +51,7 @@ Ready-made Docker images are available on [Docker Hub](https://hub.docker.com/r/ To run the server in a Docker container create a directory `bin` where you want to store the database and binaries. Then run following command from the directory where you have the `bin`-directory. ``` -docker run -d --restart unless-stopped --name esp-update-server -v $PWD/bin:/server/bin -p 5000:5000 marcovannoord/esp-update-server:latest +docker run -dt --restart unless-stopped --name esp-update-server -v $PWD/bin:/server/bin -p 5000:5000 marcovannoord/esp-update-server:latest ``` Using the `-v` option ensures files are stored outside the Docker container and are thus persisted even if the container is terminated. @@ -63,7 +63,7 @@ docker build -t esp-update-server:latest . to re-build the docker-image from source To directly run this app, run ``` -docker run -d --restart unless-stopped --name esp-update-server -v $PWD/bin:/server/bin -p 5000:5000 esp-update-server:latest +docker run -dt --restart unless-stopped --name esp-update-server -v $PWD/bin:/server/bin -p 5000:5000 esp-update-server:latest ``` ### Device and platform management From ea350b0b63b12957cab849a7608f89818e4bdbf6 Mon Sep 17 00:00:00 2001 From: Marco Date: Wed, 2 Feb 2022 16:27:38 +0100 Subject: [PATCH 62/73] fix notification message having wrong color and being unreadable --- server/templates/layout.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/templates/layout.html b/server/templates/layout.html index ac4c332..15ac208 100644 --- a/server/templates/layout.html +++ b/server/templates/layout.html @@ -65,7 +65,7 @@ {% with messages = get_flashed_messages(with_categories=true) %} {% if messages %} {% for category, message in messages %} -
    +
    {{message}}
    {% endfor %} From 2244b647150f89de7ed1557d875b1ade493b33e4 Mon Sep 17 00:00:00 2001 From: Marco Date: Wed, 2 Feb 2022 17:26:02 +0100 Subject: [PATCH 63/73] Add very basic user-manager --- server/main.py | 37 +++++++++++++++++++++++++ server/templates/layout.html | 5 ++++ server/templates/users.html | 53 ++++++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 server/templates/users.html diff --git a/server/main.py b/server/main.py index 7e9abdd..c74f9c4 100644 --- a/server/main.py +++ b/server/main.py @@ -9,6 +9,7 @@ flash, send_from_directory, current_app, + session ) from flask.helpers import make_response from flask_login import login_required, current_user @@ -307,6 +308,42 @@ def upload_post(): flash('Error: File type not allowed.') return redirect(request.url) + +@main.route('/users', methods=['POST']) +@login_required +def users_post(): + # Toggle admin-rights + if request.form.get('_method') and 'TOGGLE' in request.form.get('_method'): + if request.form['_user']: + user_id = request.form.get('_user',type=int) + if int(session["_user_id"]) is user_id: # do not allow user to edit self(prevents locking yourself out) + flash("Cannot edit self") + return redirect(request.url) + user = User.query.filter_by(id=user_id).first() + if request.form.get('_status') and 'on' in request.form.get('_status'): + user.admin = True + else: # checkboxes are not POST-ed when false, so assume it's unchecked if missing + user.admin = False + db.session.commit() + flash("Successfully toggled Admin-status for user {}".format(user.name)) + + # Delete user. FIXME: not implemented yet + elif request.form.get('_method') and 'DELETE' in request.form.get('_method'): + if request.form['_user']: + device_id = request.form.get('_device',type=int) + device = Device.query.filter_by(id=device_id).first() + device.notes = request.form.get('_notes') # update the note + db.session.commit() + flash("Updated note",'warning') + return redirect(request.url) + +@main.route("/users") +@login_required +def users(): + users = User.query.all() + return render_template("users.html", users=users) + + def get_MD5(filename): path = os.path.join(os.path.dirname(__file__), current_app.config['UPLOAD_FOLDER'], filename) if os.path.isfile(path): diff --git a/server/templates/layout.html b/server/templates/layout.html index 15ac208..1b84624 100644 --- a/server/templates/layout.html +++ b/server/templates/layout.html @@ -41,6 +41,11 @@ Upload file {% endif %} + {% if current_user.is_authenticated %} + + Users + + {% endif %} {% if not current_user.is_authenticated %} Login diff --git a/server/templates/users.html b/server/templates/users.html new file mode 100644 index 0000000..f2cdbbd --- /dev/null +++ b/server/templates/users.html @@ -0,0 +1,53 @@ + + +{% extends "layout.html" %} + +{% block content %} +

    + Users +

    +

    + Easy system to manage OTA updates for Espressif-based devices +

    + + +
    +
    +

    Users

    +
    +
    +
    + + + + + + + + + + + {% if users %} + {% for user in users %} + + + + + + + + {% endfor %} + {% endif %} + +
    NameEmailAdminDelete
    {{ user.name }}{{user.email}}{{user.admin}} + + + + + +
    +
    +
    +
    +{% endblock %} \ No newline at end of file From ff876ff94394d94dafbbf65c5ebb02f8f42f1f36 Mon Sep 17 00:00:00 2001 From: Marco Date: Wed, 2 Feb 2022 17:37:06 +0100 Subject: [PATCH 64/73] allow deleting users --- server/main.py | 17 ++++++++++------- server/templates/users.html | 14 ++++++++++++-- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/server/main.py b/server/main.py index c74f9c4..d7d0823 100644 --- a/server/main.py +++ b/server/main.py @@ -324,17 +324,20 @@ def users_post(): user.admin = True else: # checkboxes are not POST-ed when false, so assume it's unchecked if missing user.admin = False - db.session.commit() - flash("Successfully toggled Admin-status for user {}".format(user.name)) + db.session.commit() + flash("Successfully toggled Admin-status for user {}".format(user.name)) # Delete user. FIXME: not implemented yet elif request.form.get('_method') and 'DELETE' in request.form.get('_method'): - if request.form['_user']: - device_id = request.form.get('_device',type=int) - device = Device.query.filter_by(id=device_id).first() - device.notes = request.form.get('_notes') # update the note + if request.form['_user']: + user_id = request.form.get('_user',type=int) + if int(session["_user_id"]) is user_id: # do not allow user to edit self(prevents locking yourself out) + flash("Cannot delete self") + return redirect(request.url) + user = User.query.filter_by(id=user_id).first() + db.session.delete(user) db.session.commit() - flash("Updated note",'warning') + flash("Successfully deleted user {}".format(user.name)) return redirect(request.url) @main.route("/users") diff --git a/server/templates/users.html b/server/templates/users.html index f2cdbbd..85ad699 100644 --- a/server/templates/users.html +++ b/server/templates/users.html @@ -32,15 +32,25 @@

    Users

    {{ user.name }} {{user.email}} - {{user.admin}}
    + onclick="if (!confirm('Are you sure you want to make {{ user.name }} a {% if user.admin == true %}Non-{% endif %} admin ?')) { return false }$('#toggleUser{{user.id}}').submit();">
    + + +
    + + + +
    +
    + {% endfor %} From 1420aa8875d2881c6465c6a2006b3e58f2c71eef Mon Sep 17 00:00:00 2001 From: Marco Date: Wed, 2 Feb 2022 19:00:01 +0100 Subject: [PATCH 65/73] fix some UI bugs, check for lowercase platformname when adding a platform, add device-count to whitelist platforms --- server/main.py | 2 +- server/templates/users.html | 2 +- server/templates/whitelist.html | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/server/main.py b/server/main.py index d7d0823..a8379b4 100644 --- a/server/main.py +++ b/server/main.py @@ -77,7 +77,7 @@ def create_post(): flash('Error: Platform name contains illegal characters. Only a-Z, 0-9 and - are allowed') return redirect(url_for("main.create")) - platform = Platform.query.filter_by(name=platform_name).first() # if this returns a result, then the platform already exists in database + platform = Platform.query.filter_by(name=platform_name.lower()).first() # if this returns a result, then the platform already exists in database if platform: flash("Platform already exists") return redirect(url_for("main.create")) diff --git a/server/templates/users.html b/server/templates/users.html index 85ad699..414794e 100644 --- a/server/templates/users.html +++ b/server/templates/users.html @@ -37,7 +37,7 @@

    Users

    + onclick="if (!confirm('Are you sure you want to {% if user.admin == true %}revoke admin-privileges from{% else %}allow Admin-privileges to{% endif %} {{ user.name }} ?')) { return false }$('#toggleUser{{user.id}}').submit();"> diff --git a/server/templates/whitelist.html b/server/templates/whitelist.html index d744761..87d8f77 100644 --- a/server/templates/whitelist.html +++ b/server/templates/whitelist.html @@ -54,6 +54,7 @@

    {{ platform.name.title() }}


    Downloads: {{platform.downloads}}
    Latest firmware: {{platform.version}}
    + Devices: {{platform.devices|count}}
    {% if platform.devices %} From 39fc551f663429a47f3a1a2bd509fe0a6212a007 Mon Sep 17 00:00:00 2001 From: Marco Date: Thu, 3 Feb 2022 10:45:47 +0100 Subject: [PATCH 66/73] prettier toggle-switch for admin-rights --- server/static/bulma-switch.min.css | 1 + server/templates/layout.html | 1 + server/templates/users.html | 11 ++++++----- 3 files changed, 8 insertions(+), 5 deletions(-) create mode 100644 server/static/bulma-switch.min.css diff --git a/server/static/bulma-switch.min.css b/server/static/bulma-switch.min.css new file mode 100644 index 0000000..5a07523 --- /dev/null +++ b/server/static/bulma-switch.min.css @@ -0,0 +1 @@ +.switch[type=checkbox]{outline:0;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;display:inline-block;position:absolute;opacity:0}.switch[type=checkbox]:focus+label::after,.switch[type=checkbox]:focus+label::before,.switch[type=checkbox]:focus+label:after,.switch[type=checkbox]:focus+label:before{outline:1px dotted #b5b5b5}.switch[type=checkbox][disabled]{cursor:not-allowed}.switch[type=checkbox][disabled]+label{opacity:.5}.switch[type=checkbox][disabled]+label::before,.switch[type=checkbox][disabled]+label:before{opacity:.5}.switch[type=checkbox][disabled]+label::after,.switch[type=checkbox][disabled]+label:after{opacity:.5}.switch[type=checkbox][disabled]+label:hover{cursor:not-allowed}.switch[type=checkbox]+label{position:relative;display:inline-flex;align-items:center;justify-content:flex-start;font-size:1rem;height:2.5em;line-height:1.5;padding-left:3.5rem;padding-top:.2rem;cursor:pointer}.switch[type=checkbox]+label::before,.switch[type=checkbox]+label:before{position:absolute;display:block;top:calc(50% - 1.5rem * .5);left:0;width:3rem;height:1.5rem;border:.1rem solid transparent;border-radius:4px;background:#b5b5b5;content:""}.switch[type=checkbox]+label::after,.switch[type=checkbox]+label:after{display:block;position:absolute;top:calc(50% - 1rem * .5);left:.25rem;width:1rem;height:1rem;transform:translate3d(0,0,0);border-radius:4px;background:#fff;transition:all .25s ease-out;content:""}.switch[type=checkbox]+label .switch-active,.switch[type=checkbox]+label .switch-inactive{font-size:.9rem;z-index:1;margin-top:-4px}.switch[type=checkbox]+label.has-text-inside .switch-inactive{margin-left:-1.925rem}.switch[type=checkbox]+label.has-text-inside .switch-active{margin-left:-3.25rem}.switch[type=checkbox].is-rtl+label{padding-left:0;padding-right:3.5rem}.switch[type=checkbox].is-rtl+label::before,.switch[type=checkbox].is-rtl+label:before{left:auto;right:0}.switch[type=checkbox].is-rtl+label::after,.switch[type=checkbox].is-rtl+label:after{left:auto;right:1.625rem}.switch[type=checkbox]:checked+label::before,.switch[type=checkbox]:checked+label:before{background:#00d1b2}.switch[type=checkbox]:checked+label::after{left:1.625rem}.switch[type=checkbox]:checked.is-rtl+label::after,.switch[type=checkbox]:checked.is-rtl+label:after{left:auto;right:.25rem}.switch[type=checkbox].is-outlined+label::before,.switch[type=checkbox].is-outlined+label:before{background-color:transparent;border-color:#b5b5b5}.switch[type=checkbox].is-outlined+label::after,.switch[type=checkbox].is-outlined+label:after{background:#b5b5b5}.switch[type=checkbox].is-outlined:checked+label::before,.switch[type=checkbox].is-outlined:checked+label:before{background-color:transparent;border-color:#00d1b2}.switch[type=checkbox].is-outlined:checked+label::after,.switch[type=checkbox].is-outlined:checked+label:after{background:#00d1b2}.switch[type=checkbox].is-thin+label::before,.switch[type=checkbox].is-thin+label:before{top:.5454545456rem;height:.375rem}.switch[type=checkbox].is-thin+label::after,.switch[type=checkbox].is-thin+label:after{box-shadow:0 0 3px #7a7a7a}.switch[type=checkbox].is-rounded+label::before,.switch[type=checkbox].is-rounded+label:before{border-radius:24px}.switch[type=checkbox].is-rounded+label::after,.switch[type=checkbox].is-rounded+label:after{border-radius:50%}.switch[type=checkbox].is-small+label{position:relative;display:inline-flex;align-items:center;justify-content:flex-start;font-size:.75rem;height:2.5em;line-height:1.5;padding-left:2.75rem;padding-top:.2rem;cursor:pointer}.switch[type=checkbox].is-small+label::before,.switch[type=checkbox].is-small+label:before{position:absolute;display:block;top:calc(50% - 1.125rem * .5);left:0;width:2.25rem;height:1.125rem;border:.1rem solid transparent;border-radius:4px;background:#b5b5b5;content:""}.switch[type=checkbox].is-small+label::after,.switch[type=checkbox].is-small+label:after{display:block;position:absolute;top:calc(50% - .625rem * .5);left:.25rem;width:.625rem;height:.625rem;transform:translate3d(0,0,0);border-radius:4px;background:#fff;transition:all .25s ease-out;content:""}.switch[type=checkbox].is-small+label .switch-active,.switch[type=checkbox].is-small+label .switch-inactive{font-size:.65rem;z-index:1;margin-top:-4px}.switch[type=checkbox].is-small+label.has-text-inside .switch-inactive{margin-left:-1.55rem}.switch[type=checkbox].is-small+label.has-text-inside .switch-active{margin-left:-2.5rem}.switch[type=checkbox].is-small.is-rtl+label{padding-left:0;padding-right:2.75rem}.switch[type=checkbox].is-small.is-rtl+label::before,.switch[type=checkbox].is-small.is-rtl+label:before{left:auto;right:0}.switch[type=checkbox].is-small.is-rtl+label::after,.switch[type=checkbox].is-small.is-rtl+label:after{left:auto;right:1.25rem}.switch[type=checkbox].is-small:checked+label::before,.switch[type=checkbox].is-small:checked+label:before{background:#00d1b2}.switch[type=checkbox].is-small:checked+label::after{left:1.25rem}.switch[type=checkbox].is-small:checked.is-rtl+label::after,.switch[type=checkbox].is-small:checked.is-rtl+label:after{left:auto;right:.25rem}.switch[type=checkbox].is-small.is-outlined+label::before,.switch[type=checkbox].is-small.is-outlined+label:before{background-color:transparent;border-color:#b5b5b5}.switch[type=checkbox].is-small.is-outlined+label::after,.switch[type=checkbox].is-small.is-outlined+label:after{background:#b5b5b5}.switch[type=checkbox].is-small.is-outlined:checked+label::before,.switch[type=checkbox].is-small.is-outlined:checked+label:before{background-color:transparent;border-color:#00d1b2}.switch[type=checkbox].is-small.is-outlined:checked+label::after,.switch[type=checkbox].is-small.is-outlined:checked+label:after{background:#00d1b2}.switch[type=checkbox].is-small.is-thin+label::before,.switch[type=checkbox].is-small.is-thin+label:before{top:.4090909093rem;height:.28125rem}.switch[type=checkbox].is-small.is-thin+label::after,.switch[type=checkbox].is-small.is-thin+label:after{box-shadow:0 0 3px #7a7a7a}.switch[type=checkbox].is-small.is-rounded+label::before,.switch[type=checkbox].is-small.is-rounded+label:before{border-radius:24px}.switch[type=checkbox].is-small.is-rounded+label::after,.switch[type=checkbox].is-small.is-rounded+label:after{border-radius:50%}.switch[type=checkbox].is-medium+label{position:relative;display:inline-flex;align-items:center;justify-content:flex-start;font-size:1.25rem;height:2.5em;line-height:1.5;padding-left:4.25rem;padding-top:.2rem;cursor:pointer}.switch[type=checkbox].is-medium+label::before,.switch[type=checkbox].is-medium+label:before{position:absolute;display:block;top:calc(50% - 1.875rem * .5);left:0;width:3.75rem;height:1.875rem;border:.1rem solid transparent;border-radius:4px;background:#b5b5b5;content:""}.switch[type=checkbox].is-medium+label::after,.switch[type=checkbox].is-medium+label:after{display:block;position:absolute;top:calc(50% - 1.375rem * .5);left:.25rem;width:1.375rem;height:1.375rem;transform:translate3d(0,0,0);border-radius:4px;background:#fff;transition:all .25s ease-out;content:""}.switch[type=checkbox].is-medium+label .switch-active,.switch[type=checkbox].is-medium+label .switch-inactive{font-size:1.15rem;z-index:1;margin-top:-4px}.switch[type=checkbox].is-medium+label.has-text-inside .switch-inactive{margin-left:-2.3rem}.switch[type=checkbox].is-medium+label.has-text-inside .switch-active{margin-left:-4rem}.switch[type=checkbox].is-medium.is-rtl+label{padding-left:0;padding-right:4.25rem}.switch[type=checkbox].is-medium.is-rtl+label::before,.switch[type=checkbox].is-medium.is-rtl+label:before{left:auto;right:0}.switch[type=checkbox].is-medium.is-rtl+label::after,.switch[type=checkbox].is-medium.is-rtl+label:after{left:auto;right:2rem}.switch[type=checkbox].is-medium:checked+label::before,.switch[type=checkbox].is-medium:checked+label:before{background:#00d1b2}.switch[type=checkbox].is-medium:checked+label::after{left:2rem}.switch[type=checkbox].is-medium:checked.is-rtl+label::after,.switch[type=checkbox].is-medium:checked.is-rtl+label:after{left:auto;right:.25rem}.switch[type=checkbox].is-medium.is-outlined+label::before,.switch[type=checkbox].is-medium.is-outlined+label:before{background-color:transparent;border-color:#b5b5b5}.switch[type=checkbox].is-medium.is-outlined+label::after,.switch[type=checkbox].is-medium.is-outlined+label:after{background:#b5b5b5}.switch[type=checkbox].is-medium.is-outlined:checked+label::before,.switch[type=checkbox].is-medium.is-outlined:checked+label:before{background-color:transparent;border-color:#00d1b2}.switch[type=checkbox].is-medium.is-outlined:checked+label::after,.switch[type=checkbox].is-medium.is-outlined:checked+label:after{background:#00d1b2}.switch[type=checkbox].is-medium.is-thin+label::before,.switch[type=checkbox].is-medium.is-thin+label:before{top:.6818181819rem;height:.46875rem}.switch[type=checkbox].is-medium.is-thin+label::after,.switch[type=checkbox].is-medium.is-thin+label:after{box-shadow:0 0 3px #7a7a7a}.switch[type=checkbox].is-medium.is-rounded+label::before,.switch[type=checkbox].is-medium.is-rounded+label:before{border-radius:24px}.switch[type=checkbox].is-medium.is-rounded+label::after,.switch[type=checkbox].is-medium.is-rounded+label:after{border-radius:50%}.switch[type=checkbox].is-large+label{position:relative;display:inline-flex;align-items:center;justify-content:flex-start;font-size:1.5rem;height:2.5em;line-height:1.5;padding-left:5rem;padding-top:.2rem;cursor:pointer}.switch[type=checkbox].is-large+label::before,.switch[type=checkbox].is-large+label:before{position:absolute;display:block;top:calc(50% - 2.25rem * .5);left:0;width:4.5rem;height:2.25rem;border:.1rem solid transparent;border-radius:4px;background:#b5b5b5;content:""}.switch[type=checkbox].is-large+label::after,.switch[type=checkbox].is-large+label:after{display:block;position:absolute;top:calc(50% - 1.75rem * .5);left:.25rem;width:1.75rem;height:1.75rem;transform:translate3d(0,0,0);border-radius:4px;background:#fff;transition:all .25s ease-out;content:""}.switch[type=checkbox].is-large+label .switch-active,.switch[type=checkbox].is-large+label .switch-inactive{font-size:1.4rem;z-index:1;margin-top:-4px}.switch[type=checkbox].is-large+label.has-text-inside .switch-inactive{margin-left:-2.675rem}.switch[type=checkbox].is-large+label.has-text-inside .switch-active{margin-left:-4.75rem}.switch[type=checkbox].is-large.is-rtl+label{padding-left:0;padding-right:5rem}.switch[type=checkbox].is-large.is-rtl+label::before,.switch[type=checkbox].is-large.is-rtl+label:before{left:auto;right:0}.switch[type=checkbox].is-large.is-rtl+label::after,.switch[type=checkbox].is-large.is-rtl+label:after{left:auto;right:2.375rem}.switch[type=checkbox].is-large:checked+label::before,.switch[type=checkbox].is-large:checked+label:before{background:#00d1b2}.switch[type=checkbox].is-large:checked+label::after{left:2.375rem}.switch[type=checkbox].is-large:checked.is-rtl+label::after,.switch[type=checkbox].is-large:checked.is-rtl+label:after{left:auto;right:.25rem}.switch[type=checkbox].is-large.is-outlined+label::before,.switch[type=checkbox].is-large.is-outlined+label:before{background-color:transparent;border-color:#b5b5b5}.switch[type=checkbox].is-large.is-outlined+label::after,.switch[type=checkbox].is-large.is-outlined+label:after{background:#b5b5b5}.switch[type=checkbox].is-large.is-outlined:checked+label::before,.switch[type=checkbox].is-large.is-outlined:checked+label:before{background-color:transparent;border-color:#00d1b2}.switch[type=checkbox].is-large.is-outlined:checked+label::after,.switch[type=checkbox].is-large.is-outlined:checked+label:after{background:#00d1b2}.switch[type=checkbox].is-large.is-thin+label::before,.switch[type=checkbox].is-large.is-thin+label:before{top:.8181818183rem;height:.5625rem}.switch[type=checkbox].is-large.is-thin+label::after,.switch[type=checkbox].is-large.is-thin+label:after{box-shadow:0 0 3px #7a7a7a}.switch[type=checkbox].is-large.is-rounded+label::before,.switch[type=checkbox].is-large.is-rounded+label:before{border-radius:24px}.switch[type=checkbox].is-large.is-rounded+label::after,.switch[type=checkbox].is-large.is-rounded+label:after{border-radius:50%}.switch[type=checkbox].is-white+label .switch-active{display:none}.switch[type=checkbox].is-white+label .switch-inactive{display:inline-block}.switch[type=checkbox].is-white:checked+label::before,.switch[type=checkbox].is-white:checked+label:before{background:#fff}.switch[type=checkbox].is-white:checked+label .switch-active{display:inline-block}.switch[type=checkbox].is-white:checked+label .switch-inactive{display:none}.switch[type=checkbox].is-white.is-outlined:checked+label::before,.switch[type=checkbox].is-white.is-outlined:checked+label:before{background-color:transparent;border-color:#fff!important}.switch[type=checkbox].is-white.is-outlined:checked+label::after,.switch[type=checkbox].is-white.is-outlined:checked+label:after{background:#fff}.switch[type=checkbox].is-white.is-thin.is-outlined+label::after,.switch[type=checkbox].is-white.is-thin.is-outlined+label:after{box-shadow:none}.switch[type=checkbox].is-unchecked-white+label::before,.switch[type=checkbox].is-unchecked-white+label:before{background:#fff}.switch[type=checkbox].is-unchecked-white.is-outlined+label::before,.switch[type=checkbox].is-unchecked-white.is-outlined+label:before{background-color:transparent;border-color:#fff!important}.switch[type=checkbox].is-unchecked-white.is-outlined+label::after,.switch[type=checkbox].is-unchecked-white.is-outlined+label:after{background:#fff}.switch[type=checkbox].is-black+label .switch-active{display:none}.switch[type=checkbox].is-black+label .switch-inactive{display:inline-block}.switch[type=checkbox].is-black:checked+label::before,.switch[type=checkbox].is-black:checked+label:before{background:#0a0a0a}.switch[type=checkbox].is-black:checked+label .switch-active{display:inline-block}.switch[type=checkbox].is-black:checked+label .switch-inactive{display:none}.switch[type=checkbox].is-black.is-outlined:checked+label::before,.switch[type=checkbox].is-black.is-outlined:checked+label:before{background-color:transparent;border-color:#0a0a0a!important}.switch[type=checkbox].is-black.is-outlined:checked+label::after,.switch[type=checkbox].is-black.is-outlined:checked+label:after{background:#0a0a0a}.switch[type=checkbox].is-black.is-thin.is-outlined+label::after,.switch[type=checkbox].is-black.is-thin.is-outlined+label:after{box-shadow:none}.switch[type=checkbox].is-unchecked-black+label::before,.switch[type=checkbox].is-unchecked-black+label:before{background:#0a0a0a}.switch[type=checkbox].is-unchecked-black.is-outlined+label::before,.switch[type=checkbox].is-unchecked-black.is-outlined+label:before{background-color:transparent;border-color:#0a0a0a!important}.switch[type=checkbox].is-unchecked-black.is-outlined+label::after,.switch[type=checkbox].is-unchecked-black.is-outlined+label:after{background:#0a0a0a}.switch[type=checkbox].is-light+label .switch-active{display:none}.switch[type=checkbox].is-light+label .switch-inactive{display:inline-block}.switch[type=checkbox].is-light:checked+label::before,.switch[type=checkbox].is-light:checked+label:before{background:#f5f5f5}.switch[type=checkbox].is-light:checked+label .switch-active{display:inline-block}.switch[type=checkbox].is-light:checked+label .switch-inactive{display:none}.switch[type=checkbox].is-light.is-outlined:checked+label::before,.switch[type=checkbox].is-light.is-outlined:checked+label:before{background-color:transparent;border-color:#f5f5f5!important}.switch[type=checkbox].is-light.is-outlined:checked+label::after,.switch[type=checkbox].is-light.is-outlined:checked+label:after{background:#f5f5f5}.switch[type=checkbox].is-light.is-thin.is-outlined+label::after,.switch[type=checkbox].is-light.is-thin.is-outlined+label:after{box-shadow:none}.switch[type=checkbox].is-unchecked-light+label::before,.switch[type=checkbox].is-unchecked-light+label:before{background:#f5f5f5}.switch[type=checkbox].is-unchecked-light.is-outlined+label::before,.switch[type=checkbox].is-unchecked-light.is-outlined+label:before{background-color:transparent;border-color:#f5f5f5!important}.switch[type=checkbox].is-unchecked-light.is-outlined+label::after,.switch[type=checkbox].is-unchecked-light.is-outlined+label:after{background:#f5f5f5}.switch[type=checkbox].is-dark+label .switch-active{display:none}.switch[type=checkbox].is-dark+label .switch-inactive{display:inline-block}.switch[type=checkbox].is-dark:checked+label::before,.switch[type=checkbox].is-dark:checked+label:before{background:#363636}.switch[type=checkbox].is-dark:checked+label .switch-active{display:inline-block}.switch[type=checkbox].is-dark:checked+label .switch-inactive{display:none}.switch[type=checkbox].is-dark.is-outlined:checked+label::before,.switch[type=checkbox].is-dark.is-outlined:checked+label:before{background-color:transparent;border-color:#363636!important}.switch[type=checkbox].is-dark.is-outlined:checked+label::after,.switch[type=checkbox].is-dark.is-outlined:checked+label:after{background:#363636}.switch[type=checkbox].is-dark.is-thin.is-outlined+label::after,.switch[type=checkbox].is-dark.is-thin.is-outlined+label:after{box-shadow:none}.switch[type=checkbox].is-unchecked-dark+label::before,.switch[type=checkbox].is-unchecked-dark+label:before{background:#363636}.switch[type=checkbox].is-unchecked-dark.is-outlined+label::before,.switch[type=checkbox].is-unchecked-dark.is-outlined+label:before{background-color:transparent;border-color:#363636!important}.switch[type=checkbox].is-unchecked-dark.is-outlined+label::after,.switch[type=checkbox].is-unchecked-dark.is-outlined+label:after{background:#363636}.switch[type=checkbox].is-primary+label .switch-active{display:none}.switch[type=checkbox].is-primary+label .switch-inactive{display:inline-block}.switch[type=checkbox].is-primary:checked+label::before,.switch[type=checkbox].is-primary:checked+label:before{background:#00d1b2}.switch[type=checkbox].is-primary:checked+label .switch-active{display:inline-block}.switch[type=checkbox].is-primary:checked+label .switch-inactive{display:none}.switch[type=checkbox].is-primary.is-outlined:checked+label::before,.switch[type=checkbox].is-primary.is-outlined:checked+label:before{background-color:transparent;border-color:#00d1b2!important}.switch[type=checkbox].is-primary.is-outlined:checked+label::after,.switch[type=checkbox].is-primary.is-outlined:checked+label:after{background:#00d1b2}.switch[type=checkbox].is-primary.is-thin.is-outlined+label::after,.switch[type=checkbox].is-primary.is-thin.is-outlined+label:after{box-shadow:none}.switch[type=checkbox].is-unchecked-primary+label::before,.switch[type=checkbox].is-unchecked-primary+label:before{background:#00d1b2}.switch[type=checkbox].is-unchecked-primary.is-outlined+label::before,.switch[type=checkbox].is-unchecked-primary.is-outlined+label:before{background-color:transparent;border-color:#00d1b2!important}.switch[type=checkbox].is-unchecked-primary.is-outlined+label::after,.switch[type=checkbox].is-unchecked-primary.is-outlined+label:after{background:#00d1b2}.switch[type=checkbox].is-link+label .switch-active{display:none}.switch[type=checkbox].is-link+label .switch-inactive{display:inline-block}.switch[type=checkbox].is-link:checked+label::before,.switch[type=checkbox].is-link:checked+label:before{background:#485fc7}.switch[type=checkbox].is-link:checked+label .switch-active{display:inline-block}.switch[type=checkbox].is-link:checked+label .switch-inactive{display:none}.switch[type=checkbox].is-link.is-outlined:checked+label::before,.switch[type=checkbox].is-link.is-outlined:checked+label:before{background-color:transparent;border-color:#485fc7!important}.switch[type=checkbox].is-link.is-outlined:checked+label::after,.switch[type=checkbox].is-link.is-outlined:checked+label:after{background:#485fc7}.switch[type=checkbox].is-link.is-thin.is-outlined+label::after,.switch[type=checkbox].is-link.is-thin.is-outlined+label:after{box-shadow:none}.switch[type=checkbox].is-unchecked-link+label::before,.switch[type=checkbox].is-unchecked-link+label:before{background:#485fc7}.switch[type=checkbox].is-unchecked-link.is-outlined+label::before,.switch[type=checkbox].is-unchecked-link.is-outlined+label:before{background-color:transparent;border-color:#485fc7!important}.switch[type=checkbox].is-unchecked-link.is-outlined+label::after,.switch[type=checkbox].is-unchecked-link.is-outlined+label:after{background:#485fc7}.switch[type=checkbox].is-info+label .switch-active{display:none}.switch[type=checkbox].is-info+label .switch-inactive{display:inline-block}.switch[type=checkbox].is-info:checked+label::before,.switch[type=checkbox].is-info:checked+label:before{background:#3e8ed0}.switch[type=checkbox].is-info:checked+label .switch-active{display:inline-block}.switch[type=checkbox].is-info:checked+label .switch-inactive{display:none}.switch[type=checkbox].is-info.is-outlined:checked+label::before,.switch[type=checkbox].is-info.is-outlined:checked+label:before{background-color:transparent;border-color:#3e8ed0!important}.switch[type=checkbox].is-info.is-outlined:checked+label::after,.switch[type=checkbox].is-info.is-outlined:checked+label:after{background:#3e8ed0}.switch[type=checkbox].is-info.is-thin.is-outlined+label::after,.switch[type=checkbox].is-info.is-thin.is-outlined+label:after{box-shadow:none}.switch[type=checkbox].is-unchecked-info+label::before,.switch[type=checkbox].is-unchecked-info+label:before{background:#3e8ed0}.switch[type=checkbox].is-unchecked-info.is-outlined+label::before,.switch[type=checkbox].is-unchecked-info.is-outlined+label:before{background-color:transparent;border-color:#3e8ed0!important}.switch[type=checkbox].is-unchecked-info.is-outlined+label::after,.switch[type=checkbox].is-unchecked-info.is-outlined+label:after{background:#3e8ed0}.switch[type=checkbox].is-success+label .switch-active{display:none}.switch[type=checkbox].is-success+label .switch-inactive{display:inline-block}.switch[type=checkbox].is-success:checked+label::before,.switch[type=checkbox].is-success:checked+label:before{background:#48c78e}.switch[type=checkbox].is-success:checked+label .switch-active{display:inline-block}.switch[type=checkbox].is-success:checked+label .switch-inactive{display:none}.switch[type=checkbox].is-success.is-outlined:checked+label::before,.switch[type=checkbox].is-success.is-outlined:checked+label:before{background-color:transparent;border-color:#48c78e!important}.switch[type=checkbox].is-success.is-outlined:checked+label::after,.switch[type=checkbox].is-success.is-outlined:checked+label:after{background:#48c78e}.switch[type=checkbox].is-success.is-thin.is-outlined+label::after,.switch[type=checkbox].is-success.is-thin.is-outlined+label:after{box-shadow:none}.switch[type=checkbox].is-unchecked-success+label::before,.switch[type=checkbox].is-unchecked-success+label:before{background:#48c78e}.switch[type=checkbox].is-unchecked-success.is-outlined+label::before,.switch[type=checkbox].is-unchecked-success.is-outlined+label:before{background-color:transparent;border-color:#48c78e!important}.switch[type=checkbox].is-unchecked-success.is-outlined+label::after,.switch[type=checkbox].is-unchecked-success.is-outlined+label:after{background:#48c78e}.switch[type=checkbox].is-warning+label .switch-active{display:none}.switch[type=checkbox].is-warning+label .switch-inactive{display:inline-block}.switch[type=checkbox].is-warning:checked+label::before,.switch[type=checkbox].is-warning:checked+label:before{background:#ffe08a}.switch[type=checkbox].is-warning:checked+label .switch-active{display:inline-block}.switch[type=checkbox].is-warning:checked+label .switch-inactive{display:none}.switch[type=checkbox].is-warning.is-outlined:checked+label::before,.switch[type=checkbox].is-warning.is-outlined:checked+label:before{background-color:transparent;border-color:#ffe08a!important}.switch[type=checkbox].is-warning.is-outlined:checked+label::after,.switch[type=checkbox].is-warning.is-outlined:checked+label:after{background:#ffe08a}.switch[type=checkbox].is-warning.is-thin.is-outlined+label::after,.switch[type=checkbox].is-warning.is-thin.is-outlined+label:after{box-shadow:none}.switch[type=checkbox].is-unchecked-warning+label::before,.switch[type=checkbox].is-unchecked-warning+label:before{background:#ffe08a}.switch[type=checkbox].is-unchecked-warning.is-outlined+label::before,.switch[type=checkbox].is-unchecked-warning.is-outlined+label:before{background-color:transparent;border-color:#ffe08a!important}.switch[type=checkbox].is-unchecked-warning.is-outlined+label::after,.switch[type=checkbox].is-unchecked-warning.is-outlined+label:after{background:#ffe08a}.switch[type=checkbox].is-danger+label .switch-active{display:none}.switch[type=checkbox].is-danger+label .switch-inactive{display:inline-block}.switch[type=checkbox].is-danger:checked+label::before,.switch[type=checkbox].is-danger:checked+label:before{background:#f14668}.switch[type=checkbox].is-danger:checked+label .switch-active{display:inline-block}.switch[type=checkbox].is-danger:checked+label .switch-inactive{display:none}.switch[type=checkbox].is-danger.is-outlined:checked+label::before,.switch[type=checkbox].is-danger.is-outlined:checked+label:before{background-color:transparent;border-color:#f14668!important}.switch[type=checkbox].is-danger.is-outlined:checked+label::after,.switch[type=checkbox].is-danger.is-outlined:checked+label:after{background:#f14668}.switch[type=checkbox].is-danger.is-thin.is-outlined+label::after,.switch[type=checkbox].is-danger.is-thin.is-outlined+label:after{box-shadow:none}.switch[type=checkbox].is-unchecked-danger+label::before,.switch[type=checkbox].is-unchecked-danger+label:before{background:#f14668}.switch[type=checkbox].is-unchecked-danger.is-outlined+label::before,.switch[type=checkbox].is-unchecked-danger.is-outlined+label:before{background-color:transparent;border-color:#f14668!important}.switch[type=checkbox].is-unchecked-danger.is-outlined+label::after,.switch[type=checkbox].is-unchecked-danger.is-outlined+label:after{background:#f14668}.field-body .switch[type=checkbox]+label{margin-top:.375em} \ No newline at end of file diff --git a/server/templates/layout.html b/server/templates/layout.html index 1b84624..480b462 100644 --- a/server/templates/layout.html +++ b/server/templates/layout.html @@ -8,6 +8,7 @@ ESP update server + {{ moment.include_moment() }} diff --git a/server/templates/users.html b/server/templates/users.html index 414794e..945095c 100644 --- a/server/templates/users.html +++ b/server/templates/users.html @@ -32,13 +32,14 @@

    Users

    {{ user.name }} {{user.email}} - -
    + + - -
    + + + From 7c89c939bcfb7c0e66775e150a481dfc20bb6fcc Mon Sep 17 00:00:00 2001 From: Marco Date: Fri, 4 Feb 2022 09:38:50 +0100 Subject: [PATCH 67/73] use the HTTP_X_FORWARDED_FOR header so we have the correct IP address if behind a proxy, and add Nginx example reverse proxy --- nginx_example.conf | 29 +++++++++++++++++++++++++++++ server/main.py | 8 ++++---- 2 files changed, 33 insertions(+), 4 deletions(-) create mode 100644 nginx_example.conf diff --git a/nginx_example.conf b/nginx_example.conf new file mode 100644 index 0000000..bd8b9a0 --- /dev/null +++ b/nginx_example.conf @@ -0,0 +1,29 @@ +# Note: It is important to add: +# `underscores_in_headers on; # allow underscores_in_headers to be parsed and passed on` +# to the global nginx.conf , otherwise the MAC-address will not be received by the OTA-server +server { + listen 5001; + + server_name fwupdate.test.com; + location / { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_pass http://127.0.0.1:5000; + } + + location /update { + proxy_pass http://127.0.0.1:5000/update; + proxy_buffering on; + proxy_buffers 12 12k; + proxy_redirect off; + + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $server_name; + proxy_set_header X-Forwarded-Proto http; + proxy_pass_request_headers on; + } + +} diff --git a/server/main.py b/server/main.py index a8379b4..ca90296 100644 --- a/server/main.py +++ b/server/main.py @@ -172,9 +172,7 @@ def whitelist_post(): @main.route("/update", methods=["GET"]) def update(): - __error = 400 __dev = request.args.get("dev", default=None) # get requested device version - if "X_ESP8266_STA_MAC" in request.headers: __mac = request.headers["X_ESP8266_STA_MAC"] __mac = str(re.sub(r"[^0-9A-fa-f]+", "", __mac.lower())) @@ -194,15 +192,17 @@ def update(): if __dev and __mac and __ver and len(__mac) == 12 : # If we know this device already device = Device.query.filter_by(mac=__mac).first() + # get ip address, either if forwarded by proxy or directly + remote_ip = request.environ.get('HTTP_X_FORWARDED_FOR', request.remote_addr) if device: device.last_seen = datetime.utcnow() device.version = str(__ver) device.requested_platform = __dev - device.IP = request.remote_addr + device.IP = remote_ip if device.first_seen is None: # If the device was manually added, first_seen will be None device.first_seen = datetime.utcnow() else: - device = Device(mac=__mac, version=str(__ver), requested_platform=__dev, IP=request.remote_addr) + device = Device(mac=__mac, version=str(__ver), requested_platform=__dev, IP=remote_ip) # add the new device to the database db.session.add(device) db.session.commit() From 3f01d8128fd10e4ed50b24095f6e0f13c756fa01 Mon Sep 17 00:00:00 2001 From: Marco Date: Fri, 4 Feb 2022 10:37:44 +0100 Subject: [PATCH 68/73] allow us to forget about any unbound devices --- server/main.py | 23 +++++++++++++++++------ server/templates/whitelist.html | 13 ++++++++++++- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/server/main.py b/server/main.py index ca90296..dca94e6 100644 --- a/server/main.py +++ b/server/main.py @@ -117,18 +117,29 @@ def whitelist_post(): if request.form['_device']: device_id = request.form.get('_device',type=int) device = Device.query.filter_by(id=device_id).first() - device.type = None # Set the type to None, instead of deleting the device completely - db.session.commit() - flash("Deleted device from platform",'warning') + if device: + device.type = None # Set the type to None, instead of deleting the device completely + db.session.commit() + flash("Deleted device from platform",'warning') # Edit notes elif request.form.get('_method') and 'NOTES' in request.form.get('_method'): if request.form['_device']: device_id = request.form.get('_device',type=int) device = Device.query.filter_by(id=device_id).first() - device.notes = request.form.get('_notes') # update the note - db.session.commit() - flash("Updated note",'warning') + if device: + device.notes = request.form.get('_notes') # update the note + db.session.commit() + flash("Updated note",'warning') + + if request.form.get('_method') and 'FORGET' in request.form.get('_method'): + if request.form['_device']: + device_id = request.form.get('_device',type=int) + device = Device.query.filter_by(id=device_id).first() + if device: + db.session.delete(device) + db.session.commit() + flash("Forgot about device {}".format(device.mac),'warning') elif request.form.get('action') and 'ADD' in request.form.get('action'): # Ensure valid data. diff --git a/server/templates/whitelist.html b/server/templates/whitelist.html index 87d8f77..227106e 100644 --- a/server/templates/whitelist.html +++ b/server/templates/whitelist.html @@ -156,6 +156,7 @@

    These devices have been seen, but have Notes Edit Add + Forget @@ -187,7 +188,17 @@

    These devices have been seen, but have src="{{ url_for('static', filename='plus-box.svg') }}"> - + + +
    + + + +
    +
    + {% endfor %} From 466f22ec1e51e16462f3018b697e4b3de0b0b7b7 Mon Sep 17 00:00:00 2001 From: Marco Date: Fri, 4 Feb 2022 18:03:26 +0100 Subject: [PATCH 69/73] typofix --- server/templates/whitelist.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/templates/whitelist.html b/server/templates/whitelist.html index 227106e..d1ca2b9 100644 --- a/server/templates/whitelist.html +++ b/server/templates/whitelist.html @@ -194,7 +194,7 @@

    These devices have been seen, but have From 2e9be1983661bd4144455c94bf6934b9c81bd59f Mon Sep 17 00:00:00 2001 From: Marco Date: Mon, 7 Feb 2022 15:03:09 +0100 Subject: [PATCH 70/73] lower sentry trace sample rate to prevent hitting the free-tier limit --- server/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/__init__.py b/server/__init__.py index a829022..fbf3e8e 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -70,7 +70,7 @@ def load_user(user_id): # Set traces_sample_rate to 1.0 to capture 100% # of transactions for performance monitoring. # We recommend adjusting this value in production. - traces_sample_rate=1.0 + traces_sample_rate=0.005 ) From 7e97fed9a5303e5e1cd6fba549b839cec1b5c12d Mon Sep 17 00:00:00 2001 From: Marco Date: Wed, 9 Feb 2022 18:10:43 +0100 Subject: [PATCH 71/73] if -> elif bug fixed --- server/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/main.py b/server/main.py index dca94e6..41e3b6c 100644 --- a/server/main.py +++ b/server/main.py @@ -132,7 +132,7 @@ def whitelist_post(): db.session.commit() flash("Updated note",'warning') - if request.form.get('_method') and 'FORGET' in request.form.get('_method'): + elif request.form.get('_method') and 'FORGET' in request.form.get('_method'): if request.form['_device']: device_id = request.form.get('_device',type=int) device = Device.query.filter_by(id=device_id).first() From 52f633b8544ae950e3875a38c52102e8cd27d59a Mon Sep 17 00:00:00 2001 From: Marco Date: Wed, 9 Feb 2022 17:28:00 +0000 Subject: [PATCH 72/73] Create docker-publish.yml --- .github/workflows/docker-publish.yml | 93 ++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 .github/workflows/docker-publish.yml diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..50749a4 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,93 @@ +name: Docker + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +on: + schedule: + - cron: '41 22 * * *' + push: + branches: [ master ] + # Publish semver tags as releases. + tags: [ 'v*.*.*' ] + pull_request: + branches: [ master ] + +env: + # Use docker.io for Docker Hub if empty + REGISTRY: ghcr.io + # github.repository as / + IMAGE_NAME: ${{ github.repository }} + + +jobs: + build: + + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + # This is used to complete the identity challenge + # with sigstore/fulcio when running outside of PRs. + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + # Install the cosign tool except on PR + # https://github.com/sigstore/cosign-installer + - name: Install cosign + if: github.event_name != 'pull_request' + uses: sigstore/cosign-installer@1e95c1de343b5b0c23352d6417ee3e48d5bcd422 + with: + cosign-release: 'v1.4.0' + + + # Workaround: https://github.com/docker/build-push-action/issues/461 + - name: Setup Docker buildx + uses: docker/setup-buildx-action@79abd3f86f79a9d68a23c75a09a9a85889262adf + + # Login against a Docker registry except on PR + # https://github.com/docker/login-action + - name: Log into registry ${{ env.REGISTRY }} + if: github.event_name != 'pull_request' + uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Extract metadata (tags, labels) for Docker + # https://github.com/docker/metadata-action + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + # Build and push Docker image with Buildx (don't push on PR) + # https://github.com/docker/build-push-action + - name: Build and push Docker image + id: build-and-push + uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + # Sign the resulting Docker image digest except on PRs. + # This will only write to the public Rekor transparency log when the Docker + # repository is public to avoid leaking data. If you would like to publish + # transparency data even for private images, pass --force to cosign below. + # https://github.com/sigstore/cosign + - name: Sign the published Docker image + if: ${{ github.event_name != 'pull_request' }} + env: + COSIGN_EXPERIMENTAL: "true" + # This step uses the identity token to provision an ephemeral certificate + # against the sigstore community Fulcio instance. + run: cosign sign ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }} From 3de2e1cb81d3cdf52f7df74f11dae7a01829e0bc Mon Sep 17 00:00:00 2001 From: Marco Date: Fri, 18 Mar 2022 08:17:25 +0000 Subject: [PATCH 73/73] Update docker-publish.yml disable nightly builds --- .github/workflows/docker-publish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 50749a4..f4b6bc8 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -6,8 +6,8 @@ name: Docker # documentation. on: - schedule: - - cron: '41 22 * * *' +# schedule: +# - cron: '41 22 * * *' push: branches: [ master ] # Publish semver tags as releases.