diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..f4b6bc8 --- /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 }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4ec2567 --- /dev/null +++ b/.gitignore @@ -0,0 +1,211 @@ +# 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,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 +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +### 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 +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,venv + +# 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/README.md b/README.md index 95e1914..e09f2b8 100644 --- a/README.md +++ b/README.md @@ -13,42 +13,71 @@ 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. +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 -### Start Server From Code +Linux: +``` +export ADMIN_EMAIL=desired_login_email@yahoo.com +export ADMIN_PASSWORD=verysecurepassword +``` +Windows: +``` +SET ADMIN_EMAIL=desired_login_email@yahoo.com +SET ADMIN_PASSWORD=verysecurepassword +``` +Docker: +``` +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 -To run the server directly from code start it with the following command: +To run the server directly from sourcecode start it with the following command: ``` -python3 server.py +python -m pip install -r requirements.txt # To install the required dependencies +export FLASK_APP=server +export FLASK_ENV=development +python3 -m flask run --host=0.0.0.0 ``` -### Start Server From Docker? +### Running with 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. - -To run the server in a Docker container create a directory for storing binaries. Go inside this directory and execute the following command: +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 -v $PWD:/esp-update-server/bin -p 5000:5000 kstobbe/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. -### 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 -dt --restart unless-stopped --name esp-update-server -v $PWD/bin:/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. +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?ver=v1.0.2&dev=chase +http://192.168.0.10:5000/update?dev=smart-lamp&ver=v1.0.2 ``` The server will respond with _HTTP Error Code_: @@ -58,24 +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 HOST "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* 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( urlBase); - checkUrl.concat( "?ver=" + String(VERSION) ); - checkUrl.concat( "&dev=" + String(HOST) ); + String checkUrl = String( ota_update_server) + String("update?dev=" DEVICE_PLATFORM "&ver=" FW_VERSION ); Serial.println("INFO: Checking for updates at URL: " + String( checkUrl ) ); @@ -106,17 +133,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 FW_VERSION "v1.0.2 +#define DEVICE_PLATFORM "SMART_LAMP" -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( urlBase); - checkUrl.concat( "?ver=" + String(VERSION) ); - checkUrl.concat( "&dev=" + String(HOST) ); + String checkUrl = String( ota_update_server) + String("update?dev=" DEVICE_PLATFORM "&ver=" FW_VERSION ); Serial.println("INFO: Checking for updates at URL: " + String( checkUrl ) ); @@ -137,6 +162,13 @@ void checkForUpdates(void) } ``` +## TODO +- [ ] Usermanager +- [ ] Ability to delete platforms +- [ ] Better input handling/checking +- [ ] Getting it production-ready and safe +- [ ] Make compatible with AutoConnect + ## Legal Project is under the [MIT License](LICENSE.md). 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/dockerfile b/dockerfile index 3bd49c5..4086950 100644 --- a/dockerfile +++ b/dockerfile @@ -1,6 +1,9 @@ -FROM python:alpine3.7 -COPY . /esp-update-server -WORKDIR /esp-update-server -RUN pip install -r requirements.txt +# syntax=docker/dockerfile:1 +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 -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/img/whitelist.png b/img/whitelist.png new file mode 100644 index 0000000..8d19591 Binary files /dev/null and b/img/whitelist.png differ 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/requirements.txt b/requirements.txt index 160edaf..7dfd253 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,7 @@ -flask==1.0.2 -pyYAML==5.1 +flask==2.0.2 packaging==19.0 +Flask-HTTPAuth==4.2.0 +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.py b/server.py deleted file mode 100644 index 7935449..0000000 --- a/server.py +++ /dev/null @@ -1,274 +0,0 @@ -from datetime import datetime -from flask import Flask, request, render_template, flash, redirect, url_for, send_from_directory -from packaging import version -import re -import time -import os -import yaml - -__author__ = 'Kristian Stobbe' -__copyright__ = 'Copyright 2019, K. Stobbe' -__credits__ = ['Kristian Stobbe'] -__license__ = 'MIT' -__version__ = '1.1.0' -__maintainer__ = 'Kristian Stobbe' -__email__ = 'mail@kstobbe.dk' -__status__ = 'Production' - -ALLOWED_EXTENSIONS = set(['bin']) -app = Flask(__name__) -app.config['UPLOAD_FOLDER'] = './bin' -app.config['SECRET_KEY'] = 'Kri57i4n570bb33r3nF1ink3rFyr' -PLATFORMS_YAML = app.config['UPLOAD_FOLDER'] + '/platforms.yml' - - -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 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 - - -@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(): - __error = 400 - platforms = load_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: - log_event("INFO: Dev: " + __dev + "Ver: " + __ver) - __dev = __dev.lower() - if platforms: - if __dev in platforms.keys(): - if __mac in platforms[__dev]['whitelist']: - 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 - - -@app.route('/upload', methods=['GET', 'POST']) -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(): - 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')) - 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) - 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']) -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']) -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']) -def whitelist(): - platforms = load_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: - return render_template('whitelist.html', platforms=platforms) - else: - return render_template('status.html', platforms=platforms) - - -@app.route('/') -def index(): - platforms = load_yaml() - return render_template('status.html', platforms=platforms) - - -if __name__ == '__main__': - app.run(host='0.0.0.0', port=int('5000'), debug=False) diff --git a/server/__init__.py b/server/__init__.py new file mode 100644 index 0000000..fbf3e8e --- /dev/null +++ b/server/__init__.py @@ -0,0 +1,77 @@ +# 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__) + + 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' # 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.init_app(app) + + login_manager = LoginManager() + login_manager.login_view = 'auth.login' + 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_PASSWORD') + if ADMIN_EMAIL and ADMIN_PASSWORD: + 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 + 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): + # 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) + + # 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=0.005 +) + + + return app \ No newline at end of file diff --git a/server/auth.py b/server/auth.py new file mode 100644 index 0000000..6559b2e --- /dev/null +++ b/server/auth.py @@ -0,0 +1,68 @@ +# 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 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')) + +@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/main.py b/server/main.py new file mode 100644 index 0000000..41e3b6c --- /dev/null +++ b/server/main.py @@ -0,0 +1,368 @@ +# main.py + +from flask import ( + Blueprint, + render_template, + redirect, + url_for, + request, + flash, + send_from_directory, + current_app, + session +) +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 +from . import db +from datetime import datetime +import time +import re +from packaging import version # for semver support +import os +import hashlib + + +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") + print(st + " " + msg) + + +@main.route("/") +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 +def profile(): + return render_template("profile.html", name=current_user.name) + + +@main.route("/create") +@login_required +def create(): + return render_template("create.html") + + +@main.route("/create", methods=["POST"]) +@login_required +def create_post(): + + platform_name = request.form.get("name") + 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.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")) + + notes = request.form.get("notes") + # Create a new platform using this information + 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() + flash("Success: Added new platform{}".format(new_platform.name),'warning') + return redirect(url_for("main.whitelist")) + + +@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("/whitelist") +@login_required +def whitelist(): + platforms = Platform.query.all() + 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).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']: + device_id = request.form.get('_device',type=int) + device = Device.query.filter_by(id=device_id).first() + 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() + if device: + device.notes = request.form.get('_notes') # update the note + db.session.commit() + flash("Updated note",'warning') + + 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() + 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. + 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 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'].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 + db.session.add(device) + db.session.commit() + 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 the device and platform are known, whitelist the device + known_device.type = known_platform.id + 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: + flash('Error: Platform unkown') + else: + flash('Error: MAC address malformed.') + else: + flash('Error: No data entered.') + else: + flash('Error: Unknown action.') + return render_template("whitelist.html", platforms=platforms, unbound_devices=unbound_devices) + +@main.route("/update", methods=["GET"]) +def update(): + __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 = 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 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 + 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 = 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=remote_ip) + # 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.filter_by(name=__dev).first() + if platform: # device is known for a platform + device_whitelisted = ( + 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 + ) + 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): + 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( + 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) + 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)) + return "Error: Internal error", 500 + 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: Unkown platform") + return "Error: Unkown platform", 500 + 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") +@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(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), 'warning') + return redirect(url_for('main.whitelist')) + 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) + 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) + + +@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']: + 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("Successfully deleted user {}".format(user.name)) + 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): + 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/models.py b/server/models.py new file mode 100644 index 0000000..0972076 --- /dev/null +++ b/server/models.py @@ -0,0 +1,35 @@ +# models.py + +from flask_login import UserMixin +from . import db +from sqlalchemy.sql import func + + +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)) + admin = db.Column(db.Boolean()) + +class Platform(db.Model): + id = db.Column(db.Integer, primary_key=True) # primary keys are required by SQLAlchemy + name = db.Column(db.String(100), unique=True) + version = db.Column(db.String(100)) + 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 + 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.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 + requested_platform = db.Column(db.String(100)) # the name of the platform that the device thinks it is + 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/static/favicon.ico b/server/static/favicon.ico new file mode 100644 index 0000000..541dd4a Binary files /dev/null and b/server/static/favicon.ico differ 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/static/plus-box.svg b/server/static/plus-box.svg new file mode 100644 index 0000000..f745cef --- /dev/null +++ b/server/static/plus-box.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/server/static/style.css b/server/static/style.css new file mode 100644 index 0000000..3752fb2 --- /dev/null +++ b/server/static/style.css @@ -0,0 +1,69 @@ + +/* Float cancel and delete buttons and add an equal width */ +.cancelbtn, .deletebtn { + float: left; + width: 50%; + } + + /* Add a color to the cancel button */ + .cancelbtn { + background-color: #ccc; + color: black; + } + + /* Add a color to the delete button */ + .deletebtn { + background-color: #f44336; + } + +/* The Modal (background) */ +.modal { + display: none; /* Hidden by default */ + position: fixed; /* Stay in place */ + z-index: 1; /* Sit on top */ + left: 0; + top: 0; + width: 100%; /* Full width */ + height: 100%; /* Full height */ + overflow: auto; /* Enable scroll if needed */ + background-color: #474e5d; + padding-top: 50px; + } + + /* Modal Content/Box */ + .modal-content { + background-color: #fefefe; + margin: 5% auto 15% auto; /* 5% from the top, 15% from the bottom and centered */ + border: 1px solid #888; + width: 80%; /* Could be more or less, depending on screen size */ + } + +/* The Modal Close Button (x) */ +.close { + position: absolute; + right: 35px; + top: 15px; + font-size: 40px; + font-weight: bold; + color: #f1f1f1; + } + + .close:hover, + .close:focus { + color: #f44336; + cursor: pointer; + } + + /* Clear floats */ + .clearfix::after { + content: ""; + clear: both; + display: table; + } + + /* Change styles for cancel button and delete button on extra small screens */ + @media screen and (max-width: 300px) { + .cancelbtn, .deletebtn { + width: 100%; + } + } \ No newline at end of file diff --git a/server/static/trash-can-outline.svg b/server/static/trash-can-outline.svg new file mode 100644 index 0000000..d63f5c1 --- /dev/null +++ b/server/static/trash-can-outline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/server/templates/create.html b/server/templates/create.html new file mode 100644 index 0000000..8f0142d --- /dev/null +++ b/server/templates/create.html @@ -0,0 +1,26 @@ + + +{% extends "layout.html" %} + +{% block content %} +
+

