From dca7509eb40de312ea42c079fbf83ce2758edaa8 Mon Sep 17 00:00:00 2001 From: Andreas Berthoud Date: Tue, 3 Aug 2021 19:46:59 +0200 Subject: [PATCH 1/5] backend: Add DB and login --- .gitignore | 3 +- backend/dev.env | 7 + backend/monsun_backend/__init__.py | 61 ++++++- backend/monsun_backend/access/__init__.py | 3 + backend/monsun_backend/access/admin.py | 25 +++ backend/monsun_backend/access/init.py | 10 ++ backend/monsun_backend/access/permissions.py | 24 +++ backend/monsun_backend/container.py | 37 ++--- backend/monsun_backend/database.py | 40 +++++ backend/monsun_backend/defaults/config.yml | 3 + backend/monsun_backend/endpoints/__init__.py | 0 backend/monsun_backend/endpoints/admin.py | 15 ++ .../command.py} | 14 +- backend/monsun_backend/endpoints/login.py | 38 +++++ backend/monsun_backend/endpoints/logout.py | 23 +++ backend/monsun_backend/error.py | 52 ++++++ backend/monsun_backend/marshmallow_schemas.py | 19 +++ backend/monsun_backend/models/__init__.py | 3 + backend/monsun_backend/models/user_role.py | 152 ++++++++++++++++++ backend/pytest.ini | 5 + backend/requirements.txt | 9 ++ backend/tests/conftest.py | 60 +++++++ backend/tests/endpoints/test_login.py | 50 ++++++ backend/tests/requirements.txt | 1 + backend/tests/utilities.py | 40 +++++ 25 files changed, 665 insertions(+), 29 deletions(-) create mode 100644 backend/dev.env create mode 100644 backend/monsun_backend/access/__init__.py create mode 100644 backend/monsun_backend/access/admin.py create mode 100644 backend/monsun_backend/access/init.py create mode 100644 backend/monsun_backend/access/permissions.py create mode 100644 backend/monsun_backend/database.py create mode 100644 backend/monsun_backend/endpoints/__init__.py create mode 100644 backend/monsun_backend/endpoints/admin.py rename backend/monsun_backend/{command_endpoint.py => endpoints/command.py} (80%) create mode 100644 backend/monsun_backend/endpoints/login.py create mode 100644 backend/monsun_backend/endpoints/logout.py create mode 100644 backend/monsun_backend/error.py create mode 100644 backend/monsun_backend/marshmallow_schemas.py create mode 100644 backend/monsun_backend/models/__init__.py create mode 100644 backend/monsun_backend/models/user_role.py create mode 100644 backend/pytest.ini create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/endpoints/test_login.py create mode 100644 backend/tests/requirements.txt create mode 100644 backend/tests/utilities.py diff --git a/.gitignore b/.gitignore index 6f52837..63f6d79 100644 --- a/.gitignore +++ b/.gitignore @@ -65,4 +65,5 @@ Release/ .vscode/ venv*/ *.pyc -config.yml +monsun_config.yml +.idea/ diff --git a/backend/dev.env b/backend/dev.env new file mode 100644 index 0000000..879b6b7 --- /dev/null +++ b/backend/dev.env @@ -0,0 +1,7 @@ +FLASK_APP=monsun_backend +FLASK_ENV=development +SECRET=\\\x10#\xd9\x89,2|hBN\xe4\xdf\xe0\xf7W +POSTGRES_PASSWORD=pass +POSTGRES_USER=usr +POSTGRES_HOST=localhost +POSTGRES_DB_NAME=monsun_backend diff --git a/backend/monsun_backend/__init__.py b/backend/monsun_backend/__init__.py index c9e1258..2bdd2c3 100644 --- a/backend/monsun_backend/__init__.py +++ b/backend/monsun_backend/__init__.py @@ -1,21 +1,52 @@ +import logging import os from logging.config import dictConfig from flask_api import FlaskAPI +from flask_marshmallow import Marshmallow -from . import command_endpoint +"""https://blog.miguelgrinberg.com/post/how-to-add-flask-migrate-to-an-existing-project""" # noqa +from flask_migrate import Migrate +from flask_security import Security + +from . import access from . import command_execution +from . import database +from . import error +from . import models +from .endpoints import admin +from .endpoints import command +from .endpoints import login +from .endpoints import logout -__title__ = "monsun-backend" +__title__ = "monsun_backend" __author__ = "Andreas Berthoud, Fabian Klein" __version__ = "0.0.0" __email__ = "andreasberthoud@gmail.com" __copyright__ = f"2021 {__author__}" +os.environ.update( + { + "DATABASE_URI": "postgresql+psycopg2://" + "{dbuser}:{dbpass}@{dbhost}/{dbname}{sslmode}".format( + dbuser=os.getenv("POSTGRES_USER", "usr"), + dbpass=os.getenv("POSTGRES_PASSWORD", "pass"), + dbhost=os.getenv("POSTGRES_HOST", "localhost"), + dbname=os.getenv("POSTGRES_DB_NAME", "monsun_backend"), + sslmode="?sslmode=require" if os.getenv("ENABLE_SSL") is not None else "", + ), + }, +) + +migrate = Migrate() + def create_app() -> FlaskAPI: app = FlaskAPI(__name__) - app.register_blueprint(command_endpoint.bp) + app.register_blueprint(command.bp) + app.register_blueprint(login.bp) + app.register_blueprint(logout.bp) + app.register_blueprint(admin.bp) dictConfig( { @@ -42,8 +73,30 @@ def create_app() -> FlaskAPI: }, }, ) + logger = logging.getLogger("monsun_backend.init") + _exception_logger = logging.getLogger("monsun_backend.exception") + + app.secret_key = os.getenv("SECRET") + app.config.update( + SQLALCHEMY_TRACK_MODIFICATIONS=False, + SQLALCHEMY_DATABASE_URI=os.getenv("DATABASE_URI"), + ) + + logger.info(f"DATABASE_URI: {os.getenv('DATABASE_URI')}") + database.db.init_app(app) + migrate.init_app(app, database.db) + Marshmallow(app) + Security(app=app, datastore=models.user_datastore) + access.init_app(app) - # container.init_app(app) + # register error handler + @app.errorhandler(error.MonsunError) + def monsun_error_handler(e: error.MonsunError): + if 500 > e.status_code >= 400: + _exception_logger.info(f"Exception raised ({e.status_code}): {e.data}") + if e.status_code >= 500: + _exception_logger.error(f"Exception raised ({e.status_code}): {e.data}") + return e.response if os.environ.get("WERKZEUG_RUN_MAIN") != "true": # prevent from be called twice in debug mode diff --git a/backend/monsun_backend/access/__init__.py b/backend/monsun_backend/access/__init__.py new file mode 100644 index 0000000..d14c1d4 --- /dev/null +++ b/backend/monsun_backend/access/__init__.py @@ -0,0 +1,3 @@ +from .admin import make_admin_user # noqa +from .init import init_app # noqa +from .permissions import admin_permission # noqa diff --git a/backend/monsun_backend/access/admin.py b/backend/monsun_backend/access/admin.py new file mode 100644 index 0000000..7f6a151 --- /dev/null +++ b/backend/monsun_backend/access/admin.py @@ -0,0 +1,25 @@ +from flask import Flask +from monsun_backend import models + +from ..container import get_initialize_container + + +def init_app(app: Flask): + app.before_first_request(make_admin_user) + + +def make_admin_user() -> models.User: + """Makes the admin user if he does not exist + + The admin is always the first user (id==1) + """ + container = get_initialize_container() + admin_user: models.User = models.user_datastore.get_user(identifier=1) + if admin_user is None: + admin_user = models.user_datastore.create_user( + email=container.config.admin_user_email(), + password=container.config.admin_user_password(), + ) + admin_user.save() + + return admin_user diff --git a/backend/monsun_backend/access/init.py b/backend/monsun_backend/access/init.py new file mode 100644 index 0000000..b0eef67 --- /dev/null +++ b/backend/monsun_backend/access/init.py @@ -0,0 +1,10 @@ +"""Cannot be part ot __init__.py because it must be executed AFTER imports""" +from flask import Flask + +from . import admin +from . import permissions + + +def init_app(app: Flask): + admin.init_app(app=app) + permissions.init_app(app=app) diff --git a/backend/monsun_backend/access/permissions.py b/backend/monsun_backend/access/permissions.py new file mode 100644 index 0000000..8b77b53 --- /dev/null +++ b/backend/monsun_backend/access/permissions.py @@ -0,0 +1,24 @@ +from flask import Flask +from flask_login import current_user +from flask_principal import AnonymousIdentity +from flask_principal import Identity +from flask_principal import Permission +from flask_principal import Principal +from flask_principal import RoleNeed +from monsun_backend import models + +principals = Principal() + + +@principals.identity_loader +def read_identity_from_flask_login(): + if current_user.is_authenticated: + return Identity(current_user.id) + return AnonymousIdentity() + + +def init_app(app: Flask): + principals.init_app(app=app) + + +admin_permission = Permission(RoleNeed(models.RoleType.admin.name)) diff --git a/backend/monsun_backend/container.py b/backend/monsun_backend/container.py index 6175ab0..c4a36d7 100644 --- a/backend/monsun_backend/container.py +++ b/backend/monsun_backend/container.py @@ -1,8 +1,6 @@ import logging from pathlib import Path from pprint import pformat -from typing import Dict -from typing import Optional from dependency_injector import containers from dependency_injector import providers @@ -27,30 +25,33 @@ class Container(containers.DeclarativeContainer): @log_function_call -def get_initialize_container( - config: Optional[Dict] = None, - config_file: Optional[Path] = None, -) -> Container: +def get_initialize_container() -> Container: logger = _logger.getChild("initialize_container") logger.debug("initialize container...") container = Container() - logger.debug(f"initialize container from config file: {CONFIG_FILE}") + logger.debug(f"initialize container with defaults from config file: {CONFIG_FILE}") container.config.from_yaml(CONFIG_FILE, required=True) - user_config = Path.cwd() / "config.yml" - if user_config.is_file(): - logger.debug(f"initialize container from user config file: {user_config}") - container.config.from_yaml(user_config, required=True) - - if config is not None: - logger.debug(f"initialize container with config: {config}") - container.config.from_dict(config) - - if config_file is not None: + current_dir = Path.cwd() + is_searching = True + while is_searching: + config_file = current_dir / "monsun_config.yml" + if config_file.is_file(): + logger.debug(f"initialize container from user config file: {config_file}") + container.config.from_yaml(config_file, required=True) + break + else: + current_dir = current_dir.parent + + if str(current_dir) == current_dir.anchor: + is_searching = False + + config_file = Path("/var/config/monsun_config.yml") + if config_file.is_file(): logger.debug(f"initialize container from config file: {config_file}") - container.config.from_yaml(config_file) + container.config.from_yaml(config_file, required=True) logger.debug(f"container config:\n{pformat(container.config())}") return container diff --git a/backend/monsun_backend/database.py b/backend/monsun_backend/database.py new file mode 100644 index 0000000..d346f3f --- /dev/null +++ b/backend/monsun_backend/database.py @@ -0,0 +1,40 @@ +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy import Column +from sqlalchemy import Integer +from sqlalchemy.ext.declarative import as_declarative +from sqlalchemy.ext.declarative import declared_attr + + +@as_declarative() +class BaseModel: + """This class provides helper methods that can be used by its subclasses""" + + id = Column(Integer, primary_key=True) + + @declared_attr + def __tablename__(cls): + return cls.__name__.lower() + + def save(self): + db.session.add(self) + db.session.commit() + return self + + def delete(self): + db.session.delete(self) + db.session.commit() + + @classmethod + def get_all(cls): + return cls.query.all() + + def __repr__(self): + return f"<{self.__class__.__name__} {self.name!r}>" + + +def session_commit(): + """Shortcut for ``db.session.commit()``""" + db.session.commit() + + +db = SQLAlchemy(model_class=BaseModel) diff --git a/backend/monsun_backend/defaults/config.yml b/backend/monsun_backend/defaults/config.yml index 3294877..1e32306 100644 --- a/backend/monsun_backend/defaults/config.yml +++ b/backend/monsun_backend/defaults/config.yml @@ -2,3 +2,6 @@ baudrate: 115200 header_size: 4 heartbeat_interval: 1 serial_reconnection_wait_timeout: 1 +admin_user_email: andreasberthoud@gmail.com +admin_user_password: password +roles: [] diff --git a/backend/monsun_backend/endpoints/__init__.py b/backend/monsun_backend/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/monsun_backend/endpoints/admin.py b/backend/monsun_backend/endpoints/admin.py new file mode 100644 index 0000000..2ac4525 --- /dev/null +++ b/backend/monsun_backend/endpoints/admin.py @@ -0,0 +1,15 @@ +from flask import blueprints +from flask_api import status + +from .. import access + +bp = blueprints.Blueprint("admin", __name__) + + +@bp.route( + rule="/admin", + methods=["GET"], +) +@access.admin_permission.require(http_exception=status.HTTP_403_FORBIDDEN) +def admin(): + return "success", 200 diff --git a/backend/monsun_backend/command_endpoint.py b/backend/monsun_backend/endpoints/command.py similarity index 80% rename from backend/monsun_backend/command_endpoint.py rename to backend/monsun_backend/endpoints/command.py index fea1560..c509d2c 100644 --- a/backend/monsun_backend/command_endpoint.py +++ b/backend/monsun_backend/endpoints/command.py @@ -7,12 +7,13 @@ from flask import make_response from flask import request from flask_api import status -from . import commands -from .command_execution import execute_command -from .commands import CommandId -from .commands import CommandTarget -from .commands import get_command_id_from_name -from .commands import get_request_class +from .. import access +from .. import commands +from ..command_execution import execute_command +from ..commands import CommandId +from ..commands import CommandTarget +from ..commands import get_command_id_from_name +from ..commands import get_request_class _logger = logging.getLogger(__name__) @@ -20,6 +21,7 @@ bp = blueprints.Blueprint("command", __name__) @bp.route("//command", methods=["POST", "GET"]) +@access.admin_permission.require(http_exception=status.HTTP_403_FORBIDDEN) def command(role: str): logger = _logger.getChild(f"{role}/command") diff --git a/backend/monsun_backend/endpoints/login.py b/backend/monsun_backend/endpoints/login.py new file mode 100644 index 0000000..04070e9 --- /dev/null +++ b/backend/monsun_backend/endpoints/login.py @@ -0,0 +1,38 @@ +from typing import Optional + +from flask import blueprints +from flask import make_response +from flask import request +from flask_api import status +from flask_login import login_user +from monsun_backend import error +from monsun_backend import marshmallow_schemas +from monsun_backend import models + +bp = blueprints.Blueprint("login", __name__) + + +@bp.route( + rule="/login", + methods=["POST"], +) +def login(): + email_: Optional[str] = request.form.get("email") + if email_ is None: + raise error.BadRequest("email is missing") + + user_: models.User = models.User.query.filter_by(email=email_).first() + if not user_: + raise error.BadRequest("User does not exist") + + if user_.check_password(password=request.form.get("password")): + if login_user(user_): + return make_response( + marshmallow_schemas.user_schema.jsonify(user_), + status.HTTP_200_OK, + ) + else: + # this shouldn't happen, but one never knows... + raise error.InternalServerError("Could not login user") + else: + raise error.Unauthorized("Password is incorrect") diff --git a/backend/monsun_backend/endpoints/logout.py b/backend/monsun_backend/endpoints/logout.py new file mode 100644 index 0000000..f58f57f --- /dev/null +++ b/backend/monsun_backend/endpoints/logout.py @@ -0,0 +1,23 @@ +from flask import blueprints +from flask import jsonify +from flask import make_response +from flask_api import status +from flask_login import login_required +from flask_login import logout_user + +bp = blueprints.Blueprint("logout", __name__) + + +@bp.route( + rule="/logout", + methods=["DELETE"], +) +@login_required +def logout(): + # Remove the user information from the session + logout_user() + + return make_response( + jsonify(message="logout successful"), + status.HTTP_200_OK, + ) diff --git a/backend/monsun_backend/error.py b/backend/monsun_backend/error.py new file mode 100644 index 0000000..eb79046 --- /dev/null +++ b/backend/monsun_backend/error.py @@ -0,0 +1,52 @@ +from typing import Dict +from typing import Optional +from typing import Union + +from flask import Response +from flask import jsonify +from flask_api import status + + +class MonsunError(Exception): + status_code = status.HTTP_501_NOT_IMPLEMENTED + + data: Dict + message: Optional[str] + + def __init__( + self, + content: Union[str, Dict], + ): + if isinstance(content, str): + self.message = content + self.data = {"message": self.message} + elif isinstance(content, Dict): + self.data = content + else: + raise ValueError("'content' must be either a string or an dict") + + @property + def response(self) -> Response: + response: Response = jsonify(self.data) + response.status_code = self.status_code + return response + + +class BadRequest(MonsunError): + status_code = status.HTTP_400_BAD_REQUEST + + +class Unauthorized(MonsunError): + status_code = status.HTTP_401_UNAUTHORIZED + + +class Forbidden(MonsunError): + status_code = status.HTTP_403_FORBIDDEN + + +class InternalServerError(MonsunError): + status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + + +class ConfigurationError(InternalServerError): + """raised in case of invalid configuration""" diff --git a/backend/monsun_backend/marshmallow_schemas.py b/backend/monsun_backend/marshmallow_schemas.py new file mode 100644 index 0000000..2c0f8f0 --- /dev/null +++ b/backend/monsun_backend/marshmallow_schemas.py @@ -0,0 +1,19 @@ +from flask_marshmallow import Marshmallow +from marshmallow import fields + +from .models import User + +ma = Marshmallow() + + +class UserSchema(ma.SQLAlchemySchema): # type: ignore + class Meta: + model = User + + id = fields.Int() + email = fields.Str() + active = fields.Bool() + + +user_schema = UserSchema() +user_schema_public = UserSchema(only=("id", "email")) diff --git a/backend/monsun_backend/models/__init__.py b/backend/monsun_backend/models/__init__.py new file mode 100644 index 0000000..b23161c --- /dev/null +++ b/backend/monsun_backend/models/__init__.py @@ -0,0 +1,3 @@ +from .user_role import RoleType # noqa +from .user_role import User # noqa +from .user_role import user_datastore # noqa diff --git a/backend/monsun_backend/models/user_role.py b/backend/monsun_backend/models/user_role.py new file mode 100644 index 0000000..223210f --- /dev/null +++ b/backend/monsun_backend/models/user_role.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import enum +from typing import Dict +from typing import Optional +from typing import Sequence + +from flask_security import RoleMixin +from flask_security import SQLAlchemyUserDatastore +from flask_security import UserMixin +from werkzeug.security import check_password_hash +from werkzeug.security import generate_password_hash + +from ..database import BaseModel +from ..database import db + +roles_users = db.Table( + "roles_users", + db.Column("user_id", db.Integer(), db.ForeignKey("user.id")), + db.Column("role_id", db.Integer(), db.ForeignKey("role.id")), +) + + +class RoleType(enum.Enum): + """The type of a role""" + + admin: int = 1 + + +ROLE_DESCRIPTIONS: Dict[RoleType, str] = { + RoleType.admin: "Has admin rights", +} + + +class Role(BaseModel, RoleMixin): + id: int # type: ignore + """The DB id""" + + name: str = db.Column(db.String(80), unique=True) + """The name of the role, see :class:`RoleType`""" + + description: str = db.Column(db.String(255)) + """A short description of the role""" + + def __init__( + self, + name: str, + description: str, + ): + """ + :param name: The name of the role, see :class:`RoleType` + :param description: A short description of the role + """ + self.name = name + self.description = description + + @classmethod + def get_role( + cls, + role_type: RoleType, + ) -> Role: + """Fetches the role from the DB or creates it if necessary + + :param role_type: The type of the role + :return: The role instance + """ + role_: Optional[Role] = cls.query.filter_by(name=role_type.name).first() + + if role_ is None: + role_ = cls( + name=role_type.name, + description=ROLE_DESCRIPTIONS[role_type], + ) + role_.save() + + return role_ + + +class User(BaseModel, UserMixin): + id: int # type: ignore + """The DB id""" + + email: str = db.Column(db.String(255), unique=True) + """The email address""" + + password: str = db.Column(db.String(512)) + """The password as hash""" + + active: bool = db.Column(db.Boolean()) + """Is the user activated?""" + + roles: Sequence[Role] = db.relationship( + "Role", + secondary=roles_users, + backref=db.backref("users", lazy="dynamic"), + ) + """The roles which the user has""" + + def __init__( + self, + email: str, + password: str, + active: bool = True, + roles: Sequence[Role] = None, + ): + """ + + :param email: The email address + :param password: The password as plain text + :param active: *True* if the user is active + :param roles: The Roles of the user. + """ + self.email = email + self.set_password(password=password) + self.active = active + self.roles = roles or [Role.get_role(RoleType.admin)] + + def set_password( + self, + password: str, + ): + """Create hashed password.""" + self.password = generate_password_hash( + password=password, + ) + + def check_password( + self, + password: str, + ) -> bool: + """Check hashed password + + :param password: The password in plain text + :return: True if equal + """ + return bool( + check_password_hash( + pwhash=self.password, + password=password, + ), + ) + + def __repr__(self): + return ( + f"<{self.__class__.__name__} " + f"email={self.email!r}, " + f"active={self.active!r}, " + f"roles={self.roles!r}>" + ) + + +user_datastore = SQLAlchemyUserDatastore(db, User, Role) diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..de532e4 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +addopts = -s + +markers = + client: required client device (dongle) diff --git a/backend/requirements.txt b/backend/requirements.txt index bd17f68..7a4b393 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,4 +1,13 @@ dependency-injector[yaml]>=4.34.0,<5 +email_validator flask-api>=3.0.post1,<4 +flask-migrate +flask-script +flask-sqlalchemy +flask_marshmallow +flask_security +marshmallow-sqlalchemy +psycopg2-binary pyserial>=3.5,<4 +sqlalchemy-utils uwsgi diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..db892c2 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,60 @@ +import os +from typing import Iterator +from typing import Type + +import pytest +import utilities +from flask.testing import FlaskClient +from monsun_backend.container import get_initialize_container +from sqlalchemy import create_engine +from sqlalchemy_utils import create_database +from sqlalchemy_utils import database_exists +from utilities import setup_app + + +@pytest.fixture(scope="session", autouse=True) +def db_test(): + """makes sure that the DB exists""" + engine = create_engine(os.getenv("DATABASE_URI")) + if not database_exists(engine.url): + create_database(engine.url) + + +@pytest.fixture(scope="class") +def app_class_scope(db_test): + with setup_app() as app: + yield app + + +@pytest.fixture(scope="class") +def client_class_scope(app_class_scope): + return app_class_scope.test_client + + +@pytest.fixture() +def app(db_test): + with setup_app() as app: + yield app + + +@pytest.fixture() +def client(app): + return app.test_client + + +@pytest.fixture(scope="class") +def as_admin( + client_class_scope: Type[FlaskClient], +) -> Iterator[utilities.TestUser]: + container = get_initialize_container() + with client_class_scope() as client_: + yield utilities.TestUser( + client_.post( + "/login", + data={ + "email": container.config.admin_user_email(), + "password": container.config.admin_user_password(), + }, + ), + client_, + ) diff --git a/backend/tests/endpoints/test_login.py b/backend/tests/endpoints/test_login.py new file mode 100644 index 0000000..03f1f6e --- /dev/null +++ b/backend/tests/endpoints/test_login.py @@ -0,0 +1,50 @@ +from flask import Response +from flask_api import status +from monsun_backend.container import get_initialize_container +from utilities import TestUser + + +def test_login_as_admin_then_get_status_ok(client): + container = get_initialize_container() + + response: Response = client().post( + "/login", + data={ + "email": container.config.admin_user_email(), + "password": container.config.admin_user_password(), + }, + ) + + assert response.status_code == status.HTTP_200_OK + + +def test_logout_as_not_logged_in_user_then_get_status_found(client): + response: Response = client().get( + "/logout", + ) + assert response.status_code == status.HTTP_302_FOUND + + +def test_logout_as_admin_then_get_status_found(as_admin: TestUser): + response: Response = as_admin.client.get( + "/logout", + ) + assert response.status_code == status.HTTP_302_FOUND + + +def test_as_admin_access_admin_endpoint_then_get_status_ok(as_admin: TestUser): + response: Response = as_admin.client.get( + "/admin", + ) + + assert response.status_code == status.HTTP_200_OK + + +def test_as_not_logged_in_user_access_admin_endpoint_then_get_status_forbidden( + client, +): # noqa + response: Response = client().get( + "/admin", + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN diff --git a/backend/tests/requirements.txt b/backend/tests/requirements.txt new file mode 100644 index 0000000..e079f8a --- /dev/null +++ b/backend/tests/requirements.txt @@ -0,0 +1 @@ +pytest diff --git a/backend/tests/utilities.py b/backend/tests/utilities.py new file mode 100644 index 0000000..f54e0e3 --- /dev/null +++ b/backend/tests/utilities.py @@ -0,0 +1,40 @@ +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Iterator + +from flask import Flask +from flask import Response +from flask.testing import FlaskClient +from monsun_backend import access +from monsun_backend import create_app +from monsun_backend import database + +TESTS_DIR = Path(__file__).parent + + +@contextmanager +def setup_app() -> Iterator[Flask]: + app: Flask = create_app() + app.config["SERVER_NAME"] = "monsun_backend.localdomain" + app.config["TESTING"] = True + + # binds the app to the current context + with app.app_context(): + # create all tables + database.db.create_all() + + access.make_admin_user() + + yield app + with app.app_context(): + # drop all tables + database.db.session.remove() + database.db.drop_all() + + +@dataclass +class TestUser: + __test__ = False + response: Response + client: FlaskClient From 052e9de8e997e011bd3349843b39308655cfffc1 Mon Sep 17 00:00:00 2001 From: Andreas Berthoud Date: Tue, 3 Aug 2021 20:43:19 +0200 Subject: [PATCH 2/5] backend: Add DB migrations --- backend/migrations/README | 1 + backend/migrations/alembic.ini | 50 +++++++++++ backend/migrations/env.py | 88 ++++++++++++++++++++ backend/migrations/script.py.mako | 24 ++++++ backend/migrations/versions/312674fa9656_.py | 58 +++++++++++++ 5 files changed, 221 insertions(+) create mode 100644 backend/migrations/README create mode 100644 backend/migrations/alembic.ini create mode 100644 backend/migrations/env.py create mode 100644 backend/migrations/script.py.mako create mode 100644 backend/migrations/versions/312674fa9656_.py diff --git a/backend/migrations/README b/backend/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/backend/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/backend/migrations/alembic.ini b/backend/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/backend/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/migrations/env.py b/backend/migrations/env.py new file mode 100644 index 0000000..b80ce85 --- /dev/null +++ b/backend/migrations/env.py @@ -0,0 +1,88 @@ +from __future__ import with_statement + +import logging +from logging.config import fileConfig + +from alembic import context +from flask import current_app + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger("alembic.env") + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option( + "sqlalchemy.url", + str(current_app.extensions["migrate"].db.get_engine().url).replace("%", "%%"), +) +target_metadata = current_app.extensions["migrate"].db.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=target_metadata, literal_binds=True) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, "autogenerate", False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info("No changes in schema detected.") + + connectable = current_app.extensions["migrate"].db.get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + process_revision_directives=process_revision_directives, + **current_app.extensions["migrate"].configure_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/migrations/script.py.mako b/backend/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/backend/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/backend/migrations/versions/312674fa9656_.py b/backend/migrations/versions/312674fa9656_.py new file mode 100644 index 0000000..b5f04fb --- /dev/null +++ b/backend/migrations/versions/312674fa9656_.py @@ -0,0 +1,58 @@ +"""empty message + +Revision ID: 312674fa965 +Revises: +Create Date: 2021-08-03 18:17:22.918120 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "312674fa9656" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "role", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=80), nullable=True), + sa.Column("description", sa.String(length=255), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + op.create_table( + "user", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("email", sa.String(length=255), nullable=True), + sa.Column("password", sa.String(length=512), nullable=True), + sa.Column("active", sa.Boolean(), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("email"), + ) + op.create_table( + "roles_users", + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column("role_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ["role_id"], + ["role.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["user.id"], + ), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("roles_users") + op.drop_table("user") + op.drop_table("role") + # ### end Alembic commands ### From d681995de0979d9d3ac3260b1fef2c0344cc4dbf Mon Sep 17 00:00:00 2001 From: Andreas Berthoud Date: Wed, 28 Jul 2021 13:19:59 +0200 Subject: [PATCH 3/5] backend: Add Docker support --- backend/.dockerignore | 4 ++ backend/.gitignore | 1 + backend/docker-compose.yml | 25 +++++++ backend/docker-entrypoint.sh | 6 ++ backend/docker_build_and_upload.sh | 3 + backend/dockerfile | 45 ++++++++++++ backend/monsun_backend/__init__.py | 12 +++- backend/monsun_backend/command_execution.py | 3 +- backend/nginx.conf | 47 ++++++++++++ backend/pytest.ini | 3 + backend/system_tests/test_login.py | 74 +++++++++++++++++++ backend/tests/docker-compose.yml | 12 ++++ backend/tests/requirements.txt | 2 + backend/uwsgi.ini | 12 ++++ backend/wait-for-it.sh | 79 +++++++++++++++++++++ 15 files changed, 325 insertions(+), 3 deletions(-) create mode 100644 backend/.dockerignore create mode 100644 backend/docker-compose.yml create mode 100755 backend/docker-entrypoint.sh create mode 100755 backend/docker_build_and_upload.sh create mode 100644 backend/dockerfile create mode 100644 backend/nginx.conf create mode 100644 backend/system_tests/test_login.py create mode 100644 backend/tests/docker-compose.yml create mode 100644 backend/uwsgi.ini create mode 100644 backend/wait-for-it.sh diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..66a3cdf --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,4 @@ +**/__pycache__/ +build/ +dist/ +**/*.egg-info diff --git a/backend/.gitignore b/backend/.gitignore index 66a3cdf..8d51787 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -2,3 +2,4 @@ build/ dist/ **/*.egg-info +db-data/ diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 0000000..35faeca --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,25 @@ +version: "2" + +services: + monsun_postgres: + image: postgres + container_name: monsun_postgres + restart: always + volumes: + - ./db-data:/var/lib/postgresql/data + env_file: dev.env + + monsun_backend: + build: . + container_name: monsun_backd + restart: always + ports: + - 80:80 + volumes: + - ./config:/var/config/ + depends_on: + - monsun_postgres + environment: + POSTGRES_HOST: monsun_postgres + env_file: dev.env + command: ["bash", "./wait-for-it.sh", "monsun_postgres:5432", "-t", "60", "--", "./docker-entrypoint.sh"] diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh new file mode 100755 index 0000000..f5ca333 --- /dev/null +++ b/backend/docker-entrypoint.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +source /opt/venv/bin/activate +flask db upgrade + +service nginx start +uwsgi --ini uwsgi.ini diff --git a/backend/docker_build_and_upload.sh b/backend/docker_build_and_upload.sh new file mode 100755 index 0000000..e5eed83 --- /dev/null +++ b/backend/docker_build_and_upload.sh @@ -0,0 +1,3 @@ +docker build -t monsun_backend . +docker tag monsun_backend:latest registry.berthoud.dev/monsun_backend +docker push registry.berthoud.dev/monsun_backend:latest diff --git a/backend/dockerfile b/backend/dockerfile new file mode 100644 index 0000000..c8fdb41 --- /dev/null +++ b/backend/dockerfile @@ -0,0 +1,45 @@ +# stage 1 +FROM python:3.8-slim-buster as backend-build + +VOLUME /app +WORKDIR /app + +RUN apt-get update \ + && apt-get -y install python3-dev \ + && apt-get -y install build-essential + +RUN python3 -m venv /opt/venv + +COPY . . + +RUN . /opt/venv/bin/activate \ + && pip install --upgrade setuptools wheel \ + && pip install -r requirements.txt \ + && python setup.py sdist bdist_wheel \ + && pip install monsun_backend --no-index --find-links file:///app/dist + +# stage 2 +FROM python:3.8-slim-buster + +RUN apt-get update \ + && apt-get -y install nginx \ + && apt-get -y install python3-dev \ + && apt-get -y install build-essential \ + && apt-get -qy install netcat + +RUN mkdir /var/config +VOLUME /var/config + +VOLUME /app +WORKDIR /app + +COPY docker-entrypoint.sh /app/docker-entrypoint.sh +COPY wait-for-it.sh /app/wait-for-it.sh +COPY wsgi.py /app/wsgi.py +COPY nginx.conf /etc/nginx +COPY uwsgi.ini /app/uwsgi.ini +COPY migrations /app/migrations + +COPY --from=backend-build /opt/venv /opt/venv + +CMD ["bash", "./docker-entrypoint.sh"] diff --git a/backend/monsun_backend/__init__.py b/backend/monsun_backend/__init__.py index 2bdd2c3..2c0f96b 100644 --- a/backend/monsun_backend/__init__.py +++ b/backend/monsun_backend/__init__.py @@ -1,13 +1,17 @@ import logging -import os from logging.config import dictConfig from flask_api import FlaskAPI from flask_marshmallow import Marshmallow """https://blog.miguelgrinberg.com/post/how-to-add-flask-migrate-to-an-existing-project""" # noqa +import os + from flask_migrate import Migrate from flask_security import Security +from sqlalchemy import create_engine +from sqlalchemy_utils import create_database +from sqlalchemy_utils import database_exists from . import access from . import command_execution @@ -48,6 +52,12 @@ def create_app() -> FlaskAPI: app.register_blueprint(logout.bp) app.register_blueprint(admin.bp) + # somehow the 'flask db upgrade' does not create the DB as promised... + # so let's do it here instead + engine = create_engine(os.getenv("DATABASE_URI")) + if not database_exists(engine.url): + create_database(engine.url) + dictConfig( { "version": 1, diff --git a/backend/monsun_backend/command_execution.py b/backend/monsun_backend/command_execution.py index f1341a1..d301009 100644 --- a/backend/monsun_backend/command_execution.py +++ b/backend/monsun_backend/command_execution.py @@ -17,8 +17,6 @@ from typing import Tuple from serial import Serial -from backend.monsun_backend.util import log_function_call - from . import commands from .commands import Command from .commands import CommandId @@ -27,6 +25,7 @@ from .commands import Request from .commands import Response from .commands import get_response_class from .container import get_initialize_container +from .util import log_function_call _logger = logging.getLogger(__file__) diff --git a/backend/nginx.conf b/backend/nginx.conf new file mode 100644 index 0000000..0c42b0b --- /dev/null +++ b/backend/nginx.conf @@ -0,0 +1,47 @@ +user www-data; +worker_processes auto; +pid /run/nginx.pid; + +events { + worker_connections 1024; + use epoll; + multi_accept on; +} + +http { + access_log /dev/stdout; + error_log /dev/stdout; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + + client_max_body_size 20M; + + include /etc/nginx/mime.types; + default_type application/octet-stream; + + index index.html index.htm; + + server { + listen 80 default_server; + listen [::]:80 default_server; + server_name localhost; + root /var/www/html; + + location / { + include uwsgi_params; + uwsgi_pass unix:/tmp/uwsgi.socket; + uwsgi_read_timeout 1h; + uwsgi_send_timeout 1h; + proxy_send_timeout 1h; + proxy_read_timeout 1h; + + proxy_buffer_size 128k; + proxy_buffers 4 256k; + proxy_busy_buffers_size 256k; + } + } +} diff --git a/backend/pytest.ini b/backend/pytest.ini index de532e4..48b83af 100644 --- a/backend/pytest.ini +++ b/backend/pytest.ini @@ -3,3 +3,6 @@ addopts = -s markers = client: required client device (dongle) + +env = + D:TEST_BASE_URL=https://monsun.berthoud.dev \ No newline at end of file diff --git a/backend/system_tests/test_login.py b/backend/system_tests/test_login.py new file mode 100644 index 0000000..f7c305a --- /dev/null +++ b/backend/system_tests/test_login.py @@ -0,0 +1,74 @@ +import logging +import os + +import pytest +import requests +from flask_api import status + +BASE_URL = os.environ.get("TEST_BASE_URL", "http://localhost") +LOGIN_URI = BASE_URL + "/login" +LOGOUT_URI = BASE_URL + "/logout" +ADMIN_URI = BASE_URL + "/admin" + +PASSWORD = os.environ.get("MONSUN_PASSWORD", "password") + + +@pytest.fixture(autouse=True) +def log_variables(): + logger = logging.getLogger("system tests") + logger.info(f"BASE_URL: {BASE_URL}") + + +@pytest.fixture() +def session() -> requests.Session: + session = requests.Session() + response_login = session.post( + url=LOGIN_URI, + data={"email": "andreasberthoud@gmail.com", "password": PASSWORD}, + ) + assert response_login.status_code == status.HTTP_200_OK + return session + + +def test_login_as_admin_and_accessing_admin_endpoint(): + session_admin_ = requests.Session() + response_login = session_admin_.post( + url=LOGIN_URI, + data={"email": "andreasberthoud@gmail.com", "password": PASSWORD}, + ) + assert response_login.status_code == status.HTTP_200_OK + + response_admin_logged_in = session_admin_.get( + url=ADMIN_URI, + ) + assert response_admin_logged_in.status_code == status.HTTP_200_OK + + response_logout = session_admin_.delete( + url=LOGOUT_URI, + ) + assert response_logout.status_code == status.HTTP_200_OK + + response_admin_logged_out = session_admin_.get( + url=ADMIN_URI, + ) + assert response_admin_logged_out.status_code == status.HTTP_403_FORBIDDEN + + +def test_toggle_client_led_then_status_is_ok(session): + response = session.post( + url=BASE_URL + "/client/command?cmd=led&id=red&command=toggle", + ) + assert response.status_code == status.HTTP_200_OK + + +def test_pair_with_server_then_status_is_ok(session): + response = session.post(url=BASE_URL + "/client/command?cmd=gp&command_id=1") + assert response.status_code == status.HTTP_200_OK + + +def test_toggle_server_led_then_status_is_ok(session): + """Requires a connected GATT server""" + response = session.post( + url=BASE_URL + "/client/command?cmd=led&id=red&command=toggle&target=server", + ) + assert response.status_code == status.HTTP_200_OK diff --git a/backend/tests/docker-compose.yml b/backend/tests/docker-compose.yml new file mode 100644 index 0000000..71d61fd --- /dev/null +++ b/backend/tests/docker-compose.yml @@ -0,0 +1,12 @@ +version: "2" + +services: + tests_monsun_postgres-test: + image: postgres + environment: + POSTGRES_PASSWORD: pass + POSTGRES_USER: usr + POSTGRES_DB: sqlalchemy + POSTGRES_HOST: postgres + ports: + - 5432:5432 diff --git a/backend/tests/requirements.txt b/backend/tests/requirements.txt index e079f8a..32cb7ff 100644 --- a/backend/tests/requirements.txt +++ b/backend/tests/requirements.txt @@ -1 +1,3 @@ pytest +pytest-env +requests diff --git a/backend/uwsgi.ini b/backend/uwsgi.ini new file mode 100644 index 0000000..47232f1 --- /dev/null +++ b/backend/uwsgi.ini @@ -0,0 +1,12 @@ +[uwsgi] +wsgi-file = wsgi.py +uid = www-data +gid = www-data +master = true +processes = 5 + +socket = /tmp/uwsgi.socket +chmod-sock = 664 +vacuum = true + +die-on-term = true diff --git a/backend/wait-for-it.sh b/backend/wait-for-it.sh new file mode 100644 index 0000000..2b0db3e --- /dev/null +++ b/backend/wait-for-it.sh @@ -0,0 +1,79 @@ +#!/bin/sh +# https://github.com/vishnubob/wait-for-it +TIMEOUT=15 +QUIET=0 + +echoerr() { + if [ "$QUIET" -ne 1 ]; then printf "%s\n" "$*" 1>&2; fi +} + +usage() { + exitcode="$1" + cat << USAGE >&2 +Usage: + $cmdname host:port [-t timeout] [-- command args] + -q | --quiet Do not output any status messages + -t TIMEOUT | --timeout=timeout Timeout in seconds, zero for no timeout + -- COMMAND ARGS Execute command with args after the test finishes +USAGE + exit "$exitcode" +} + +wait_for() { + for i in `seq $TIMEOUT` ; do + nc -z "$HOST" "$PORT" > /dev/null 2>&1 + + result=$? + if [ $result -eq 0 ] ; then + if [ $# -gt 0 ] ; then + exec "$@" + fi + exit 0 + fi + sleep 1 + done + echo "Operation timed out" >&2 + exit 1 +} + +while [ $# -gt 0 ] +do + case "$1" in + *:* ) + HOST=$(printf "%s\n" "$1"| cut -d : -f 1) + PORT=$(printf "%s\n" "$1"| cut -d : -f 2) + shift 1 + ;; + -q | --quiet) + QUIET=1 + shift 1 + ;; + -t) + TIMEOUT="$2" + if [ "$TIMEOUT" = "" ]; then break; fi + shift 2 + ;; + --timeout=*) + TIMEOUT="${1#*=}" + shift 1 + ;; + --) + shift + break + ;; + --help) + usage 0 + ;; + *) + echoerr "Unknown argument: $1" + usage 1 + ;; + esac +done + +if [ "$HOST" = "" -o "$PORT" = "" ]; then + echoerr "Error: you need to provide a host and port to test." + usage 2 +fi + +wait_for "$@" \ No newline at end of file From 6c30a5337612e3862b3139b0be80a8631bc2d589 Mon Sep 17 00:00:00 2001 From: Andreas Berthoud Date: Sat, 7 Aug 2021 14:22:13 +0200 Subject: [PATCH 4/5] backend: Add production docker-compose config --- backend/docker-compose-prod.yml | 38 +++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 backend/docker-compose-prod.yml diff --git a/backend/docker-compose-prod.yml b/backend/docker-compose-prod.yml new file mode 100644 index 0000000..d13d040 --- /dev/null +++ b/backend/docker-compose-prod.yml @@ -0,0 +1,38 @@ +version: "3" + +services: + monsun_postgres: + image: postgres + container_name: monsun_postgres + restart: always + logging: + driver: json-file + options: + max-size: "200k" + max-file: "10" + volumes: + - ./db-data:/var/lib/postgresql/data + env_file: prod.env + + monsun_backend: + image: registry.berthoud.dev/monsun_backend + container_name: monsun_backend + restart: always + logging: + driver: json-file + options: + max-size: "200k" + max-file: "10" + ports: + - 30010:80 + volumes: + - ./config:/var/config/ + depends_on: + - monsun_postgres + environment: + POSTGRES_HOST: monsun_postgres + env_file: prod.env + devices: + - "/dev/ttyACM0:/dev/tty.client" + + command: ["bash", "./wait-for-it.sh", "monsun_postgres:5432", "-t", "60", "--", "./docker-entrypoint.sh"] From f017ca20b2368953622c6cb0c4da6208d6a33443 Mon Sep 17 00:00:00 2001 From: Andreas Berthoud Date: Sat, 7 Aug 2021 14:25:15 +0200 Subject: [PATCH 5/5] Add setup-nas-env.sh --- backend/setup-nas-env.sh | 5 +++++ 1 file changed, 5 insertions(+) create mode 100755 backend/setup-nas-env.sh diff --git a/backend/setup-nas-env.sh b/backend/setup-nas-env.sh new file mode 100755 index 0000000..bfdd831 --- /dev/null +++ b/backend/setup-nas-env.sh @@ -0,0 +1,5 @@ +# Remove DB data and create clean folder +rm -rfd db-data/ && mkdir db-data + +# Enable RW for everyone +chmod 666 /dev/ttyACM0