Add platform

+
+
+
+
+ +
+
+ +
+
+ +
+
+ + +
+
+
+{% endblock %} \ No newline at end of file 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..9c9074c --- /dev/null +++ b/server/templates/index.html @@ -0,0 +1,12 @@ + + +{% extends "layout.html" %} + +{% block content %} +

+ ESP Update server +

+

+ 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 new file mode 100644 index 0000000..480b462 --- /dev/null +++ b/server/templates/layout.html @@ -0,0 +1,95 @@ + + + + + + + + + ESP update server + + + + + {{ moment.include_moment() }} + + + + +
+ +
+ +
+ +
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {{message}} +
+ {% endfor %} + {% endif %} + {% endwith %} + {% 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/login.html b/server/templates/login.html new file mode 100644 index 0000000..c31fa14 --- /dev/null +++ b/server/templates/login.html @@ -0,0 +1,31 @@ + + +{% extends "layout.html" %} + +{% block content %} +
+

Login

+
+
+
+
+ +
+
+ +
+
+ +
+
+
+ +
+ +
+
+
+{% 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..7e90ed3 --- /dev/null +++ b/server/templates/signup.html @@ -0,0 +1,32 @@ + + +{% extends "layout.html" %} + +{% block content %} +
+

Sign Up

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

Upload Platform Image

+

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

+
+
+
+ +
+
+ +
+ +{% endblock %} \ No newline at end of file diff --git a/server/templates/users.html b/server/templates/users.html new file mode 100644 index 0000000..945095c --- /dev/null +++ b/server/templates/users.html @@ -0,0 +1,64 @@ + + +{% 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}} +
+ + + + +
+
+ +
+ + + +
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/server/templates/whitelist.html b/server/templates/whitelist.html new file mode 100644 index 0000000..d1ca2b9 --- /dev/null +++ b/server/templates/whitelist.html @@ -0,0 +1,215 @@ + + +{% extends "layout.html" %} + +{% block content %} +

+ Whitelist +

+

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

+
+
+
+

Add device

+

Bind devices to a platform

+
+ +
+
+{% with platforms = platforms %} +{% if platforms %} +{% for platform in platforms %} +
+
+

{{ platform.name.title() }}

+ Notes: + {{platform.notes}} +
+ + + + +
+
+ Downloads: {{platform.downloads}}
+ Latest firmware: {{platform.version}}
+ Devices: {{platform.devices|count}}
+
+
+ {% if platform.devices %} +
+ + + + + + + + + + + + + + + {% for device in platform.devices %} + + + + + + + + + + + + {% endfor %} + +
MACVersion + IPFirst seenLast seenNotesEditDelete
{{ format_mac(device.mac.upper()) }}{{device.version}}{{device.IP}}{% 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}} + +
+ + + + +
+
+
+ +
+ + + +
+
+
+
+ {% else %} +
+ No devices for platform {{ platform.name.title() }} +
+ {% 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 + Platform + IPFirst seenLast seenNotesEditAddForget
    {{ format_mac(device.mac.upper()) }}{{device.version}}{{device.requested_platform}}{{device.IP}}{% 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}} + +
    + + + + +
    +
    +
    + + + + + +
    + + + +
    +
    +
    + + {% else %} +
    +
  • No new devices. + {% endif %} +
  • +
    + {% endwith %} + {% endblock %} \ No newline at end of file diff --git a/static/style.css b/static/style.css deleted file mode 100644 index 99fd26a..0000000 --- a/static/style.css +++ /dev/null @@ -1,17 +0,0 @@ -body { font-family: sans-serif; background: #eee; } -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; - padding: 0.8em; background: white; } -.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 dl { font-weight: bold; } -.metanav { text-align: right; font-size: 0.8em; padding: 0.3em; - margin-bottom: 1em; background: #fafafa; } -.flash { background: #cee5F5; padding: 0.5em; - border: 1px solid #aacbe2; } -.error { background: #f0d6d6; padding: 0.5em; } diff --git a/templates/create.html b/templates/create.html deleted file mode 100644 index 0856443..0000000 --- a/templates/create.html +++ /dev/null @@ -1,12 +0,0 @@ -{% extends "layout.html" %} -{% block body %} -

    Create Platform

    -
    -
    -
    Platform name: -
    -
    -
    -
    -{% endblock %} - diff --git a/templates/layout.html b/templates/layout.html deleted file mode 100644 index ba6844d..0000000 --- a/templates/layout.html +++ /dev/null @@ -1,24 +0,0 @@ - - - ESP Update Server - - - -
    -

    ESP Update Server

    - - {% for message in get_flashed_messages() %} -
    {{ message }}
    - {% endfor %} - {% block body %}{% endblock %} -
    - K. Stobbe -
    -
    - diff --git a/templates/status.html b/templates/status.html deleted file mode 100644 index 83fcca8..0000000 --- a/templates/status.html +++ /dev/null @@ -1,61 +0,0 @@ -{% extends "layout.html" %} -{% block body %} -

    Platforms

    -
      - {% if platforms: %} - {% for key, value in platforms.items(): %} -
    • {{ key.title() }}

      - - - - - - - - - - - - - - - - - - - - - -
      Version :  - {% if value['version']: %} - {{ value['version'] }} - {% else %} - None - {% endif %} -
      Uploaded :  - {% if value['uploaded']: %} - {{ value['uploaded'] }} - {% else %} - None - {% endif %} -
      Downloads :  - {% if value['downloads']: %} - {{ value['downloads'] }} - {% else %} - 0 - {% endif %} -
      Whitelist :  - {% if value['whitelist']: %} - {% for device in value['whitelist']: %} - {{ format_mac(device.upper()) }}
      - {% endfor %} - {% else %} - None - {% endif %} -
      - {% endfor %} - {% else %} -
    • No platforms created. - {% endif %} -
    -{% endblock %} diff --git a/templates/upload.html b/templates/upload.html deleted file mode 100644 index f08597d..0000000 --- a/templates/upload.html +++ /dev/null @@ -1,12 +0,0 @@ -{% extends "layout.html" %} -{% block body %} -

    Upload Platform Image

    -
    -
    -
    Binary file upload: -
    -
    -
    -
    -{% endblock %} - diff --git a/templates/whitelist.html b/templates/whitelist.html deleted file mode 100644 index 2bee036..0000000 --- a/templates/whitelist.html +++ /dev/null @@ -1,47 +0,0 @@ -{% extends "layout.html" %} -{% block body %} -

    Manage Whitelists

    -
    -
    -
    MAC Address: -
    -
    Platform: -
    -
    -
    -
    - - - - - - - - {% 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()) }} -
    - - - -
    -
    - -{% endblock %} -