From 3aae53781f43704eba9cbeb95a98dce497167701 Mon Sep 17 00:00:00 2001 From: DanielKusyDev Date: Fri, 22 May 2020 23:21:32 +0200 Subject: [PATCH 01/42] Test cryptograpgic elements lige key generation, pbkdf2 encryption and cipher comparisons. --- requirements.txt | 4 ++++ server.py | 36 +++++++++++------------------------- tests.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 25 deletions(-) create mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d72f455 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +cffi==1.14.0 +cryptography==2.9.2 +pycparser==2.20 +six==1.14.0 diff --git a/server.py b/server.py index 9a30a46..5faf2aa 100644 --- a/server.py +++ b/server.py @@ -1,7 +1,7 @@ import socket from message import Message -from utils import ProtonError +import utils from controllers import Controller @@ -15,12 +15,14 @@ def recv_all(self, sock): result += sock.recv(1).decode() return result - def dispatch(self, message): - action = message.action + def dispatch(self, raw_message): + message = Message(raw_message) + controller = Controller() try: - urlpatterns[action](message) - except KeyError as e: - print(e) + result = getattr(controller, message.action)(message) + except PermissionError as e: + self.send("Permission denied. Authorization required.") + return result def runserver(self): sock = socket.socket(family=socket.AF_INET, type=socket.SOCK_STREAM) @@ -31,26 +33,10 @@ def runserver(self): conn, addr = sock.accept() with conn: raw_message = self.recv_all(conn) - try: - message = Message(raw_message) - - except ProtonError as e: - str(e) - self.dispatch(message) - except socket.error as e: + request_result = self.dispatch(raw_message) + except (socket.error, utils.ProtonError) as e: print(e) if __name__ == "__main__": - s = Server() - r = """{ - "action": "update", - "params": { - "username": "daniel", - "password": "pass" - } - }""" - message = Message(r) - controller = Controller() - result = getattr(controller, message.action)(message) - print(result) \ No newline at end of file + s = Server() \ No newline at end of file diff --git a/tests.py b/tests.py index 765761e..24f7740 100644 --- a/tests.py +++ b/tests.py @@ -10,6 +10,37 @@ from message import Message +class CryptographyTestCase(unittest.TestCase): + + def setUp(self) -> None: + self.plain = "test123123" + + def test_key_generation(self): + key1 = crypto.generate_key() + key2 = crypto.generate_key() + self.assertEqual(key1, key2) + + def test_encryption(self): + cipher = crypto.encrypt(self.plain) + self.assertNotEqual(self.plain, cipher) + + cipher2 = crypto.encrypt(self.plain) + self.assertNotEqual(cipher, cipher2) + + def test_decryption(self): + cipher = crypto.encrypt(self.plain) + decrypted_cipher = crypto.decrypt(cipher) + self.assertEqual(decrypted_cipher, self.plain) + + cipher2 = crypto.encrypt(self.plain) + decrypted_cipher2 = crypto.decrypt(cipher2) + self.assertEqual(decrypted_cipher, decrypted_cipher2) + + def test_comparison(self): + cipher = crypto.encrypt(self.plain) + self.assertTrue(crypto.compare(self.plain, cipher)) + + class ProtonTestCase(unittest.TestCase): def setUp(self) -> None: From b3fbabae741c2c631fc9719b08d3bf05e93f93ec Mon Sep 17 00:00:00 2001 From: Daniel Kusy <36250676+DanielKusyDev@users.noreply.github.com> Date: Fri, 22 May 2020 23:23:54 +0200 Subject: [PATCH 02/42] Create python-app.yml Add basic CI actions. --- .github/workflows/python-app.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/python-app.yml diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml new file mode 100644 index 0000000..eaa682a --- /dev/null +++ b/.github/workflows/python-app.yml @@ -0,0 +1,29 @@ +# This workflow will install Python dependencies, run tests and lint with a single version of Python +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: Proton + +on: + push: + branches: [ master, develop ] + pull_request: + branches: [ master, develop ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python 3.8 + uses: actions/setup-python@v2 + with: + python-version: 3.8 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Test with pytest + run: | + python3 -m unittest tests.py From 10c089762968e83585816279043839d84f7001d7 Mon Sep 17 00:00:00 2001 From: DanielKusyDev Date: Fri, 22 May 2020 23:28:30 +0200 Subject: [PATCH 03/42] Add action to prepare tests to be executed --- .github/workflows/python-app.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index eaa682a..bef0c1a 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -24,6 +24,10 @@ jobs: run: | python -m pip install --upgrade pip if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + + -name: Prepare test environment + run: | + cp config_example.ini config_example.ini - name: Test with pytest run: | python3 -m unittest tests.py From 64050c33d68c3a0e635f7c769b7ee1f775b96b7d Mon Sep 17 00:00:00 2001 From: Daniel Kusy <36250676+DanielKusyDev@users.noreply.github.com> Date: Fri, 22 May 2020 23:29:42 +0200 Subject: [PATCH 04/42] Update python-app.yml --- .github/workflows/python-app.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index bef0c1a..1d974dc 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -25,8 +25,8 @@ jobs: python -m pip install --upgrade pip if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - -name: Prepare test environment - run: | + - name: Prepare test environment + run:| cp config_example.ini config_example.ini - name: Test with pytest run: | From 6f25f724046734a9e72defa4ea2f407b00d839e5 Mon Sep 17 00:00:00 2001 From: DanielKusyDev Date: Fri, 22 May 2020 23:32:40 +0200 Subject: [PATCH 05/42] Fixed typo in action yml --- .github/workflows/python-app.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index bef0c1a..95e176c 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -24,7 +24,6 @@ jobs: run: | python -m pip install --upgrade pip if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - -name: Prepare test environment run: | cp config_example.ini config_example.ini From ab48f2749e9a8f254863a9f16a436230bb50780c Mon Sep 17 00:00:00 2001 From: DanielKusyDev Date: Mon, 25 May 2020 18:32:09 +0200 Subject: [PATCH 06/42] Create feature of getting single post and create one --- assets/corgi.jpeg | Bin 0 -> 45881 bytes controllers.py | 47 +++++++++++++++++++++++++--------------------- create_db.sql | 14 ++++++-------- message.py | 7 ++++--- models.py | 2 +- requests.json | 16 ++++++++-------- tests.py | 31 ++++++++++++++++++++++++++++++ 7 files changed, 76 insertions(+), 41 deletions(-) create mode 100644 assets/corgi.jpeg diff --git a/assets/corgi.jpeg b/assets/corgi.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..eeef5c31230b1e1ec439b5e41ff7b0791f32d911 GIT binary patch literal 45881 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 literal 0 HcmV?d00001 diff --git a/controllers.py b/controllers.py index 8f16caf..3775b69 100644 --- a/controllers.py +++ b/controllers.py @@ -9,37 +9,37 @@ class Controller(object): def __init__(self, db_name="sqlite3.db"): self.db_name = db_name + self.post_model = models.Post(self.db_name) + self.user_model = models.User(self.db_name) + self.auth_model = models.AuthToken(self.db_name) def _get_token(self, user): user_id = user[0] - auth_token_model = models.AuthToken(self.db_name) - token = auth_token_model.first(user_id=user_id) + token = self.auth_model.first(user_id=user_id) if token: - token = auth_token_model.update(data={"expires": auth_token_model.get_fresh_expiration()}, - where={"user_id": user_id}) + token = self.auth_model.update(data={"expires": self.auth_model.get_fresh_expiration()}, + where={"user_id": user_id}) else: - token = auth_token_model.create(user_id=user_id) + token = self.auth_model.create(user_id=user_id) return token def register(self, message): params = message.params - user_model = models.User(self.db_name) - users = user_model.filter(username=params.get("username")) + users = self.user_model.filter(username=params.get("username")) if len(users) > 0: raise ProtonError("Given user already exists.") username = params.get("username") password = params.get("password") - user_model.create(username=username, password=password) - users = user_model.first(username=username) + self.user_model.create(username=username, password=password) + users = self.user_model.first(username=username) return users def login(self, message): params = message.params username = params["username"] password = params["password"] - user_model = models.User(self.db_name) - user = user_model.first(username=username) + user = self.user_model.first(username=username) if not user or not crypto.compare(password, user[2]): raise ProtonError("Incorrect username or/and password.") @@ -48,20 +48,25 @@ def login(self, message): @validate_auth def logout(self, message): - try: - token = message.opts["auth_token"] - except KeyError: - raise ProtonError - auth_model = models.AuthToken(self.db_name) - auth_model.delete(token=token) + token = message.opts["auth_token"] + self.auth_model.delete(token=token) @validate_auth - def get(self, message): - pass + def create(self, message): + user_id = self.auth_model.first(token=message.opts["auth_token"])[1] + post = self.post_model.create(user_id=user_id, **message.params) + return post @validate_auth - def create(self, message): - pass + def get(self, message): + + if getattr(message, "params", None) is not None and message.params.get("id", None) is not None: + post_id = message.params["id"] + if post_id is not None: + return self.post_model.first(id=post_id) + else: + return self.post_model.all() + @validate_auth def alter(self, message): diff --git a/create_db.sql b/create_db.sql index d5d4069..d60c042 100644 --- a/create_db.sql +++ b/create_db.sql @@ -3,7 +3,6 @@ drop table if exists authtoken; drop table if exists post; drop table if exists user; - create table user ( id integer not null @@ -30,13 +29,13 @@ create unique index authtoken_id_uindex create table post ( - id integer not null + id integer not null constraint post_pk primary key autoincrement, - image varchar(1024), - description varchar, - header varchar(1024), - user_id integer + image varchar, + content varchar, + title varchar(1024), + user_id integer references user on update cascade on delete cascade ); @@ -45,5 +44,4 @@ create unique index post_int_uindex on post (id); create unique index user_id_uindex - on user (id); - + on user (id); \ No newline at end of file diff --git a/message.py b/message.py index 127e564..63fcba9 100644 --- a/message.py +++ b/message.py @@ -11,7 +11,7 @@ def __init__(self, json_string): "login": ["username", "password"], "logout": None, "get": None, - "create": ["image", "content", "header"], + "create": ["content", "title"], "alter": ["id"], "delete": ["id"], } @@ -40,8 +40,9 @@ def get_action(self): def get_params(self): params = self.obj.get("params", None) if isinstance(params, dict): - for param in self.required_action_params[self.action]: - assert param in params.keys() + if self.required_action_params[self.action] is not None: + for param in self.required_action_params[self.action]: + assert param in params.keys() else: assert params is None assert self.required_action_params.get(self.action, None) is None diff --git a/models.py b/models.py index 23bdc54..c797fbc 100644 --- a/models.py +++ b/models.py @@ -87,7 +87,7 @@ def delete(self, **kwargs): class Post(Model): - fields = ["image", "description", "header", "user_id"] + fields = ["image", "content", "title", "user_id"] class User(Model): diff --git a/requests.json b/requests.json index 9c7fcca..0116899 100644 --- a/requests.json +++ b/requests.json @@ -20,26 +20,26 @@ } }, { - "action": "get", + "action": "create", + "params": { + "image": "data:image/jpeg;base64...", + "content": "Lorem ipsum...", + "title": "dolor sit amet" + }, "opts": { "auth_token": "gsF23!a4..." } }, { "action": "get", - "params": { - "id": 1 - }, "opts": { "auth_token": "gsF23!a4..." } }, { - "action": "create", + "action": "get", "params": { - "image": "data:image/jpeg;base64...", - "content": "Lorem ipsum...", - "header": "dolor sit amet" + "id": 1 }, "opts": { "auth_token": "gsF23!a4..." diff --git a/tests.py b/tests.py index 24f7740..b69c859 100644 --- a/tests.py +++ b/tests.py @@ -1,3 +1,4 @@ +import base64 import json import os import sqlite3 @@ -151,10 +152,23 @@ def test_opts(self): class ControllerTests(ProtonTestCase): + @classmethod + def setUpClass(cls) -> None: + with open("assets/corgi.jpeg", "rb") as img: + img_as_str = img.read() + cls.image_str = base64.b64encode(img_as_str).decode() + def setUp(self) -> None: super(ControllerTests, self).setUp() self.controller = Controller(self.db_name) + def _login(self, request, create_user=True): + if create_user: + self._request_action(self.requests[0]) + token = self._request_action(self.requests[1]) + request["opts"]["auth_token"] = token[2] + return request + def _request_action(self, request): raw_request = json.dumps(request) message = Message(raw_request) @@ -214,3 +228,20 @@ def test_proper_logout(self): logout_request = self.requests[2].copy() del logout_request["opts"]["auth_token"] self._request_action(logout_request) + + def _create_post(self): + request = self._login(self.requests[3]) + request["params"]["image"] = self.image_str + response = self._request_action(request) + return response + + def test_create_full_data_post(self): + response = self._create_post() + self.assertTrue(response) + + def test_getting_post_by_id(self): + post = self._create_post() + request = self._login(self.requests[5], False) + response = self._request_action(request) + self.assertIsInstance(response, tuple) + self.assertNotIsInstance(response[0], tuple) From 8deff5a46199ee686e4cc69dca4c0bfacd55eecd Mon Sep 17 00:00:00 2001 From: DanielKusyDev Date: Mon, 25 May 2020 18:53:35 +0200 Subject: [PATCH 07/42] Add ability to modify and delete posts --- controllers.py | 7 ++++--- models.py | 3 ++- requests.json | 14 +++++++------- tests.py | 33 ++++++++++++++++++++++++++++++--- 4 files changed, 43 insertions(+), 14 deletions(-) diff --git a/controllers.py b/controllers.py index 3775b69..2bf754b 100644 --- a/controllers.py +++ b/controllers.py @@ -67,11 +67,12 @@ def get(self, message): else: return self.post_model.all() - @validate_auth def alter(self, message): - pass + post_id = message.params.pop("id") + return self.post_model.update(data=message.params, where={"id": post_id}) @validate_auth def delete(self, message): - pass + post_id = message.params.pop("id") + return self.post_model.delete(id=post_id) diff --git a/models.py b/models.py index c797fbc..38387c4 100644 --- a/models.py +++ b/models.py @@ -80,10 +80,11 @@ def update(self, data: dict, where: dict): return self.first(**data) def delete(self, **kwargs): + obj = self.first(**kwargs) conditions = self.get_conditions(kwargs) sql = f"DELETE FROM {self.table_name} WHERE {conditions}" self.execute_sql(sql, kwargs) - return True + return obj class Post(Model): diff --git a/requests.json b/requests.json index 0116899..a62a350 100644 --- a/requests.json +++ b/requests.json @@ -46,21 +46,21 @@ } }, { - "action": "delete", + "action": "alter", "params": { - "id": 1 + "id": 1, + "image": "data:image/jpeg;base64", + "content": "consectetur adipiscing elit.", + "title": "Proin nibh augue" }, "opts": { "auth_token": "gsF23!a4..." } }, { - "action": "alter", + "action": "delete", "params": { - "id": 1, - "image": "data:image/jpeg;base64", - "content": "consectetur adipiscing elit.", - "header": "Proin nibh augue" + "id": 1 }, "opts": { "auth_token": "gsF23!a4..." diff --git a/tests.py b/tests.py index b69c859..f9d855f 100644 --- a/tests.py +++ b/tests.py @@ -229,8 +229,8 @@ def test_proper_logout(self): del logout_request["opts"]["auth_token"] self._request_action(logout_request) - def _create_post(self): - request = self._login(self.requests[3]) + def _create_post(self, create_user=True): + request = self._login(self.requests[3], create_user) request["params"]["image"] = self.image_str response = self._request_action(request) return response @@ -240,8 +240,35 @@ def test_create_full_data_post(self): self.assertTrue(response) def test_getting_post_by_id(self): - post = self._create_post() + self._create_post() request = self._login(self.requests[5], False) response = self._request_action(request) self.assertIsInstance(response, tuple) self.assertNotIsInstance(response[0], tuple) + + def test_getting_post(self): + self._create_post(True) + self._create_post(False) + request = self._login(self.requests[4], False) + response = self._request_action(request) + self.assertIsInstance(response, list) + self.assertEqual(len(response), 2) + self.assertIsInstance(response[0], tuple) + + def test_post_modify(self): + post = self._create_post() + request = self.requests[6] + title = "NEWTITLE" + request["params"]["title"] = title + request = self._login(request, False) + response = self._request_action(request) + self.assertIsInstance(response, tuple) + self.assertNotEqual(post[3], response[3]) + self.assertEqual(response[3], title) + + def test_post_deletion(self): + post = self._create_post() + request = self._login(self.requests[7], False) + response = self._request_action(request) + self.assertIsInstance(response, tuple) + self.assertListEqual(self.post_model.all(), []) From ea7e0c28f63031be86ff36973762f65a00e3a1eb Mon Sep 17 00:00:00 2001 From: Daniel Kusy <36250676+DanielKusyDev@users.noreply.github.com> Date: Mon, 25 May 2020 18:55:07 +0200 Subject: [PATCH 08/42] Update python-app.yml --- .github/workflows/python-app.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 3fcc037..f7a823c 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -25,8 +25,7 @@ jobs: python -m pip install --upgrade pip if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Prepare test environment - run:| - cp config_example.ini config_example.ini + run: cp config_example.ini config_example.ini - name: Test with pytest run: | python3 -m unittest tests.py From a95ca9afb8bd8969236dd96237ee4d970e9aae17 Mon Sep 17 00:00:00 2001 From: Daniel Kusy <36250676+DanielKusyDev@users.noreply.github.com> Date: Mon, 25 May 2020 18:57:49 +0200 Subject: [PATCH 09/42] Update python-app.yml --- .github/workflows/python-app.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index f7a823c..a647ba9 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -25,7 +25,7 @@ jobs: python -m pip install --upgrade pip if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Prepare test environment - run: cp config_example.ini config_example.ini + run: cp config_example.ini config.ini - name: Test with pytest run: | python3 -m unittest tests.py From e29de1836d20771e70f8adac872842f053f466eb Mon Sep 17 00:00:00 2001 From: DanielKusyDev Date: Mon, 25 May 2020 19:57:26 +0200 Subject: [PATCH 10/42] Make server run asynchronous by usage of event based request handlers. --- server.py | 117 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 100 insertions(+), 17 deletions(-) diff --git a/server.py b/server.py index 5faf2aa..83fac04 100644 --- a/server.py +++ b/server.py @@ -1,4 +1,5 @@ -import socket +import socket, select +import queue from message import Message import utils @@ -9,34 +10,116 @@ class Server(object): def __init__(self, address=("127.0.0.1", 2553)): self.address = address - def recv_all(self, sock): + @staticmethod + def recv_all(sock): result = "" while result[-2:] != "\r\n": result += sock.recv(1).decode() return result - def dispatch(self, raw_message): + @staticmethod + def send(sock, message): + if isinstance(message, str): + message = message.encode() + sock.sendall(message) + + def dispatch(self, sock, raw_message): message = Message(raw_message) controller = Controller() try: result = getattr(controller, message.action)(message) + # if isinstance(result, tuple): + except PermissionError as e: - self.send("Permission denied. Authorization required.") - return result + result = "Permission denied. Authorization required." + finally: + return result + + def _process_writable_connections(self, writable, output_conns, message_queue): + for sock in writable: + try: + message = message_queue[sock].get() + except queue.Empty: + output_conns.remove(sock) + raise queue.Empty + try: + self.dispatch(message) + e + return writable, output_conns, message_queue + + @staticmethod + def _process_exceptional_connections(exceptional, input_conns, output_conns, message_queue): + for sock in exceptional: + input_conns.remove(sock) + if sock in output_conns: + output_conns.remove(sock) + sock.close() + del message_queue[sock] + return exceptional, input_conns, output_conns, message_queue + + @staticmethod + def _process_input_connections(server_socket, readable, input_conns, output_conns, message_queue): + for sock in readable: + if sock is server_socket: + conn, c_addr = sock.accept() + conn.setblocking(0) + input_conns.append(conn) + message_queue[conn] = queue.Queue() + else: + message = sock.recv(1024) + if message: + message_queue[sock].put(message) + if sock not in output_conns: + output_conns.append(sock) + + else: + if sock in output_conns: + output_conns.remove(sock) + input_conns.remove(sock) + sock.close() + del message_queue[sock] + return server_socket, readable, input_conns, output_conns, message_queue def runserver(self): - sock = socket.socket(family=socket.AF_INET, type=socket.SOCK_STREAM) - sock.bind(self.address) - sock.listen(10) - try: - while True: - conn, addr = sock.accept() - with conn: - raw_message = self.recv_all(conn) - request_result = self.dispatch(raw_message) - except (socket.error, utils.ProtonError) as e: - print(e) + server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server_socket.setblocking(False) + server_socket.bind(addr) + server_socket.listen() + + input_conns = [server_socket] + output_conns = [] + + message_queue = {} + while input_conns: + readable, writable, exceptional = select.select(input_conns, output_conns, input_conns) + + server_socket, readable, input_conns, output_conns, message_queue = self._process_input_connections( + server_socket, readable, input_conns, + output_conns, message_queue) + writable, output_conns, message_queue = self._process_writable_connections(writable, output_conns, + message_queue) + exceptional, input_conns, output_conns, message_queue = self._process_exceptional_connections(exceptional, + input_conns, + output_conns, + message_queue) + server_socket.close() + # sock.bind(self.address) + # sock.listen(10) + # try: + # while True: + # conn, addr = sock.accept() + # with conn: + # raw_message = self.recv_all(conn) + # request_result = self.dispatch(raw_message) + # except (socket.error, utils.ProtonError) as e: + # print(e) + + +# if __name__ == "__main__": - s = Server() \ No newline at end of file + s = Server() + + addr = ("127.0.0.1", 6666) From c2d13fb00241af36b38b46003adc0d78ffa76caf Mon Sep 17 00:00:00 2001 From: DanielKusyDev Date: Mon, 1 Jun 2020 17:46:58 +0200 Subject: [PATCH 11/42] Create Response class to store result of controller call --- controllers.py | 60 +++++++++++++++++++++++++++++++++++++++----------- models.py | 18 +++++++++++++++ responses.json | 33 +++++++++++++++++++++++++++ server.py | 25 ++++++--------------- tests.py | 47 +++++++++++++++++++-------------------- 5 files changed, 128 insertions(+), 55 deletions(-) create mode 100644 responses.json diff --git a/controllers.py b/controllers.py index 2bf754b..4d696c9 100644 --- a/controllers.py +++ b/controllers.py @@ -1,10 +1,39 @@ import datetime +from typing import Union import models import crypto from utils import validate_auth, ProtonError +class Response(object): + def __init__(self, status, message=None): + self.message = message + self.status = status.upper() == "OK" + + +class ModelResponse(Response): + def __init__(self, status, model, raw_instance: Union[list, tuple], message=None): + super(ModelResponse, self).__init__(status=status, message=message) + if not isinstance(model, models.Model): + model = model() + if not isinstance(raw_instance[0], tuple): + raw_instance = [raw_instance] + + self.model = model + self.raw_instance = raw_instance + self.message = message + self.data = self.create_data() + + def create_data(self): + table_schema = self.model.get_table_cols() + data = [] + for instance in self.raw_instance: + single_obj_data = {col_name: val for col_name, val in zip(table_schema, instance)} + data.append(single_obj_data) + return data + + class Controller(object): def __init__(self, db_name="sqlite3.db"): @@ -13,8 +42,7 @@ def __init__(self, db_name="sqlite3.db"): self.user_model = models.User(self.db_name) self.auth_model = models.AuthToken(self.db_name) - def _get_token(self, user): - user_id = user[0] + def _get_token(self, user_id): token = self.auth_model.first(user_id=user_id) if token: token = self.auth_model.update(data={"expires": self.auth_model.get_fresh_expiration()}, @@ -27,13 +55,13 @@ def register(self, message): params = message.params users = self.user_model.filter(username=params.get("username")) if len(users) > 0: - raise ProtonError("Given user already exists.") + return Response(status="ERROR", message="Given user already exists.") username = params.get("username") password = params.get("password") self.user_model.create(username=username, password=password) users = self.user_model.first(username=username) - return users + return ModelResponse("OK", self.user_model, users) def login(self, message): params = message.params @@ -41,38 +69,44 @@ def login(self, message): password = params["password"] user = self.user_model.first(username=username) if not user or not crypto.compare(password, user[2]): - raise ProtonError("Incorrect username or/and password.") + return Response(status="ERROR", message="Incorrect username or/and password.") - token = self._get_token(user) - return token + token = self._get_token(user[0]) + return ModelResponse("OK", self.auth_model, token) @validate_auth def logout(self, message): token = message.opts["auth_token"] self.auth_model.delete(token=token) + return Response("OK") @validate_auth def create(self, message): user_id = self.auth_model.first(token=message.opts["auth_token"])[1] post = self.post_model.create(user_id=user_id, **message.params) - return post + return ModelResponse(status="OK", model=self.post_model, raw_instance=post, + message="Post created successfully.") @validate_auth def get(self, message): - + instance = None if getattr(message, "params", None) is not None and message.params.get("id", None) is not None: post_id = message.params["id"] if post_id is not None: - return self.post_model.first(id=post_id) + instance = self.post_model.first(id=post_id) else: - return self.post_model.all() + instance = self.post_model.all() + + return ModelResponse("OK", self.post_model, raw_instance=instance) @validate_auth def alter(self, message): post_id = message.params.pop("id") - return self.post_model.update(data=message.params, where={"id": post_id}) + instance = self.post_model.update(data=message.params, where={"id": post_id}) + return ModelResponse("OK", self.post_model, instance) @validate_auth def delete(self, message): post_id = message.params.pop("id") - return self.post_model.delete(id=post_id) + self.post_model.delete(id=post_id) + return Response("OK") diff --git a/models.py b/models.py index 38387c4..8c43505 100644 --- a/models.py +++ b/models.py @@ -22,6 +22,24 @@ def __del__(self): def get_fields(self): return ",".join(self.fields) + def get_table_cols(self): + sql = f"PRAGMA table_info({self.table_name})" + cursor = self.conn.cursor() + cursor.execute(sql) + raw_cols = cursor.fetchall() + + def map_col_type(col): + c_type = col[2].lower() + if "integer" in c_type: + return c_type + elif "char" in c_type: + return str + elif "datetime" in c_type: + return datetime.timedelta + + cols = {col[1]: map_col_type(col) for col in raw_cols} + return cols + def get_conditions(self, filters): conditions = [f"{key}=:{key}" for key, val in filters.items()] conditions = f" and ".join(conditions) diff --git a/responses.json b/responses.json new file mode 100644 index 0000000..b0bc59e --- /dev/null +++ b/responses.json @@ -0,0 +1,33 @@ +[ + { + "status": "ERROR", + "message": "Lorem ipsum dolor sit amet..." + }, + { + "status": "OK", + "message": "Lorem ipsum dolor sit amet...", + "data": [ + { + "id": 1, + "image": "...", + "content": "Lorem ipsum dolor sit amet...", + "title": "Lorem ipsum dolor sit amet...", + "user_id": 1 + }, + { + "id": 2, + "image": "...", + "content": "Lorem ipsum dolor sit amet...", + "title": "Lorem ipsum dolor sit amet...", + "user_id": 2 + }, + { + "id": 3, + "image": "...", + "content": "Lorem ipsum dolor sit amet...", + "title": "Lorem ipsum dolor sit amet...", + "user_id": 3 + } + ] + } +] \ No newline at end of file diff --git a/server.py b/server.py index 83fac04..3fc673c 100644 --- a/server.py +++ b/server.py @@ -23,7 +23,7 @@ def send(sock, message): message = message.encode() sock.sendall(message) - def dispatch(self, sock, raw_message): + def dispatch(self, raw_message): message = Message(raw_message) controller = Controller() try: @@ -42,9 +42,12 @@ def _process_writable_connections(self, writable, output_conns, message_queue): except queue.Empty: output_conns.remove(sock) raise queue.Empty - try: - self.dispatch(message) - e + + # try: + self.dispatch(sock, message) + # except utils.ProtonError as e: + # self.send() + return writable, output_conns, message_queue @staticmethod @@ -105,21 +108,7 @@ def runserver(self): message_queue) server_socket.close() - # sock.bind(self.address) - # sock.listen(10) - # try: - # while True: - # conn, addr = sock.accept() - # with conn: - # raw_message = self.recv_all(conn) - # request_result = self.dispatch(raw_message) - # except (socket.error, utils.ProtonError) as e: - # print(e) - - -# if __name__ == "__main__": s = Server() - addr = ("127.0.0.1", 6666) diff --git a/tests.py b/tests.py index f9d855f..173f312 100644 --- a/tests.py +++ b/tests.py @@ -7,7 +7,7 @@ import crypto import models import utils -from controllers import Controller +from controllers import Controller, Response, ModelResponse from message import Message @@ -166,7 +166,7 @@ def _login(self, request, create_user=True): if create_user: self._request_action(self.requests[0]) token = self._request_action(self.requests[1]) - request["opts"]["auth_token"] = token[2] + request["opts"]["auth_token"] = token.data[0]["token"] return request def _request_action(self, request): @@ -184,19 +184,19 @@ def test_register(self): request = self.requests[0] number_of_users = len(self.user_model.all()) result = self._request_action(request) - self.assertIsInstance(result, tuple) - self.assertGreater(len(result), number_of_users) - self.assertEqual(request["params"]["username"], result[1]) - self.assertNotEqual(request["params"]["password"], result[2]) + self.assertIsInstance(result, ModelResponse) + self.assertGreater(len(result.data), number_of_users) + self.assertEqual(request["params"]["username"], result.data[0]["username"]) + self.assertNotEqual(request["params"]["password"], result.data[0]["username"]) def test_getting_token(self): user = self._request_action(self.requests[0]) - token = self.controller._get_token(user) + token = self.controller._get_token(user.data[0]["id"]) self.assertIsInstance(token, tuple) self._request_action(self.requests[1]) - token = self.controller._get_token(user) + token = self.controller._get_token(user.data[0]["id"]) self.assertIsInstance(token, tuple) def test_login(self): @@ -204,24 +204,24 @@ def test_login(self): request = self.requests[1].copy() # check valid login result = self._request_action(request) - self.assertIsInstance(result, tuple) - is_valid = self.auth_token_model.is_valid(user_id=user[0]) + self.assertIsInstance(result, ModelResponse) + is_valid = self.auth_token_model.is_valid(user_id=user.data[0]["id"]) self.assertTrue(is_valid) # check invalid login data request["params"]["username"] = "wrongusername" - with self.assertRaises(utils.ProtonError): - result = self._request_action(request) + result = self._request_action(request) + self.assertFalse(result.status) def test_proper_logout(self): user = self._request_action(self.requests[0]) token = self._request_action(self.requests[1]) logout_request = self.requests[2].copy() - logout_request["opts"]["auth_token"] = token[2] + logout_request["opts"]["auth_token"] = token.data[0]["token"] # check if token does not exist anymore self._request_action(logout_request) - self.assertIsNone(self.auth_token_model.first(user_id=user[0])) + self.assertIsNone(self.auth_token_model.first(user_id=user.data[0]["id"])) # test attempt of providing invalid token and lack of token in opts field with self.assertRaises(PermissionError): self._request_action(logout_request) @@ -237,23 +237,22 @@ def _create_post(self, create_user=True): def test_create_full_data_post(self): response = self._create_post() - self.assertTrue(response) + self.assertTrue(response.status) def test_getting_post_by_id(self): self._create_post() request = self._login(self.requests[5], False) response = self._request_action(request) - self.assertIsInstance(response, tuple) - self.assertNotIsInstance(response[0], tuple) + self.assertIsInstance(response, ModelResponse) + self.assertTrue(response.status) def test_getting_post(self): self._create_post(True) self._create_post(False) request = self._login(self.requests[4], False) response = self._request_action(request) - self.assertIsInstance(response, list) - self.assertEqual(len(response), 2) - self.assertIsInstance(response[0], tuple) + self.assertIsInstance(response, ModelResponse) + self.assertEqual(len(response.data), 2) def test_post_modify(self): post = self._create_post() @@ -262,13 +261,13 @@ def test_post_modify(self): request["params"]["title"] = title request = self._login(request, False) response = self._request_action(request) - self.assertIsInstance(response, tuple) - self.assertNotEqual(post[3], response[3]) - self.assertEqual(response[3], title) + self.assertIsInstance(response, ModelResponse) + self.assertNotEqual(post.data[0]["title"], response.data[0]["title"]) + self.assertEqual(title, response.data[0]["title"]) def test_post_deletion(self): post = self._create_post() request = self._login(self.requests[7], False) response = self._request_action(request) - self.assertIsInstance(response, tuple) + self.assertIsInstance(response, Response) self.assertListEqual(self.post_model.all(), []) From 619a88a041b11d102f8b49320eb5d8c5b2bd51ad Mon Sep 17 00:00:00 2001 From: DanielKusyDev Date: Mon, 1 Jun 2020 19:00:31 +0200 Subject: [PATCH 12/42] Create logger class, implement logging events and rewrite non blocking queues. --- .gitignore | 1 + server.py | 161 +++++++++++++++++++++++++++++------------------------ 2 files changed, 90 insertions(+), 72 deletions(-) diff --git a/.gitignore b/.gitignore index 1a478d1..0fed8e5 100644 --- a/.gitignore +++ b/.gitignore @@ -131,3 +131,4 @@ dmypy.json .idea sqlite3.db config.ini +.cert \ No newline at end of file diff --git a/server.py b/server.py index 3fc673c..db535ff 100644 --- a/server.py +++ b/server.py @@ -1,14 +1,62 @@ +import os import socket, select import queue +import ssl +from datetime import datetime from message import Message import utils from controllers import Controller +class Logger(object): + def __init__(self, log_dir="logs", max_log_dir_size=5 * 10 ** 6): + self.log_dir = log_dir + self.log_template = "[%d/%b/%Y %H:%M:%S] {message}" + self.max_log_dir_size = max_log_dir_size + self.filename_prefix = "proton_std" + + def get_log_filename(self): + if not os.path.exists(self.log_dir): + os.mkdir(self.log_dir) + all_log_files = sorted(filter(lambda path: self.filename_prefix in path, os.listdir(self.log_dir))) + if not all_log_files: + filename = f"{self.log_dir}/{self.filename_prefix}.log" + else: + last_file = all_log_files[-1] + if os.stat(last_file).st_size < self.max_log_dir_size: + filename = last_file + else: + last_file_name_without_ext, _ = last_file.split(".") + try: + file_number = int(last_file_name_without_ext[-1]) + except ValueError: + file_number = 1 + filename = f"{self.log_dir}/{self.filename_prefix}{file_number}.log" + return filename + + def _get_message(self, message): + now = datetime.now() + log_without_date = self.log_template.format(message=message) + full_log = now.strftime(log_without_date) + return full_log + + def write(self, message): + filename = self.get_log_filename() + with open(filename, "a") as file: + log = self._get_message(message) + file.write(log) + + class Server(object): def __init__(self, address=("127.0.0.1", 2553)): + self.logger = Logger() self.address = address + self.inputs = [] + self.outputs = [] + self.message_queue = {} + self.server_socket = None + @staticmethod def recv_all(sock): @@ -23,90 +71,59 @@ def send(sock, message): message = message.encode() sock.sendall(message) - def dispatch(self, raw_message): - message = Message(raw_message) - controller = Controller() - try: - result = getattr(controller, message.action)(message) - # if isinstance(result, tuple): - - except PermissionError as e: - result = "Permission denied. Authorization required." - finally: - return result - - def _process_writable_connections(self, writable, output_conns, message_queue): - for sock in writable: - try: - message = message_queue[sock].get() - except queue.Empty: - output_conns.remove(sock) - raise queue.Empty - - # try: - self.dispatch(sock, message) - # except utils.ProtonError as e: - # self.send() - - return writable, output_conns, message_queue - - @staticmethod - def _process_exceptional_connections(exceptional, input_conns, output_conns, message_queue): - for sock in exceptional: - input_conns.remove(sock) - if sock in output_conns: - output_conns.remove(sock) - sock.close() - del message_queue[sock] - return exceptional, input_conns, output_conns, message_queue - - @staticmethod - def _process_input_connections(server_socket, readable, input_conns, output_conns, message_queue): - for sock in readable: - if sock is server_socket: + def get_secure_socket(self, raw_socket): + raw_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + raw_socket.setblocking(False) + raw_socket.bind(addr) + raw_socket.listen() + + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.verify_mode = ssl.CERT_REQUIRED + context.load_cert_chain(certfile=".cert/server.pem", keyfile=".cert/server.key") + + if ssl.HAS_SNI: + secure_socket = context.wrap_socket(raw_socket, server_side=True) + else: + secure_socket = context.wrap_socket(raw_socket, server_side=True) + return secure_socket + + def read_connections(self, connections): + for sock in connections: + if sock is self.server_socket: conn, c_addr = sock.accept() conn.setblocking(0) - input_conns.append(conn) - message_queue[conn] = queue.Queue() + self.inputs.append(conn) + self.message_queue[conn] = queue.Queue() + self.logger.write(f"Connected by {c_addr}") + else: message = sock.recv(1024) if message: message_queue[sock].put(message) - if sock not in output_conns: - output_conns.append(sock) + if sock not in outputs: + outputs.append(sock) else: - if sock in output_conns: - output_conns.remove(sock) - input_conns.remove(sock) + if sock in outputs: + outputs.remove(sock) + inputs.remove(sock) sock.close() del message_queue[sock] - return server_socket, readable, input_conns, output_conns, message_queue def runserver(self): - server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - server_socket.setblocking(False) - server_socket.bind(addr) - server_socket.listen() - - input_conns = [server_socket] - output_conns = [] - - message_queue = {} - while input_conns: - readable, writable, exceptional = select.select(input_conns, output_conns, input_conns) - - server_socket, readable, input_conns, output_conns, message_queue = self._process_input_connections( - server_socket, readable, input_conns, - output_conns, message_queue) - writable, output_conns, message_queue = self._process_writable_connections(writable, output_conns, - message_queue) - exceptional, input_conns, output_conns, message_queue = self._process_exceptional_connections(exceptional, - input_conns, - output_conns, - message_queue) - server_socket.close() + raw_socket = socket.socket(family=socket.AF_INET, type=socket.SOCK_STREAM) + try: + server_socket = self.get_secure_socket(raw_socket) + self.inputs = [server_socket] + self.server_socket = server_socket + readable, writable, _ = select.select(self.inputs, self.outputs, []) + + + + except socket.error as e: + raise e + finally: + raw_socket.close() if __name__ == "__main__": From 8190bba8d1586ec0e49ef0ca9fcad4d75787655a Mon Sep 17 00:00:00 2001 From: DanielKusyDev Date: Wed, 3 Jun 2020 19:03:23 +0200 Subject: [PATCH 13/42] Do solid refactor. Make better organized structure. --- .gitignore | 3 +- crypto.py => backend/crypto.py | 3 +- backend/server.py | 109 +++++++++++++++++++++ controllers.py => core/controllers.py | 40 ++------ message.py => core/message.py | 0 models.py => core/models.py | 2 +- core/response.py | 34 +++++++ server.py | 131 -------------------------- tests.py | 17 ++-- utils.py | 45 ++++++++- 10 files changed, 206 insertions(+), 178 deletions(-) rename crypto.py => backend/crypto.py (97%) create mode 100644 backend/server.py rename controllers.py => core/controllers.py (73%) rename message.py => core/message.py (100%) rename models.py => core/models.py (99%) create mode 100644 core/response.py delete mode 100644 server.py diff --git a/.gitignore b/.gitignore index 0fed8e5..d648ad9 100644 --- a/.gitignore +++ b/.gitignore @@ -131,4 +131,5 @@ dmypy.json .idea sqlite3.db config.ini -.cert \ No newline at end of file +.cert +test_client.py diff --git a/crypto.py b/backend/crypto.py similarity index 97% rename from crypto.py rename to backend/crypto.py index 7117b48..1d8e98e 100644 --- a/crypto.py +++ b/backend/crypto.py @@ -40,4 +40,5 @@ def decrypt(encrypted_message): encrypted_message = encrypted_message.encode() f = Fernet(key) message = f.decrypt(encrypted_message) - return message.decode() \ No newline at end of file + return message.decode() + diff --git a/backend/server.py b/backend/server.py new file mode 100644 index 0000000..bf657d7 --- /dev/null +++ b/backend/server.py @@ -0,0 +1,109 @@ +import queue +import select +import socket +import ssl +from time import sleep + +from utils import Logger + + +class ReadWriteSocketController(object): + def __init__(self): + self.logger = Logger() + self.inputs = [] + self.outputs = [] + self.message_queue = {} + + @staticmethod + def recv_all(sock): + result = "" + while result[-2:] != "\r\n": + result += sock.read(1).decode() + return result + + @staticmethod + def send(sock, message): + if isinstance(message, str): + message = message.encode() + sock.write(message) + + def read_connections(self, connections, server_socket): + for sock in connections: + try: + if sock is server_socket: + conn, c_addr = sock.accept() + conn.setblocking(0) + self.inputs.append(conn) + self.message_queue[conn] = queue.Queue() + self.logger.write(f"Connected by {c_addr}") + else: + message = self.recv_all(sock) + if message: + self.message_queue[sock].put(message) + if sock not in self.outputs: + self.outputs.append(sock) + else: + if sock in self.outputs: + self.outputs.remove(sock) + self.inputs.remove(sock) + sock.close() + del self.message_queue[sock] + except ssl.SSLWantReadError: + self.send(sock, "") + + def write_to_connections(self, connections): + for sock in connections: + try: + message = self.message_queue[sock].get_nowait() + sleep(10) + except queue.Empty: + self.outputs.remove(sock) + else: + sock.sendall(message) + + def read_or_write(self, server_socket): + while self.inputs: + readable, writable, _ = select.select(self.inputs, self.outputs, []) + self.read_connections(connections=readable, server_socket=server_socket) + self.write_to_connections(connections=writable) + + +class Server(object): + def __init__(self, address=("127.0.0.1", 6666)): + self.logger = Logger() + self.address = address + + def get_secure_socket(self, raw_socket): + context = ssl.SSLContext(ssl.PROTOCOL_TLSv1) + context.load_cert_chain(certfile="/etc/ssl/certs/proton.pem") + secure_socket = context.wrap_socket(raw_socket, server_side=True) + return secure_socket + + def get_raw_socket(self, raw_socket): + raw_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + raw_socket.setblocking(False) + raw_socket.bind(self.address) + raw_socket.listen(5) + return raw_socket + + def runserver(self): + self.logger.write(f"Starting server at {self.address[0]}:{self.address[1]}") + raw_socket = socket.socket(family=socket.AF_INET, type=socket.SOCK_STREAM) + rw_controller = ReadWriteSocketController() + + try: + raw_socket = self.get_raw_socket(raw_socket) + server_socket = self.get_secure_socket(raw_socket) + rw_controller.inputs = [server_socket] + rw_controller.read_or_write(server_socket) + + except socket.error as e: + print("todo") + raise e + finally: + raw_socket.close() + + +if __name__ == "__main__": + s = Server(("localhost", 6666)) + s.runserver() diff --git a/controllers.py b/core/controllers.py similarity index 73% rename from controllers.py rename to core/controllers.py index 4d696c9..c79148f 100644 --- a/controllers.py +++ b/core/controllers.py @@ -1,37 +1,9 @@ -import datetime -from typing import Union - -import models -import crypto -from utils import validate_auth, ProtonError - - -class Response(object): - def __init__(self, status, message=None): - self.message = message - self.status = status.upper() == "OK" - - -class ModelResponse(Response): - def __init__(self, status, model, raw_instance: Union[list, tuple], message=None): - super(ModelResponse, self).__init__(status=status, message=message) - if not isinstance(model, models.Model): - model = model() - if not isinstance(raw_instance[0], tuple): - raw_instance = [raw_instance] - - self.model = model - self.raw_instance = raw_instance - self.message = message - self.data = self.create_data() - - def create_data(self): - table_schema = self.model.get_table_cols() - data = [] - for instance in self.raw_instance: - single_obj_data = {col_name: val for col_name, val in zip(table_schema, instance)} - data.append(single_obj_data) - return data +from core import models +from backend import crypto +from utils import validate_auth + + + class Controller(object): diff --git a/message.py b/core/message.py similarity index 100% rename from message.py rename to core/message.py diff --git a/models.py b/core/models.py similarity index 99% rename from models.py rename to core/models.py index 8c43505..4687a60 100644 --- a/models.py +++ b/core/models.py @@ -3,7 +3,7 @@ import abc from time import strptime -import crypto +from backend import crypto import settings import utils diff --git a/core/response.py b/core/response.py new file mode 100644 index 0000000..d6e410a --- /dev/null +++ b/core/response.py @@ -0,0 +1,34 @@ +from typing import Union + +from core import models + + +class Response(object): + def __init__(self, status, message=None): + self.message = message + self.status = status.upper() == "OK" + + +class ModelResponse(Response): + def __init__(self, status, model, raw_instance: Union[list, tuple], message=None): + super(ModelResponse, self).__init__(status=status, message=message) + if not isinstance(model, models.Model): + model = model() + if not isinstance(raw_instance[0], tuple): + raw_instance = [raw_instance] + + self.model = model + self.raw_instance = raw_instance + self.message = message + self.data = self.create_data() + + def __str__(self): + return "xd" + + def create_data(self): + table_schema = self.model.get_table_cols() + data = [] + for instance in self.raw_instance: + single_obj_data = {col_name: val for col_name, val in zip(table_schema, instance)} + data.append(single_obj_data) + return data diff --git a/server.py b/server.py deleted file mode 100644 index db535ff..0000000 --- a/server.py +++ /dev/null @@ -1,131 +0,0 @@ -import os -import socket, select -import queue -import ssl -from datetime import datetime - -from message import Message -import utils -from controllers import Controller - - -class Logger(object): - def __init__(self, log_dir="logs", max_log_dir_size=5 * 10 ** 6): - self.log_dir = log_dir - self.log_template = "[%d/%b/%Y %H:%M:%S] {message}" - self.max_log_dir_size = max_log_dir_size - self.filename_prefix = "proton_std" - - def get_log_filename(self): - if not os.path.exists(self.log_dir): - os.mkdir(self.log_dir) - all_log_files = sorted(filter(lambda path: self.filename_prefix in path, os.listdir(self.log_dir))) - if not all_log_files: - filename = f"{self.log_dir}/{self.filename_prefix}.log" - else: - last_file = all_log_files[-1] - if os.stat(last_file).st_size < self.max_log_dir_size: - filename = last_file - else: - last_file_name_without_ext, _ = last_file.split(".") - try: - file_number = int(last_file_name_without_ext[-1]) - except ValueError: - file_number = 1 - filename = f"{self.log_dir}/{self.filename_prefix}{file_number}.log" - return filename - - def _get_message(self, message): - now = datetime.now() - log_without_date = self.log_template.format(message=message) - full_log = now.strftime(log_without_date) - return full_log - - def write(self, message): - filename = self.get_log_filename() - with open(filename, "a") as file: - log = self._get_message(message) - file.write(log) - - -class Server(object): - def __init__(self, address=("127.0.0.1", 2553)): - self.logger = Logger() - self.address = address - self.inputs = [] - self.outputs = [] - self.message_queue = {} - self.server_socket = None - - - @staticmethod - def recv_all(sock): - result = "" - while result[-2:] != "\r\n": - result += sock.recv(1).decode() - return result - - @staticmethod - def send(sock, message): - if isinstance(message, str): - message = message.encode() - sock.sendall(message) - - def get_secure_socket(self, raw_socket): - raw_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - raw_socket.setblocking(False) - raw_socket.bind(addr) - raw_socket.listen() - - context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) - context.verify_mode = ssl.CERT_REQUIRED - context.load_cert_chain(certfile=".cert/server.pem", keyfile=".cert/server.key") - - if ssl.HAS_SNI: - secure_socket = context.wrap_socket(raw_socket, server_side=True) - else: - secure_socket = context.wrap_socket(raw_socket, server_side=True) - return secure_socket - - def read_connections(self, connections): - for sock in connections: - if sock is self.server_socket: - conn, c_addr = sock.accept() - conn.setblocking(0) - self.inputs.append(conn) - self.message_queue[conn] = queue.Queue() - self.logger.write(f"Connected by {c_addr}") - - else: - message = sock.recv(1024) - if message: - message_queue[sock].put(message) - if sock not in outputs: - outputs.append(sock) - - else: - if sock in outputs: - outputs.remove(sock) - inputs.remove(sock) - sock.close() - del message_queue[sock] - - def runserver(self): - raw_socket = socket.socket(family=socket.AF_INET, type=socket.SOCK_STREAM) - try: - server_socket = self.get_secure_socket(raw_socket) - self.inputs = [server_socket] - self.server_socket = server_socket - readable, writable, _ = select.select(self.inputs, self.outputs, []) - - - - except socket.error as e: - raise e - finally: - raw_socket.close() - - -if __name__ == "__main__": - s = Server() - addr = ("127.0.0.1", 6666) diff --git a/tests.py b/tests.py index 173f312..8f7a198 100644 --- a/tests.py +++ b/tests.py @@ -1,14 +1,15 @@ +import abc import base64 import json import os import sqlite3 import unittest -import crypto -import models +from backend import crypto +from core import models import utils -from controllers import Controller, Response, ModelResponse -from message import Message +from core.controllers import Controller, Response, ModelResponse +from core.message import Message class CryptographyTestCase(unittest.TestCase): @@ -42,7 +43,7 @@ def test_comparison(self): self.assertTrue(crypto.compare(self.plain, cipher)) -class ProtonTestCase(unittest.TestCase): +class BaseControllerTest(unittest.TestCase, metaclass=abc.ABCMeta): def setUp(self) -> None: self.db_name = "test.db" @@ -57,7 +58,7 @@ def tearDown(self) -> None: os.remove(self.db_name) -class ModelTests(ProtonTestCase): +class ModelTests(BaseControllerTest): def setUp(self) -> None: super(ModelTests, self).setUp() self.user_data = { @@ -108,7 +109,7 @@ def test_auth_token_creation(self): is_valid = self.auth_token_model.is_valid(user_id=123123123) -class MessageTests(ProtonTestCase): +class MessageTests(BaseControllerTest): def setUp(self) -> None: super(MessageTests, self).setUp() @@ -150,7 +151,7 @@ def test_opts(self): self.assertIsInstance(self.message.get_opts(), dict) -class ControllerTests(ProtonTestCase): +class ControllerTests(BaseControllerTest): @classmethod def setUpClass(cls) -> None: diff --git a/utils.py b/utils.py index 046210a..89f1c7e 100644 --- a/utils.py +++ b/utils.py @@ -1,9 +1,10 @@ -import random +import os import secrets import sqlite3 import string +from datetime import datetime -import models +from core import models class ProtonError(BaseException): @@ -43,3 +44,43 @@ def create_db(db_name="sqlite3.db"): cursor = conn.cursor() with open("create_db.sql", "r") as script: cursor.executescript(script.read()) + + +class Logger(object): + def __init__(self, log_dir="logs", max_log_dir_size=5 * 10 ** 6): + self.log_dir = log_dir + self.log_template = "[%d/%b/%Y %H:%M:%S] {message}" + self.max_log_dir_size = max_log_dir_size + self.filename_prefix = "proton_std" + + def get_log_filename(self): + if not os.path.exists(self.log_dir): + os.mkdir(self.log_dir) + all_log_files = sorted(filter(lambda path: self.filename_prefix in path, os.listdir(self.log_dir))) + if not all_log_files: + filename = f"{self.log_dir}/{self.filename_prefix}.log" + else: + last_file = f"{self.log_dir}/{all_log_files[-1]}" + if os.stat(last_file).st_size < self.max_log_dir_size: + filename = last_file + else: + last_file_name_without_ext, _ = last_file.split(".") + try: + file_number = int(last_file_name_without_ext[-1]) + except ValueError: + file_number = 1 + filename = f"{self.log_dir}/{self.filename_prefix}{file_number}.log" + return filename + + def _get_message(self, message): + now = datetime.now() + log_without_date = self.log_template.format(message=message) + full_log = now.strftime(log_without_date) + return full_log + + def write(self, message): + filename = self.get_log_filename() + log = self._get_message(message) + "\n" + with open(filename, "a") as file: + file.write(log) + print(log) From 50c106887820d215028d3a87c813ff053ee3dd4a Mon Sep 17 00:00:00 2001 From: DanielKusyDev Date: Wed, 3 Jun 2020 19:04:45 +0200 Subject: [PATCH 14/42] Fix failing tests due to changes in project structure --- core/controllers.py | 2 +- tests.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/core/controllers.py b/core/controllers.py index c79148f..0fa0014 100644 --- a/core/controllers.py +++ b/core/controllers.py @@ -2,7 +2,7 @@ from backend import crypto from utils import validate_auth - +from core.response import ModelResponse, Response diff --git a/tests.py b/tests.py index 8f7a198..7be723a 100644 --- a/tests.py +++ b/tests.py @@ -8,7 +8,8 @@ from backend import crypto from core import models import utils -from core.controllers import Controller, Response, ModelResponse +from core.controllers import Controller +from core.response import Response, ModelResponse from core.message import Message From 5dea1dbe4c6cbf06aa488c42777954bc326cbec7 Mon Sep 17 00:00:00 2001 From: DanielKusyDev Date: Wed, 3 Jun 2020 22:13:44 +0200 Subject: [PATCH 15/42] Create response message with ability to serialize data --- backend/server.py | 47 ++++++++++++++++++++------------ core/{message.py => messages.py} | 36 +++++++++++++++++++++++- tests.py | 6 ++-- utils.py | 4 +-- 4 files changed, 69 insertions(+), 24 deletions(-) rename core/{message.py => messages.py} (64%) diff --git a/backend/server.py b/backend/server.py index bf657d7..209b9b3 100644 --- a/backend/server.py +++ b/backend/server.py @@ -3,7 +3,9 @@ import socket import ssl from time import sleep +from typing import List, Tuple +from core import response, messages from utils import Logger @@ -15,27 +17,31 @@ def __init__(self): self.message_queue = {} @staticmethod - def recv_all(sock): + def recv_all(sock: ssl.SSLSocket) -> str: result = "" while result[-2:] != "\r\n": result += sock.read(1).decode() return result - @staticmethod - def send(sock, message): - if isinstance(message, str): - message = message.encode() - sock.write(message) + def send(self, sock: ssl.SSLSocket, message: messages.ResponseMessage) -> None: + message_str = message.request_str + + if isinstance(message_str, str): + message_str = message_str.encode() + sock.write(message_str) - def read_connections(self, connections, server_socket): + host, port = sock.getpeername() + self.logger.write(f"{host}:{port} | REGISTER | {message.status}: {message.message} ") + + def read_connections(self, connections: List[ssl.SSLSocket], server_socket: ssl.SSLSocket) -> None: for sock in connections: try: if sock is server_socket: conn, c_addr = sock.accept() - conn.setblocking(0) + conn.setblocking(False) self.inputs.append(conn) self.message_queue[conn] = queue.Queue() - self.logger.write(f"Connected by {c_addr}") + self.logger.write(f"Connected by {c_addr[0]}:{c_addr[1]}") else: message = self.recv_all(sock) if message: @@ -49,9 +55,10 @@ def read_connections(self, connections, server_socket): sock.close() del self.message_queue[sock] except ssl.SSLWantReadError: - self.send(sock, "") + message = messages.ErrorResponseMessage("Syntax Error") + self.send(sock, message) - def write_to_connections(self, connections): + def write_to_connections(self, connections: List[ssl.SSLSocket]) -> None: for sock in connections: try: message = self.message_queue[sock].get_nowait() @@ -61,11 +68,15 @@ def write_to_connections(self, connections): else: sock.sendall(message) - def read_or_write(self, server_socket): + def read_or_write(self, server_socket: ssl.SSLSocket) -> None: while self.inputs: - readable, writable, _ = select.select(self.inputs, self.outputs, []) - self.read_connections(connections=readable, server_socket=server_socket) - self.write_to_connections(connections=writable) + try: + readable, writable, _ = select.select(self.inputs, self.outputs, []) + self.read_connections(connections=readable, server_socket=server_socket) + self.write_to_connections(connections=writable) + except Exception as e: + message = messages.ErrorResponseMessage(error=str(e)) + self.logger.write(message) class Server(object): @@ -73,20 +84,20 @@ def __init__(self, address=("127.0.0.1", 6666)): self.logger = Logger() self.address = address - def get_secure_socket(self, raw_socket): + def get_secure_socket(self, raw_socket: socket.socket) -> ssl.SSLSocket: context = ssl.SSLContext(ssl.PROTOCOL_TLSv1) context.load_cert_chain(certfile="/etc/ssl/certs/proton.pem") secure_socket = context.wrap_socket(raw_socket, server_side=True) return secure_socket - def get_raw_socket(self, raw_socket): + def get_raw_socket(self, raw_socket: socket.socket) -> socket.socket: raw_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) raw_socket.setblocking(False) raw_socket.bind(self.address) raw_socket.listen(5) return raw_socket - def runserver(self): + def runserver(self) -> None: self.logger.write(f"Starting server at {self.address[0]}:{self.address[1]}") raw_socket = socket.socket(family=socket.AF_INET, type=socket.SOCK_STREAM) rw_controller = ReadWriteSocketController() diff --git a/core/message.py b/core/messages.py similarity index 64% rename from core/message.py rename to core/messages.py index 63fcba9..12c865a 100644 --- a/core/message.py +++ b/core/messages.py @@ -3,7 +3,7 @@ import utils -class Message(object): +class RequestMessage(object): def __init__(self, json_string): self.required_action_params = { @@ -52,3 +52,37 @@ def get_opts(self): opts = self.obj.get("opts", None) assert isinstance(opts, dict) or opts is None return opts + + +class ResponseMessage(object): + def __init__(self): + self.status = None + self.message = None + self.data = None + self.request_str = None + + def construct_json(self): + _request = { + "status": self.status, + "message": self.message, + "data": self.data + } + request = {key: val for key, val in _request.items() if val is not None} + self.request_str = json.dumps(request) + + def __repr__(self): + return self.request_str + + +class ErrorResponseMessage(ResponseMessage): + def __init__(self, error): + super(ErrorResponseMessage, self).__init__() + self.message = error + self.status = "ERROR" + self.construct_json() + + +class SuccessResponseMessage(ResponseMessage): + def __init__(self): + super(SuccessResponseMessage, self).__init__() + self.status = "OK" diff --git a/tests.py b/tests.py index 7be723a..34350b8 100644 --- a/tests.py +++ b/tests.py @@ -10,7 +10,7 @@ import utils from core.controllers import Controller from core.response import Response, ModelResponse -from core.message import Message +from core.messages import RequestMessage class CryptographyTestCase(unittest.TestCase): @@ -115,7 +115,7 @@ class MessageTests(BaseControllerTest): def setUp(self) -> None: super(MessageTests, self).setUp() self.proper_request = """{"action":"register", "params":{"username":"...", "password":"..."}}""" - self.message = Message(self.proper_request) + self.message = RequestMessage(self.proper_request) def test_deserialization(self): request = """{ @@ -173,7 +173,7 @@ def _login(self, request, create_user=True): def _request_action(self, request): raw_request = json.dumps(request) - message = Message(raw_request) + message = RequestMessage(raw_request) result = getattr(self.controller, message.action)(message) return result diff --git a/utils.py b/utils.py index 89f1c7e..05136ba 100644 --- a/utils.py +++ b/utils.py @@ -80,7 +80,7 @@ def _get_message(self, message): def write(self, message): filename = self.get_log_filename() - log = self._get_message(message) + "\n" + log = self._get_message(message) with open(filename, "a") as file: - file.write(log) + file.write(log + "\n") print(log) From 4b91c2561915b0820dcf8d1a4636aba59e991bfd Mon Sep 17 00:00:00 2001 From: DanielKusyDev Date: Fri, 5 Jun 2020 13:25:50 +0200 Subject: [PATCH 16/42] Change structure of the project. Handle unexpected error by sending an information to the client. --- .gitignore | 2 +- __init__.py | 0 backend/__init__.py | 0 backend/server.py | 108 +++++++++++++------------ core/__init__.py | 0 create_db.sql => core/db/create_db.sql | 0 core/messages.py | 1 + runserver.py | 4 + utils.py | 4 +- 9 files changed, 64 insertions(+), 55 deletions(-) create mode 100644 __init__.py create mode 100644 backend/__init__.py create mode 100644 core/__init__.py rename create_db.sql => core/db/create_db.sql (100%) create mode 100644 runserver.py diff --git a/.gitignore b/.gitignore index d648ad9..d2d6a56 100644 --- a/.gitignore +++ b/.gitignore @@ -129,7 +129,7 @@ dmypy.json .pyre/ .idea -sqlite3.db +core/db/sqlite3.db config.ini .cert test_client.py diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/server.py b/backend/server.py index 209b9b3..c0feb81 100644 --- a/backend/server.py +++ b/backend/server.py @@ -2,48 +2,67 @@ import select import socket import ssl -from time import sleep -from typing import List, Tuple -from core import response, messages +from core import messages from utils import Logger +logger = Logger() -class ReadWriteSocketController(object): - def __init__(self): - self.logger = Logger() - self.inputs = [] + +def recv_all(sock: ssl.SSLSocket) -> str: + result = "" + while result[-2:] != "\r\n": + result += sock.read(1).decode() + return result + + +def send(sock: ssl.SSLSocket, message: messages.ResponseMessage) -> None: + message_str = message.request_str + + if isinstance(message_str, str): + message_str = message_str.encode() + sock.write(message_str) + + host, port = sock.getpeername() + logger.write(f"{host}:{port} | {message.status}: {message.message} ") + + +class ConnectionManager(object): + def __init__(self, server_socket): + self.server_socket = server_socket + self.inputs = [server_socket] self.outputs = [] self.message_queue = {} - @staticmethod - def recv_all(sock: ssl.SSLSocket) -> str: - result = "" - while result[-2:] != "\r\n": - result += sock.read(1).decode() - return result + self.readable, self.writable, _ = (None, None, None) - def send(self, sock: ssl.SSLSocket, message: messages.ResponseMessage) -> None: - message_str = message.request_str + def handle_unexpected_error(self, e): + for conn, val in self.message_queue.items(): + error_response = messages.ErrorResponseMessage(error=str(e)) + send(conn, error_response) + logger.write(error_response.message) - if isinstance(message_str, str): - message_str = message_str.encode() - sock.write(message_str) - - host, port = sock.getpeername() - self.logger.write(f"{host}:{port} | REGISTER | {message.status}: {message.message} ") + def process(self): + while self.inputs: + self.readable, self.writable, _ = select.select(self.inputs, self.outputs, self.inputs) + try: + self.read_input() + self.write_output() + except Exception as e: + self.handle_unexpected_error(e) + break - def read_connections(self, connections: List[ssl.SSLSocket], server_socket: ssl.SSLSocket) -> None: - for sock in connections: + def read_input(self): + for sock in self.readable: try: - if sock is server_socket: + if sock is self.server_socket: conn, c_addr = sock.accept() conn.setblocking(False) self.inputs.append(conn) self.message_queue[conn] = queue.Queue() - self.logger.write(f"Connected by {c_addr[0]}:{c_addr[1]}") + logger.write(f"Connected by {c_addr[0]}:{c_addr[1]}") else: - message = self.recv_all(sock) + message = recv_all(sock) if message: self.message_queue[sock].put(message) if sock not in self.outputs: @@ -56,32 +75,22 @@ def read_connections(self, connections: List[ssl.SSLSocket], server_socket: ssl. del self.message_queue[sock] except ssl.SSLWantReadError: message = messages.ErrorResponseMessage("Syntax Error") - self.send(sock, message) + send(sock, message) - def write_to_connections(self, connections: List[ssl.SSLSocket]) -> None: - for sock in connections: + def write_output(self): + for sock in self.writable: try: - message = self.message_queue[sock].get_nowait() - sleep(10) + raw_message = self.message_queue[sock].get_nowait() + # sleep(10) except queue.Empty: self.outputs.remove(sock) else: - sock.sendall(message) - - def read_or_write(self, server_socket: ssl.SSLSocket) -> None: - while self.inputs: - try: - readable, writable, _ = select.select(self.inputs, self.outputs, []) - self.read_connections(connections=readable, server_socket=server_socket) - self.write_to_connections(connections=writable) - except Exception as e: - message = messages.ErrorResponseMessage(error=str(e)) - self.logger.write(message) + # todo + sock.sendall(raw_message) class Server(object): def __init__(self, address=("127.0.0.1", 6666)): - self.logger = Logger() self.address = address def get_secure_socket(self, raw_socket: socket.socket) -> ssl.SSLSocket: @@ -98,23 +107,16 @@ def get_raw_socket(self, raw_socket: socket.socket) -> socket.socket: return raw_socket def runserver(self) -> None: - self.logger.write(f"Starting server at {self.address[0]}:{self.address[1]}") + logger.write(f"Starting server at {self.address[0]}:{self.address[1]}") raw_socket = socket.socket(family=socket.AF_INET, type=socket.SOCK_STREAM) - rw_controller = ReadWriteSocketController() try: raw_socket = self.get_raw_socket(raw_socket) server_socket = self.get_secure_socket(raw_socket) - rw_controller.inputs = [server_socket] - rw_controller.read_or_write(server_socket) - + connections = ConnectionManager(server_socket) + connections.process() except socket.error as e: print("todo") raise e finally: raw_socket.close() - - -if __name__ == "__main__": - s = Server(("localhost", 6666)) - s.runserver() diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/create_db.sql b/core/db/create_db.sql similarity index 100% rename from create_db.sql rename to core/db/create_db.sql diff --git a/core/messages.py b/core/messages.py index 12c865a..0414a78 100644 --- a/core/messages.py +++ b/core/messages.py @@ -69,6 +69,7 @@ def construct_json(self): } request = {key: val for key, val in _request.items() if val is not None} self.request_str = json.dumps(request) + self.request_str += "\r\n" def __repr__(self): return self.request_str diff --git a/runserver.py b/runserver.py new file mode 100644 index 0000000..d55a7ed --- /dev/null +++ b/runserver.py @@ -0,0 +1,4 @@ +from backend.server import Server + +server = Server(("localhost", 6666)) +server.runserver() diff --git a/utils.py b/utils.py index 05136ba..6933282 100644 --- a/utils.py +++ b/utils.py @@ -1,6 +1,7 @@ import os import secrets import sqlite3 +import ssl import string from datetime import datetime @@ -42,7 +43,7 @@ def create_conn(db_name="sqlite3.db"): def create_db(db_name="sqlite3.db"): conn = create_conn(db_name) cursor = conn.cursor() - with open("create_db.sql", "r") as script: + with open("core/db/create_db.sql", "r") as script: cursor.executescript(script.read()) @@ -84,3 +85,4 @@ def write(self, message): with open(filename, "a") as file: file.write(log + "\n") print(log) + From f34f9a015031686a47d5059f580e32330c0932d1 Mon Sep 17 00:00:00 2001 From: DanielKusyDev Date: Fri, 5 Jun 2020 14:11:35 +0200 Subject: [PATCH 17/42] Experimental azure CI deploy action --- .github/workflows/python-app.yml | 17 +++++++++-------- config_example.ini | 3 ++- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index a647ba9..b02e704 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -4,16 +4,11 @@ name: Proton on: - push: - branches: [ master, develop ] - pull_request: - branches: [ master, develop ] - + [push] jobs: build: - + name: Build and deploy runs-on: ubuntu-latest - steps: - uses: actions/checkout@v2 - name: Set up Python 3.8 @@ -26,6 +21,12 @@ jobs: if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Prepare test environment run: cp config_example.ini config.ini - - name: Test with pytest + - name: Test with unittest run: | python3 -m unittest tests.py + + - name: 'Run Azure webapp deploy action using publish profile credentials' + uses: azure/webapps-deploy@v2 + with: + app-name: proton + publish-profile: ${{ secrets.azureWebAppPublishProfile }} \ No newline at end of file diff --git a/config_example.ini b/config_example.ini index 5c97b51..7cd9a06 100644 --- a/config_example.ini +++ b/config_example.ini @@ -1,3 +1,4 @@ [SECRET] KEY = ... -SALT = ... \ No newline at end of file +SALT = ... +DEBUG = False \ No newline at end of file From c55504b1edb520e323cfa7bc806c75b6fd1ddca7 Mon Sep 17 00:00:00 2001 From: DanielKusyDev Date: Fri, 5 Jun 2020 14:13:00 +0200 Subject: [PATCH 18/42] Experimental azure CI deploy action --- .github/workflows/python-app.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index b02e704..d6d99cc 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -26,7 +26,7 @@ jobs: python3 -m unittest tests.py - name: 'Run Azure webapp deploy action using publish profile credentials' - uses: azure/webapps-deploy@v2 - with: - app-name: proton - publish-profile: ${{ secrets.azureWebAppPublishProfile }} \ No newline at end of file + uses: azure/webapps-deploy@v2 + with: + app-name: proton + publish-profile: ${{ secrets.azureWebAppPublishProfile }} \ No newline at end of file From 8a2c1840d3761664530eaaf799974b4b857f875d Mon Sep 17 00:00:00 2001 From: DanielKusyDev Date: Fri, 5 Jun 2020 14:15:32 +0200 Subject: [PATCH 19/42] Experimental azure CI deploy action --- .github/workflows/python-app.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index d6d99cc..43bf593 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -28,5 +28,5 @@ jobs: - name: 'Run Azure webapp deploy action using publish profile credentials' uses: azure/webapps-deploy@v2 with: - app-name: proton + app-name: "prot-on" publish-profile: ${{ secrets.azureWebAppPublishProfile }} \ No newline at end of file From b5e91f73c02aab9e93ffb64b1bef05f374eacf7a Mon Sep 17 00:00:00 2001 From: DanielKusyDev Date: Sun, 7 Jun 2020 13:06:22 +0200 Subject: [PATCH 20/42] Transform event based server into threads driven because of stateless nature of protocol. --- .github/workflows/python-app.yml | 17 ++-- .gitignore | 2 +- backend/server.py | 143 ++++++++++++------------------- core/controllers.py | 39 ++++----- core/messages.py | 63 ++++++++------ core/models.py | 2 + core/response.py | 34 -------- responses.json | 3 +- settings.py | 4 +- tests.py | 7 +- utils.py | 20 +++-- 11 files changed, 142 insertions(+), 192 deletions(-) delete mode 100644 core/response.py diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 43bf593..a647ba9 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -4,11 +4,16 @@ name: Proton on: - [push] + push: + branches: [ master, develop ] + pull_request: + branches: [ master, develop ] + jobs: build: - name: Build and deploy + runs-on: ubuntu-latest + steps: - uses: actions/checkout@v2 - name: Set up Python 3.8 @@ -21,12 +26,6 @@ jobs: if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Prepare test environment run: cp config_example.ini config.ini - - name: Test with unittest + - name: Test with pytest run: | python3 -m unittest tests.py - - - name: 'Run Azure webapp deploy action using publish profile credentials' - uses: azure/webapps-deploy@v2 - with: - app-name: "prot-on" - publish-profile: ${{ secrets.azureWebAppPublishProfile }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index d2d6a56..deb3f3f 100644 --- a/.gitignore +++ b/.gitignore @@ -131,5 +131,5 @@ dmypy.json .idea core/db/sqlite3.db config.ini -.cert +/backend/certs test_client.py diff --git a/backend/server.py b/backend/server.py index c0feb81..1a5387a 100644 --- a/backend/server.py +++ b/backend/server.py @@ -1,9 +1,8 @@ -import queue -import select import socket import ssl +import threading -from core import messages +from core import messages, controllers from utils import Logger logger = Logger() @@ -16,107 +15,73 @@ def recv_all(sock: ssl.SSLSocket) -> str: return result -def send(sock: ssl.SSLSocket, message: messages.ResponseMessage) -> None: - message_str = message.request_str +def send(sock: ssl.SSLSocket, response: messages.Response) -> None: + message_str = response.json_response if isinstance(message_str, str): message_str = message_str.encode() sock.write(message_str) host, port = sock.getpeername() - logger.write(f"{host}:{port} | {message.status}: {message.message} ") - - -class ConnectionManager(object): - def __init__(self, server_socket): - self.server_socket = server_socket - self.inputs = [server_socket] - self.outputs = [] - self.message_queue = {} - - self.readable, self.writable, _ = (None, None, None) - - def handle_unexpected_error(self, e): - for conn, val in self.message_queue.items(): - error_response = messages.ErrorResponseMessage(error=str(e)) - send(conn, error_response) - logger.write(error_response.message) - - def process(self): - while self.inputs: - self.readable, self.writable, _ = select.select(self.inputs, self.outputs, self.inputs) - try: - self.read_input() - self.write_output() - except Exception as e: - self.handle_unexpected_error(e) - break - - def read_input(self): - for sock in self.readable: - try: - if sock is self.server_socket: - conn, c_addr = sock.accept() - conn.setblocking(False) - self.inputs.append(conn) - self.message_queue[conn] = queue.Queue() - logger.write(f"Connected by {c_addr[0]}:{c_addr[1]}") - else: - message = recv_all(sock) - if message: - self.message_queue[sock].put(message) - if sock not in self.outputs: - self.outputs.append(sock) - else: - if sock in self.outputs: - self.outputs.remove(sock) - self.inputs.remove(sock) - sock.close() - del self.message_queue[sock] - except ssl.SSLWantReadError: - message = messages.ErrorResponseMessage("Syntax Error") - send(sock, message) - - def write_output(self): - for sock in self.writable: - try: - raw_message = self.message_queue[sock].get_nowait() - # sleep(10) - except queue.Empty: - self.outputs.remove(sock) - else: - # todo - sock.sendall(raw_message) + logger.write(f"{host}:{port} | {response.status}: {response.message} ") + + +class ClientThread(threading.Thread): + def __init__(self, secure_socket: ssl.SSLSocket): + super().__init__() + self.secure_socket = secure_socket + + def get_request(self): + raw_message = recv_all(self.secure_socket) + request = messages.Request(raw_message) + return request + + def run(self) -> None: + request = self.get_request() + response = getattr(controllers.Controller(), request.action)(request) + send(self.secure_socket, response) class Server(object): def __init__(self, address=("127.0.0.1", 6666)): self.address = address - def get_secure_socket(self, raw_socket: socket.socket) -> ssl.SSLSocket: - context = ssl.SSLContext(ssl.PROTOCOL_TLSv1) - context.load_cert_chain(certfile="/etc/ssl/certs/proton.pem") - secure_socket = context.wrap_socket(raw_socket, server_side=True) - return secure_socket - - def get_raw_socket(self, raw_socket: socket.socket) -> socket.socket: + def get_raw_socket(self) -> socket.socket: + raw_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) raw_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - raw_socket.setblocking(False) raw_socket.bind(self.address) - raw_socket.listen(5) + raw_socket.listen(100) return raw_socket - def runserver(self) -> None: - logger.write(f"Starting server at {self.address[0]}:{self.address[1]}") - raw_socket = socket.socket(family=socket.AF_INET, type=socket.SOCK_STREAM) - + def get_secure_socket(self, raw_socket: socket.socket) -> ssl.SSLSocket: + ssock = ssl.wrap_socket(raw_socket, server_side=True, ca_certs="backend/certs/client.pem", + certfile="backend/certs/server.pem", cert_reqs=ssl.CERT_REQUIRED, + ssl_version=ssl.PROTOCOL_TLS) + cert = ssock.getpeercert() + if not cert or ("commonName", 'proton') not in cert['subject'][5]: + raise Exception + return ssock + + def process(self, server_socket: socket.socket): try: - raw_socket = self.get_raw_socket(raw_socket) - server_socket = self.get_secure_socket(raw_socket) - connections = ConnectionManager(server_socket) - connections.process() - except socket.error as e: - print("todo") - raise e + while True: + conn, c_addr = server_socket.accept() + secure_client = self.get_secure_socket(conn) + secure_client.setblocking(False) + try: + logger.write(f"Connected by {c_addr[0]}:{c_addr[1]}") + c = ClientThread(secure_client) + c.start() + except Exception as e: + response = messages.Response(status="ERROR", message=str(e)) + send(secure_client, response) + secure_client.close() + except Exception as e: + logger.write(str(e)) finally: - raw_socket.close() + server_socket.close() + + def runserver(self): + logger.write(f"Starting server at {self.address[0]}:{self.address[1]}") + server_socket = self.get_raw_socket() + self.process(server_socket) diff --git a/core/controllers.py b/core/controllers.py index 0fa0014..c383301 100644 --- a/core/controllers.py +++ b/core/controllers.py @@ -2,8 +2,7 @@ from backend import crypto from utils import validate_auth -from core.response import ModelResponse, Response - +from core.messages import ModelResponse, Response class Controller(object): @@ -23,8 +22,8 @@ def _get_token(self, user_id): token = self.auth_model.create(user_id=user_id) return token - def register(self, message): - params = message.params + def register(self, request): + params = request.params users = self.user_model.filter(username=params.get("username")) if len(users) > 0: return Response(status="ERROR", message="Given user already exists.") @@ -33,10 +32,10 @@ def register(self, message): password = params.get("password") self.user_model.create(username=username, password=password) users = self.user_model.first(username=username) - return ModelResponse("OK", self.user_model, users) + return ModelResponse("OK", self.user_model, users, ) - def login(self, message): - params = message.params + def login(self, request): + params = request.params username = params["username"] password = params["password"] user = self.user_model.first(username=username) @@ -47,23 +46,23 @@ def login(self, message): return ModelResponse("OK", self.auth_model, token) @validate_auth - def logout(self, message): - token = message.opts["auth_token"] + def logout(self, request): + token = request.opts["auth_token"] self.auth_model.delete(token=token) return Response("OK") @validate_auth - def create(self, message): - user_id = self.auth_model.first(token=message.opts["auth_token"])[1] - post = self.post_model.create(user_id=user_id, **message.params) + def create(self, request): + user_id = self.auth_model.first(token=request.opts["auth_token"])[1] + post = self.post_model.create(user_id=user_id, **request.params) return ModelResponse(status="OK", model=self.post_model, raw_instance=post, message="Post created successfully.") @validate_auth - def get(self, message): + def get(self, request): instance = None - if getattr(message, "params", None) is not None and message.params.get("id", None) is not None: - post_id = message.params["id"] + if getattr(request, "params", None) is not None and request.params.get("id", None) is not None: + post_id = request.params["id"] if post_id is not None: instance = self.post_model.first(id=post_id) else: @@ -72,13 +71,13 @@ def get(self, message): return ModelResponse("OK", self.post_model, raw_instance=instance) @validate_auth - def alter(self, message): - post_id = message.params.pop("id") - instance = self.post_model.update(data=message.params, where={"id": post_id}) + def alter(self, request): + post_id = request.params.pop("id") + instance = self.post_model.update(data=request.params, where={"id": post_id}) return ModelResponse("OK", self.post_model, instance) @validate_auth - def delete(self, message): - post_id = message.params.pop("id") + def delete(self, request): + post_id = request.params.pop("id") self.post_model.delete(id=post_id) return Response("OK") diff --git a/core/messages.py b/core/messages.py index 0414a78..f862b98 100644 --- a/core/messages.py +++ b/core/messages.py @@ -1,9 +1,11 @@ import json +from typing import Union import utils +from core import models -class RequestMessage(object): +class Request(object): def __init__(self, json_string): self.required_action_params = { @@ -17,19 +19,16 @@ def __init__(self, json_string): } json_string = json_string self.json_string = json_string - self.obj = self.deserialize_json() try: + self.obj = self.deserialize_json() self.action = self.get_action() self.params = self.get_params() self.opts = self.get_opts() - except (KeyError, AssertionError): + except (KeyError, AssertionError, json.JSONDecodeError) as e: raise utils.ProtonError("Syntax Error") def deserialize_json(self): - try: - obj = json.loads(self.json_string) - except json.JSONDecodeError as e: - raise utils.ProtonError("Syntax Error") + obj = json.loads(self.json_string) return obj def get_action(self): @@ -54,12 +53,14 @@ def get_opts(self): return opts -class ResponseMessage(object): - def __init__(self): - self.status = None - self.message = None - self.data = None - self.request_str = None +class Response(object): + def __init__(self, status, message=None, data=None): + self.message = message + self.status = status + self.data = data + self.json_response = None + + self.construct_json() def construct_json(self): _request = { @@ -68,22 +69,32 @@ def construct_json(self): "data": self.data } request = {key: val for key, val in _request.items() if val is not None} - self.request_str = json.dumps(request) - self.request_str += "\r\n" + self.json_response = json.dumps(request) + self.json_response += "\r\n" def __repr__(self): - return self.request_str + return self.json_response -class ErrorResponseMessage(ResponseMessage): - def __init__(self, error): - super(ErrorResponseMessage, self).__init__() - self.message = error - self.status = "ERROR" - self.construct_json() +class ModelResponse(Response): + def __init__(self, status, model, raw_instance: Union[list, tuple], message=""): + + if not isinstance(model, models.Model): + model = model() + self.model = model + + if not isinstance(raw_instance[0], tuple): + raw_instance = [raw_instance] + self.raw_instance = raw_instance + data = self.create_data() + super(ModelResponse, self).__init__(status, message, data=data) -class SuccessResponseMessage(ResponseMessage): - def __init__(self): - super(SuccessResponseMessage, self).__init__() - self.status = "OK" + def create_data(self): + table_schema = self.model.get_table_cols() + data = [] + for instance in self.raw_instance: + single_obj_data = {col_name: val for col_name, val in zip(table_schema, instance) if + col_name not in self.model.write_only} + data.append(single_obj_data) + return data diff --git a/core/models.py b/core/models.py index 4687a60..fde71f1 100644 --- a/core/models.py +++ b/core/models.py @@ -10,6 +10,7 @@ class Model(abc.ABC): fields = [] + write_only = [] def __init__(self, db_name="sqlite3.db"): self.table_name = self.__class__.__name__.lower() @@ -111,6 +112,7 @@ class Post(Model): class User(Model): fields = ["username", "password"] + write_only = ["password"] def create(self, **kwargs): kwargs["password"] = crypto.encrypt(kwargs.get("password")) diff --git a/core/response.py b/core/response.py deleted file mode 100644 index d6e410a..0000000 --- a/core/response.py +++ /dev/null @@ -1,34 +0,0 @@ -from typing import Union - -from core import models - - -class Response(object): - def __init__(self, status, message=None): - self.message = message - self.status = status.upper() == "OK" - - -class ModelResponse(Response): - def __init__(self, status, model, raw_instance: Union[list, tuple], message=None): - super(ModelResponse, self).__init__(status=status, message=message) - if not isinstance(model, models.Model): - model = model() - if not isinstance(raw_instance[0], tuple): - raw_instance = [raw_instance] - - self.model = model - self.raw_instance = raw_instance - self.message = message - self.data = self.create_data() - - def __str__(self): - return "xd" - - def create_data(self): - table_schema = self.model.get_table_cols() - data = [] - for instance in self.raw_instance: - single_obj_data = {col_name: val for col_name, val in zip(table_schema, instance)} - data.append(single_obj_data) - return data diff --git a/responses.json b/responses.json index b0bc59e..f4328fe 100644 --- a/responses.json +++ b/responses.json @@ -30,4 +30,5 @@ } ] } -] \ No newline at end of file +] + diff --git a/settings.py b/settings.py index d490b7f..78ef048 100644 --- a/settings.py +++ b/settings.py @@ -8,4 +8,6 @@ EXPIRATION = { "minutes": 15 -} \ No newline at end of file +} + +DB_DIR = "core/db" \ No newline at end of file diff --git a/tests.py b/tests.py index 34350b8..0fbf5f5 100644 --- a/tests.py +++ b/tests.py @@ -9,8 +9,7 @@ from core import models import utils from core.controllers import Controller -from core.response import Response, ModelResponse -from core.messages import RequestMessage +from core.messages import Request, Response, ModelResponse class CryptographyTestCase(unittest.TestCase): @@ -115,7 +114,7 @@ class MessageTests(BaseControllerTest): def setUp(self) -> None: super(MessageTests, self).setUp() self.proper_request = """{"action":"register", "params":{"username":"...", "password":"..."}}""" - self.message = RequestMessage(self.proper_request) + self.message = Request(self.proper_request) def test_deserialization(self): request = """{ @@ -173,7 +172,7 @@ def _login(self, request, create_user=True): def _request_action(self, request): raw_request = json.dumps(request) - message = RequestMessage(raw_request) + message = Request(raw_request) result = getattr(self.controller, message.action)(message) return result diff --git a/utils.py b/utils.py index 6933282..8a9985d 100644 --- a/utils.py +++ b/utils.py @@ -5,10 +5,11 @@ import string from datetime import datetime -from core import models +import settings +from core import models, messages -class ProtonError(BaseException): +class ProtonError(Exception): """Proton protocol error base class""" pass @@ -22,28 +23,33 @@ def validate_auth(fn): def wrapper(*args, **kwargs): controller, message = args try: + assert message.opts is not None token = message.opts["auth_token"] token_model = models.AuthToken(controller.db_name) assert token_model.is_valid(token=token) except (KeyError, AssertionError, ProtonError): - raise PermissionError("Permission denied. Authorization required.") - return fn(*args, **kwargs) + response = messages.Response(status="ERROR", message="Permission denied. Authorization required.") + return response + else: + return fn(*args, **kwargs) return wrapper def create_conn(db_name="sqlite3.db"): + db = os.path.join(settings.DB_DIR, db_name) try: - conn = sqlite3.connect(db_name) + conn = sqlite3.connect(db) return conn except sqlite3.Error as e: print(e) def create_db(db_name="sqlite3.db"): - conn = create_conn(db_name) + db = os.path.join(settings.DB_DIR, db_name) + conn = create_conn(db) cursor = conn.cursor() - with open("core/db/create_db.sql", "r") as script: + with open(os.path.join(settings.DB_DIR, "create_db.sql"), "r") as script: cursor.executescript(script.read()) From a636e3c63edd4cf04a9877ad01c6db168cf4f504 Mon Sep 17 00:00:00 2001 From: DanielKusyDev Date: Sun, 7 Jun 2020 13:17:17 +0200 Subject: [PATCH 21/42] Fix failing tests. --- backend/server.py | 8 ++++++-- core/controllers.py | 3 ++- core/models.py | 4 ++-- settings.py | 4 ++-- tests.py | 5 +++-- utils.py | 15 ++++++--------- 6 files changed, 21 insertions(+), 18 deletions(-) diff --git a/backend/server.py b/backend/server.py index 1a5387a..0b641bd 100644 --- a/backend/server.py +++ b/backend/server.py @@ -38,8 +38,12 @@ def get_request(self): def run(self) -> None: request = self.get_request() - response = getattr(controllers.Controller(), request.action)(request) - send(self.secure_socket, response) + try: + response = getattr(controllers.Controller(), request.action)(request) + except PermissionError as e: + response = messages.Response(status="ERROR", message=str(e)) + finally: + send(self.secure_socket, response) class Server(object): diff --git a/core/controllers.py b/core/controllers.py index c383301..872b742 100644 --- a/core/controllers.py +++ b/core/controllers.py @@ -1,3 +1,4 @@ +import settings from core import models from backend import crypto from utils import validate_auth @@ -7,7 +8,7 @@ class Controller(object): - def __init__(self, db_name="sqlite3.db"): + def __init__(self, db_name=settings.DATABASE): self.db_name = db_name self.post_model = models.Post(self.db_name) self.user_model = models.User(self.db_name) diff --git a/core/models.py b/core/models.py index fde71f1..47e24c8 100644 --- a/core/models.py +++ b/core/models.py @@ -1,4 +1,5 @@ import datetime +import os import sqlite3 import abc from time import strptime @@ -12,9 +13,8 @@ class Model(abc.ABC): fields = [] write_only = [] - def __init__(self, db_name="sqlite3.db"): + def __init__(self, db_name=settings.DATABASE): self.table_name = self.__class__.__name__.lower() - self.db_name = db_name self.conn = utils.create_conn(db_name=db_name) def __del__(self): diff --git a/settings.py b/settings.py index 78ef048..c871835 100644 --- a/settings.py +++ b/settings.py @@ -1,5 +1,6 @@ import codecs from configparser import RawConfigParser + parser = RawConfigParser() parser.read_file(codecs.open("config.ini", "r", "utf-8")) @@ -9,5 +10,4 @@ EXPIRATION = { "minutes": 15 } - -DB_DIR = "core/db" \ No newline at end of file +DATABASE = "core/db/sqlite3.db" diff --git a/tests.py b/tests.py index 0fbf5f5..b639e69 100644 --- a/tests.py +++ b/tests.py @@ -5,6 +5,7 @@ import sqlite3 import unittest +import settings from backend import crypto from core import models import utils @@ -121,7 +122,7 @@ def test_deserialization(self): "action": "", """ self.message.json_string = request - with self.assertRaises(utils.ProtonError): + with self.assertRaises(json.JSONDecodeError): self.message.deserialize_json() def test_getting_action(self): @@ -212,7 +213,7 @@ def test_login(self): # check invalid login data request["params"]["username"] = "wrongusername" result = self._request_action(request) - self.assertFalse(result.status) + self.assertEqual(result.status, "ERROR") def test_proper_logout(self): user = self._request_action(self.requests[0]) diff --git a/utils.py b/utils.py index 8a9985d..d09052a 100644 --- a/utils.py +++ b/utils.py @@ -28,28 +28,25 @@ def wrapper(*args, **kwargs): token_model = models.AuthToken(controller.db_name) assert token_model.is_valid(token=token) except (KeyError, AssertionError, ProtonError): - response = messages.Response(status="ERROR", message="Permission denied. Authorization required.") - return response + raise PermissionError("Permission denied. Authorization required.") else: return fn(*args, **kwargs) return wrapper -def create_conn(db_name="sqlite3.db"): - db = os.path.join(settings.DB_DIR, db_name) +def create_conn(db_name=settings.DATABASE): try: - conn = sqlite3.connect(db) + conn = sqlite3.connect(db_name) return conn except sqlite3.Error as e: print(e) -def create_db(db_name="sqlite3.db"): - db = os.path.join(settings.DB_DIR, db_name) - conn = create_conn(db) +def create_db(db_name=settings.DATABASE): + conn = create_conn(db_name) cursor = conn.cursor() - with open(os.path.join(settings.DB_DIR, "create_db.sql"), "r") as script: + with open(os.path.join("core/db/create_db.sql"), "r") as script: cursor.executescript(script.read()) From ed7feafe2697d607631473062ad678970dab2784 Mon Sep 17 00:00:00 2001 From: DanielKusyDev Date: Sun, 7 Jun 2020 14:12:02 +0200 Subject: [PATCH 22/42] Transform app to support stateful version of protocol --- backend/server.py | 25 ++++++++++++++++++------- core/controllers.py | 3 ++- tests.py | 4 ++++ utils.py | 2 +- 4 files changed, 25 insertions(+), 9 deletions(-) diff --git a/backend/server.py b/backend/server.py index 0b641bd..480263e 100644 --- a/backend/server.py +++ b/backend/server.py @@ -30,20 +30,31 @@ class ClientThread(threading.Thread): def __init__(self, secure_socket: ssl.SSLSocket): super().__init__() self.secure_socket = secure_socket + self.socket_authorized = False def get_request(self): raw_message = recv_all(self.secure_socket) request = messages.Request(raw_message) return request + def get_response(self, request): + controller = controllers.Controller(self.socket_authorized) + response = getattr(controller, request.action)(request) + if request.action == "login" and response.status == "OK": + self.socket_authorized = True + elif request.action == "logout" and response.status == "OK": + self.socket_authorized = False + return response + def run(self) -> None: - request = self.get_request() - try: - response = getattr(controllers.Controller(), request.action)(request) - except PermissionError as e: - response = messages.Response(status="ERROR", message=str(e)) - finally: - send(self.secure_socket, response) + while True: + request = self.get_request() + try: + response = self.get_response(request) + except PermissionError as e: + response = messages.Response(status="ERROR", message=str(e)) + finally: + send(self.secure_socket, response) class Server(object): diff --git a/core/controllers.py b/core/controllers.py index 872b742..3d2857f 100644 --- a/core/controllers.py +++ b/core/controllers.py @@ -8,7 +8,8 @@ class Controller(object): - def __init__(self, db_name=settings.DATABASE): + def __init__(self, socket_authorized, db_name=settings.DATABASE): + self.socket_authorized = socket_authorized self.db_name = db_name self.post_model = models.Post(self.db_name) self.user_model = models.User(self.db_name) diff --git a/tests.py b/tests.py index b639e69..f3286fe 100644 --- a/tests.py +++ b/tests.py @@ -273,3 +273,7 @@ def test_post_deletion(self): response = self._request_action(request) self.assertIsInstance(response, Response) self.assertListEqual(self.post_model.all(), []) + + +class ClientRequestTests(unittest.TestCase): + pass diff --git a/utils.py b/utils.py index d09052a..73315cf 100644 --- a/utils.py +++ b/utils.py @@ -23,7 +23,7 @@ def validate_auth(fn): def wrapper(*args, **kwargs): controller, message = args try: - assert message.opts is not None + assert controller.socket_authorized token = message.opts["auth_token"] token_model = models.AuthToken(controller.db_name) assert token_model.is_valid(token=token) From fb5eeb8e4619100132fb4041b59df280388374ba Mon Sep 17 00:00:00 2001 From: DanielKusyDev Date: Mon, 8 Jun 2020 14:59:51 +0200 Subject: [PATCH 23/42] Replaced event bases server into threading based because of problems with client-server nature of protocol. Overwrited craete method for create action. --- assets/4b4cbc2c6e3b4389b6e3282faf302bed.jpeg | Bin 0 -> 45881 bytes backend/connection_manager.py | 100 +++++++++++++++++++ backend/server.py | 37 ++++--- core/controllers.py | 13 ++- core/messages.py | 2 +- core/models.py | 13 +++ requests.json | 5 +- settings.py | 1 + tests.py | 9 +- utils.py | 4 +- 10 files changed, 153 insertions(+), 31 deletions(-) create mode 100644 assets/4b4cbc2c6e3b4389b6e3282faf302bed.jpeg create mode 100644 backend/connection_manager.py diff --git a/assets/4b4cbc2c6e3b4389b6e3282faf302bed.jpeg b/assets/4b4cbc2c6e3b4389b6e3282faf302bed.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..eeef5c31230b1e1ec439b5e41ff7b0791f32d911 GIT binary patch literal 45881 zcmeFa2Ut_t)-W6kD)xpHX)`qGgboH6ML2 z2ttM;2%#u-Xws1`o&O{hVTPIaz2p79|No!oo|%)g&uV+Ewbx#|oO5vF!^ZERvnq

zumXXUm3ctNKp@a@(3t}VK}Ud+4{)9Z%9ntvA8?*Oa0qk& zxROtX15}jjbD(^AtDFjyuWZpA1j=B5ml3$W0#0F|d=a?T1LuWdCa^2h4YnB)bIO}a23?75C!Pv9PYm2h# zBhZ!@XB8z`l7A5U7bPe2f3aRS>A_6bJ;_y8lV|Acz|%+dvM0Xo0JkxR4MS;2}ro?x+H8 z_Z&w+hqhvx+$Xl<-U?`v?YKt+<=t_A;NWKKIRWs4TjgxBS-`O!yW7`|U40U8#7*Eo z(8dJlBqg@Z<<&q3HYP!*DR&^tp8x>|HabAwpp(ar9iu*WlA8MDxf91voTE8&^5hvB zx(gR*E?l5Hcam~!T{dt2_{yBYdljYq1M!?3epmWDS zNYK+m2fha#Ja^#GxdR(rKzknFczZw&(2|OrVGfY}N-y_jAJD zHV@1t=2o^0&g)q@`GhCsRkeOvkWkXMc76~+$S1Z9eK`lT8KCW9icUw5P>E4g_zryj zFrb0M_ZO&ye-64x*5Hj+SxY>i1Rg~F-wZgshN7dh?<~1>l`*tcoNjXQx z-~qoZV<+;2f{PD16zcJeTVCi#yCgV$M3Q* z^CqUb;o)#zll*yDU1-uZj}3Ynei zjYZw!`Iub9cl6Yzc+n>}h@OQh?7<4Q!OHBR34l>@8}IiT7r`;#)v6!#5N3K{zT>v# zt=EwzZAU$=3Hd9?3A~!1374txCT$@uc&NMgw$q19= zE7_)ARAR?M5I-!C*j{+j^I6P8q(j;fsbP1^p=oDQ9vgz%PW1RaMRE z!vA$gBJacxk6p8@^OqXBZV#`?y&nxPY@O^dAC7kP_YCoRHIaNrkdS@!=w(9xA9gm zJSPJElbh^HuZ^a?ZakTGg8on$FHf6k+Q%~t6{h8lqu*5p9TB_h$rJViL%U*pCA&{x zY6tV>Amyj`4$MNnbobyipD5rbS;aD>3*P?RFeFuJDEg|(zuh%XI2Aruj6}HL4$b`{ z)39#w7*AWoD)C%Z1d$ONb;4{msr5;~1}JLo_LW!Tc0QIzX@7$L?u?M_T}_lOgyYYd z{s{UV+Kv@_&wKD`jcPaORIrDrZekVOT4j~Kzntn6>I1BO;lYbZr1xFO#R%@8W@(=b z#!mbp#`O~cmmhm5b}?;$Pz5&lvJ5)cfQtixuLhW8-ZFH01zE){ejiKUr^cc@{n5vT zlPd7`R9A&F5&2+gbkb&Wq$Okhd8;6dTO2Ze>ip2LBb;KqaoJqw+hlS|2qboy zx7A^Xr_Wi9huaJk()_@)a!a~xgfS4LYX_k}2)juua*S{!n*8d)1uCSFbboPX|D z{J4I2_m_jF2+XIvy!<)*=QWn(4bYGm7{f^P)A!{?OFXU4IePR57S%6zGBo|JKEV$c z4}LhEVk>sLhag^b{ip>&GoNXqCYePu02I`DVUCFb&6dUyuCM`pcmoj)M5&(w}5h zRv^s_(qF_h9QuJl0@XF=T(aWCk0;(+)w+5G&3HQ6_O}=P2A_2kbiSbPGV>wwJMu?F zIzAiUDzC79|2!}5Y~xyuWakFxZvQIn@)`}4^@(v%#Ju)XE7JvEheK$=OP9HEBM$DK zzMT$ndP81@Ofg3j#hzuxw;s|U6|^~QfP74C-zy7h+$}NVzwo}0Eh=Yj)q>Q~m?<}^ zTR58f^s@0G(0kB#YU~frs4TNiJLa+n-+Z>2;XVTM(?dL8S6w0kLLkZO%4&zn3nkE@ zQ;Y|UK!+}KC@}()zk{H|jKHiG_@D706$oO5#h~%5P6)U&E5?cy%Bl~y!`ZOPW3bi; z*4uED1r~#Zvsz-TS#fv-42eKnvtk_atauFC+7ZrpkREi=nZf|0FxYP~kP(LfCd$o^7tXN95>D5xD7cLWBM(G(07MG}R<5`%)1Yo**Ea2Pu%9&Smw z*w8ml_(G(%4P;g6xBZ}1qiokA( z#o55&_FHNn#Ub!`z+7^DN3mo*Hp|ENN};mA!ne3#P^=}w8j2=!00!alo2DLyVW1S< zj(>GEMW{U#y)E>@UpSWvSVLne%v3lOVpHBxa%oE$DjTT1y(^_cTa2rfBgL82FboQi zjlfVeISg~$bi!dc6pN!&3`bkrK{uUn#0ramZx#<(Ibtbj8#rLcma#S%M;x3VXu(ZR zI}C;#0%WqoXbg_x++)Ck-x3f)QS%7U#AxeHxV16IRmhqGm7_rn3Rnb^^%fMx$`8aM28I*>0?Gm|09ea- z5C=LYEG#N6E+qkGJctJ|mZ$tRHL$I+e-@yqH$lP2)aP+1;GMBf*^>Ti#-r4 za6GF8+!}$F;rd)u#>I-Tl;P4BQxj6NmxtRRZn|ON+HUGPFgGMj%92Y?_Bg~v+685g zg5#mAE+{)RPTEC=i=v`5P$q)~xmYPAc%%%MG74%9mzE}P^XOnOcC26_0k9AkAOve^ zC9QQsaZ3bnC&RU+tFyDSfU}4I(9s0JQc_ZaLc)T=!u$XQKh716hq~~iacqEu=gv&+2yy~&oa9gBhBw4=Dv5+(?@gQI}>#Q{cx zw+yy~Nn2sCC@5YQNKw{sL0fycHAL_$^qbTmJ9tNtJrTI$`c1c!Z>4VlSx*Ong5N+` zBk%|}xTP$4$Aw==lpidiv%L!=ASA>qBn)iFKm`ASiR^3&N|D?Sl#s9o5&~Su)gCUx zr3J@f9I-GsrQU5qa$b~GgxcZYTT+1#03d**Z3;&;jMB(^h_-=W>C`Zma9QCkA(Wf1 zI4!uPf-9T?+%07ncsDKH5etOWmLx4Wqj8ebm6s9`mX{J&1YZ{uyCx)cLqXz(5ct{+VR`WN8&Zmj zqFajY(d?UI$$opMb^n0cK~p;PW>oFKq~$So7%av0A`rp-z+~5cg)7U;-^OCBfN@9` zr=@hARarq^94sX+&Mzzg^znV^_6pEOSi*r(QW1kyaKQteo}3@#wSd6*%DW%Qw>iiW zyW<*rECPqZ;eina7^*3~9}NYj7(lLv;{+*4M;y@Gu?TA$AR7QyZDj)>SxCcx&`02J z0kf3N4nR&q*P+&wo6U@Chmig2L1!~-0VI@YrlhbfZov9KivNZbheufbMQ$Nuc0~dC zR{A;ukGl=WYC}=>K%-JrBip47ci3x}@LpPdge5>KA}I;z_LX!Kx{p~Mfh&`4zZ!Ug*;Z<(Sp+0z$ET(nj4Tq4uh>| z-HGnK;7yvnQLtYfo9%1(7pm@te^WmPrd~V!e9xG=J*(SHegK)Y8ZZ~LgyNy(k#%b@ z72_8I>uf)i+8#;&L`(K18K?yeEPw-nacI}wH))TwnOv;C%H+xp|kBefvejpTt^ z!3mhLuzj7);qY4Uzv%e;3joM>u>*Ffsr`NZDAZOqq|gErFf1^*S}I~OsQ+OyK~YWG z5(9H2&$5(n$N~ZcED@H{^72C0>#c-bbLJb~paHru&E>#|$}4)YNvnjNJLip+tuM zlj{D&`c>Xd1h6{7y8dOSM zp`!5Zn;`!^$HFjZCpZ?^(b!gR%i(+U0;cG|3>q~i&1AAGZ){~I<$W^j|LB5E zVY^s?INXw_@hu5+@zRJIih2!8@@}LAPQPhEcBYTaq!^lb*ELn}9tiXE<*MNDx z9jmaE1S_imW$m#k_|LrGG@iq-xcx^{7@!sw%8!LQ^W)%@4My^|gV5H{3b09Ev%|un zmafMG!NBPMCwW_(J5p_cln9swyJi8$ZrXQ!+YR#j;r@nE>u(sfD5Et+ z8#^c#fp^`Z-MZb8OI}Z_V-PqvuzlgUIerQQTV)j1eQ;m-C_{k`MhOdb#lfJ!T5M}C zWRvqx*loreaA5xy7=5=D+PU8*P;jxw;DCKtSs`GDXj73L&^8UwTkLL;m%Q6jz5=#M zlz>fc^sXS=x!*&uE1vDrJU9P@*kN&mU$eAz#No*^(rXTm&~4KJ8~=dVmxbKt_GO_U_GO{CVqcz3 z%)U%&z-|PvwYx9dHf~==4azqBzIL^BN^E8EuZ({n_!|lP)Vjlr+vy))B~x%Dfx;i6+eg*BHmHra{cLH|jrTql#Nlg0@?8!`jP4IU*Y-gS=>-Q6~P4ahQ z_GYL3r2HX0?MJdVKm85G-)XWdL+vMFcZ%8%zc)wy4aJTQl+L7s_!`@rJ%Iw*ON5i% z$|-;BE5JP#cz${V*ri0+0h{5Q)3&|PU82ae3YOMzH7Jh!Y<@e%ZPD+>{DDae3cUXS zO!R@}$DS&-;5*dh1gL`n-p{bU4h5dZ!*=Eel$@~_vk%iwuG`i1c5&`O{~h~R@9&uS zRkIx_yQuaNw(08q75R$$OU7Now_WyCg-!llB->&rZO^~8p4k%!fbZb)j=+kQoP;*F z+5hzDMTn0TsB6FNc;I0qt1uvTKb*KIAFCMfz-2!iSXh#e75MoJtP~&?c&fKQR#;37 zs8kGy$z75+Z+4ypkhAX|%P7m#9SCJ%uD1E^jXdz~5f+NWZ^qwVl5O|t0h?ds2ZdtW zVY&_2AwXCFv)!*@skeRqm0-U?=MO{CzRUeh%YfH0fEP0acV6$Hv=(Ix^#9}!IVAp% zAOEf7-*WMP-SuC0{aYURw}}56UH^60zvY2{i}=6M^ub*84@8ch)I(X>F(F4ae-hzHO3p#k<(4m7=2aZr30uCzR zGfm*j%X3uczXM-8a^ZX6>&BxOfsY%Xv0Z=prjbT#N~Cw1UEbo@B`n*uGIdb-Ei}Z*9}N&vq8@^r!!l3bp7CT)^e8w)MMIR*@0^V z^t!BiY*j97iOa{VTcU0;E?}vDq@DSm_kY%|qv2}_V`ZZfX*c7=Q*OH^lUB7hKrj1O zOU)t0T*>pEw6p=T&J)T|m?Vf*Zrx9CE{&)u4u?iBFzlK zUd!@2&-1!V?yK>T7wsh8OY7%b*ph5t=0?9%obGk!Uf`adooc9Bi{m;2fe=XV1C>Ah zN9~f@05P@svz+WonM?V_GyT(&-1JCU)dnaSlIqW=a+=NqIxw5y=9E5MFI28o#eyRo zk)mOQX$$&Ay}eN=h8UkotYbORKWuDk@G<}L&}lf$Lj^BUl}1x{E|vB$H;qW$i}4&C zCuf%?B5I@;i$+USE0#-SkgAEUM_#pZmLwF$63(y>RKb~*Bxw5U`}Gv-e*F)&+G+U- z>8yy}#mq;#`f?#svbwEc(lqCcYiZmknGT`;wg4hy#7K{As}79;|A#I=PJBPT(LJrw znn&mFOf}+vdjf}K1b0ZTQU6kH?HympdtP<^dJ9e4hx=hRm0Z;Pjc+O%){q3>6@`e8 z0F%zN^%1%Q zDQZojTAyh@#sqm!D5X`a2&9{F(LR_R|E(tP4U2jUr0{WQ!3*U<1BrpvRuks@FZFm!=0A;!C=R-2DZg%I;^nYr zTtv4(bVABVvolv~!ZNIv(Ik$SxAj~XacbamZ~>D_X}r5>>K8}r2DJ}{%gmmhu4ZG6 z{n0M!&kKP>828BWa=lB?gZq_bp_#rFvVvJC*UQEQ>Uj3)Ctlnw)1_tx@csw_OU2jAEf6M7;Mmg~@y0JxV*S5Hk{Gb$d8ZE;> z^Jhe2>{uB@+Q{~TzX}rBPn~tIY~)>FzF2C@oapQR=qH1wbP7}_CK_U;Di*o@@d%Pl zWO|BfZD_v2>Ku2qSO?!r^=4kH9IMz1z0sYaQp4Gl@L%PV!uBo<7$Cb=Nzrc1rf1bCX~8#zn=-k7MpUgs|;S7IUx+$yW?3JheH>#I-j$O1kpe-SM0 zAzEA0_M35vJU9Dt-g5q=UhkzR&8oj?=Oheq=J($V3BCm*1aziFho>CV@xbGw%X~I^a3!?haiH%kPAFd1iq@b`|Ns`ez1-;r#-he6HXY_xtzjF#O#5 zzG~{+acac4aN$x|$eELw+(#b$L@}67H|_YvL9K*PC{1{eMFAWfl_c5W$VmGO#6fRz zXj1w{T!f!W>X_iO0o-bBwTI-Ij91`P(uIbQ4z(QE^>co)BSXTN&b9}EDlN_)5*SJB52GS{Ps4UN)Nk*&mgGimt@E9XW`F0%#31v-!_ zV$*_4a&)q_v9*hk*jo^HHxJj(gjb7BfkDp=ug%Xq@A(v%xVSjxOScpk6262@V{>Bt z2d%ZuGF0qkqyyndR+nsu@RJ=9tS#)zSm*0ucQL=8dvOwXDb|d=DE?jV7*|I12%COp z25(IcZZL1jG-PaWefj z)l-~p9Ii6lk-9!%GU`KOCdIF7d51<_r8IbxKJ?Iy^UZZ2#q*NqhVFW>E5)RpnzLwz z9)3Al7~Z+8syo>zJ{U25(lD;SzPLuu9;f z@r1zXPET`Yb2MWqmw=qAv?EmS>NCk|_aywS#JQo@A18u-1gt)nl{GOp=Gyi;h3+~p zq%_HJrk^MgW*!*GH~G?)%NJtp-go(z_IB>~x%J|i*-O?x&v@#2e9=z+QY!J5=!5=Z zEQ;i?R#2N2bV5!q&IQ)-eRcWA!T32Myf(oszj)OZL!JiWB1>n(%6)Gl3S8{ zX>;)?M|tGphnMj(9paqN#tl_jFyD`9&nMD-v8xD2^Aqs{xvp<4QPqY9hZmu}(GL+&cee!b#R9*_mAnTgn36E&mY z9=)#2U?lzUIyJt}@PkU421YQ`^H?V_FEjpnQz~)MHPoWV92ON3b?1iR!X!U`FG@x9 zyN2opubU6S-b0$zgT1{$8C?XdVx>ZLB}WA9#D^yj^O$ceOm(-!eek`&ORYvqen#6+ zs)ET2#%DJyrAi^REn74Arj1N6Gb%ujgV^b#l_)A{FGU-?Rn()?YhO9YGu*j`+7`piqnj zDR+*~c%%zy00~i!AlP??DNW4RV2GuzL1JJ7#9=+>Cdaba^(Cr)UhYB{k`|$#J`mL> z)yr&V&L(gxKg{j1J&Y*khj|gN9{bTD`i^=mpG3#$Tcr^>SBj;>m@8+;o%%@;0_~IY zNO#LYo}LG5b=}zPRj0JUEaSG|b^QQYukSZNL5NBV#Tcy^+aL!-Wi5oyvQ%8hnlB*O zFT87lZ|G%OY;JdTO>|VP)=2dO!K7%u+`V=7~K}2z=7A|GY;*e+RVUXm0{*|bq+F~nkIK(ZV=T)B1D7R$4Z7mypQ-N8B zO$**WRy;kY2NMi$;_HwrQ|Eu3HzQykU+IrhBqUu936O#d*JlVl^4C=CJ0GacINgcx zG0HqRlc6T3bzgioqC(2@Rbgg&M=nX=q86Ha!h|{h)X1lq`gX^xvqe_@6;TrbKC^by z6=>|3c33AVdSH6Bt6I+4H->F}|bTv5CUB-0UNLkz;;0 zWPs$V4=G`c2baZ&a`i}OfQo18tG*NP=Bd_>QDAsKTGlmQAMH|xM%G?(W!*FeY<+VS!h>-^n#16uAy18PTyG#ZN6*Vl zI#jsp$d9wH)VY%dL?+)AyTJxK2AbmLxh-aLlarN)PC($Z-kO$1bA!?SyhATzwQ{O* zbpof1kT{Q_L&Gcfv25ACB~L@p?yrL0S)-F07nkj^gT>K# zr9d{7;W*$A+e|loFip;wKh_fL!}4C#iJAV{C;hwa>KF1L`Glv&V#jhd%IK9; zeSkkiRFnTQ`yPfmx1~7yv>ArxMgL6liQDmRojg_a>n1Y}W`=RPEs~Qm#HTLiYDVZV z_0z{Mq>KftcFwO$u=t#Z1cp@)RqGAONB@JC-C+?*qtOcO=LjXK(PZ04=q}Y(%^}*y z^e>K7Uis|7%p#Dar<7k=SzLJ=$8@2}4MndJ?S)>c`jXIgQ;B|Jt}Y3i7aCt1((BInu|98< z*d2tjDc8-{NRo=E@|x79Z(lMC4|lh0k#=Y(D;Fnr$1|2W!oOIKBu{(4R#FP+KO^v- zm*h~RiHw8ANe-7k*Y(Ve^?g3+hea^FR8NR>2u;`kT`$os^4EGMjdd_7vwEeiXDWzp zUT%$zc_;nK4?O83xdDo>+5i!gmRsW0gB=2f{1OT`Kp&aw&m#$O&KQi;wAgHlk$Z4x zAp{>-{(X_;^oa5ECW3V1IxYXSPfZrAMf8NSv9W8grY|z>&biDB+Gtr-eo{O^lj9gS zI)&qL)s?1#ROT$j#KL!GR!d(XJwbMvrKb#felNKsow1zO8YWR_$a3bvjCEPEM?Hq< z6Pp_RsNCS@mss+jiu~tCV6whbt8QxMOJBH1VO4Xl1b($Yvn4z+LpOAmi@Q{Fw)lxj zT2CKQ)4}?>C_?dKw`H3-)X%Kh)|dA9d6zpMHA?vBR{EIS9{A++h$r-oy+aYsI3au_ zAjKBpqo}1%3k`4AC8x!BRov6M&2@tF6GjCbqWcA@`DB3|AYuSZFBgV4c+fdkKk4() zvB(JT+|j%U+Eb)>!^YQ1ghFtr8S_>5nTG@Yq5U=0WrSg`W2PM@#LwdOS8bge1VbK3 z!SVghO!p#Uo86sMUk2Ri5-+z!-EZkzQ-ioDWqFg zqoiBO$;RBZbnQ`IM>-`(g$9cWN7GI;SIEFcdrmmObafIs;oPD{y^IkA%Zbz&Wrg&~DmUQ^2?f`+@3k)yKdId< zoosB$cNwEy?NTvZc;b$2Pe9gqk4y&lC%DI~LW+pDX3ACbV{E~ZMPU%h%FMt_wWz+L zMw=^oY6>c<&+puQ;^uZKM6bm0;`7+x*~5(UNVGOZEKB z;pm4deH73kt~!D_M<;W{$W|3?5p+*1c?a z1ie#f@shxFJyBYpCT%$?#_$@DeK2G5qejNg;Wulmlzq4AN3GA}j%y;V4V5ENkOFEqS)e*Z(q*9Z57ve3xPpkaSCk$lQHD!3BHi ztjc|eb>=z0zM6%S!60kh>mSi6ayky)wI&evCPru?C;p?I8HMn%ipJd9o0qR(a>7flU8w-i;A<#b?2`^=zrpb%Q z7{jQLtW_K*1-V+T^Zr5{U6fJo4=jx~goVYZ<+y14YQ{K?j_P~V47riBGQlO1{me|W za{P{|@NArh<#N0#XY_{-Y;!=vxOLT+W;U4%nqi8`YF9oFk|qMDT+Kxu*l_IIz~ZR?{@Uto4F3tBgRGir0>Q^GfC#Pi0js7uTxG(!t{63#2j z)(zICy&jHvr^{Sot-jED0y{5S7#l#}K+D&eQt0Uqzg~Yf;l)bMa!?#+$pxo{9u37r zy_{4%G?H!}ui(YG7TK0?hxb+@DosC&u97npcJZ|c@k*XS`+^4bh}X!nowa0N21cip z*3#YUf^WsC9>+e9dUMFUjKas(!EXwtv-7H-U$@sM=#6lcS4VJqN=|q}Aepf!m(}3`+Er&>rMPXhe!kD1d+ouuD z9LQ1?*{yzBBc%H!D#Z%{RafWXWghP#GID-!9gJ~kTlV`baJi;~bZpvnroeJR(y~hQ z8noBZlPDe|drORm8I_Hz3DrN5q%tIJe7U1xo{7P^{k{smZ>lek5Y1U17x=E1erCEZ zS}*^Z?J{%5^*+2rp*_C1VEuSbw{d{Gqjh3jpsaKl(Z|q!FegTC1C(zURY*uMxtbWk z;NJgIb#S15Y8DQ`Y=Dd!BHjcpd9B(_)rTeb*oqBV)4N89v2oY)Hbvf{^|C}X2oVFT z3f5%}hvMQ$opYB98ngW`C+4NQAXZ|TB{a09a!YbMMMfhVNDHD>*)IwTW^s?5CLV_l z)FPn)|6-Eq$X8C!yd-J<%qL1EG3C?<(E=@aMlcjv8E0f&eN{QH3&j)#zu)6vQowhs z0*=xU3uU8M>g5|~s2S9s4wl9oaj_cHEzx(rZJ+0>HVAWmGvXoV8xEwVkvFrldJvZ} z7L2Z+j>Yrv^S#U~Z@wvX==4$swL;f|j2$odb&>A2iK(>FbXs#ZjS%H)KMfR^>L*qb z!7<+%^+)%Hl?#R$Xe0R8;^~N-xJU!llr)L<$@qAZAN^qNw62m`ytAfsQ39#&M1gnz z6TNO{deYNGlzLT<0NAIck?nn7-(%uYL9v=qsTk|GXY;~8EPv`r>6cWj8%ktSP0dZg zgqk!*nBB)kJuC;F>J)~;^wR93s_grgWAz0urIGpvM=o{7?Q zCJRFK_?|O1k$0j!etSo%{X(>OtuUK*!e6>+IXbzkk)X#94Y|ulli* zs|DgHH7UW<4(~tDmSA!lSyvOW66AhAw&h7phn_d|RLf-(y!j#uTcceU^#wB0)#XN;LXuVy@_;j>1~GgF z=9FS#{!<+lA<8S4lS8zMxgzZmIRy&X7&e9=Lc3O8{MFPCCf0&EcWS4m#|jA&oXdgy zD@rSxSyn^xw>nPRCkkY^$DDQ!y}hE?a-}tNtXd~D?rNcMNs>WvU_0T7Qf-=g0{cq3 zSg^Z>dc~W_n9|btD>_=P_2BA;T4KHivzt>>?tMvV_b*a{>gR|N%)p+5t3-QYBH{dl zvMYnNNA;v8^6G1ZQsV|%kKej#28;{y#Tj2z5hly(GS8a2^i+%)OdI2J*ZBA+ET@0z zlUm>hD-@c49BH%_q$d`T0xw4iDELjluJmTccKNT0S6L;ujGJiNcg4Q@kr;By4MmLY zM7@*F8B@`vxe)>xPmJyQ@bM07w>I{*wxMy5k9OWHDSbu8dwA3rQm%R?fPQ2HL~r-* zlNTGuo6(n#9+fK#-WxXiwp9!0(Bo;Db)1Ue{c-!%SPcTEs(&R@ix(eN)gN(G{nj9R ze9fx=MMO`Ytu_+#ii-tqq&?FGX+TK?mKRv-#7JdKFqfB0FuYCGPPvkJIF8PhAKacd zykbzA^4x`CG_knMcjQs+SWn#ht_JkjOfHjRN!Ns7PYc0WcR49uQ-Y=T!zEeu(eCuY z#~csJ4>omI)hxFZ;xtnWKCJvK@uqOKSg`N1VU5ObF}l6_)*|U}e;$z5h~#Wvhlnu+yAS$<;I2e&v%1 z)>oiJ@8xx+tX17fV%Tal&~UKEGY);<)V)9;$t+s!8C&#R!rGHh9tCOn&!#i`V-xiZ z>Td~U%y_5P%E;l=n>`X4vSW=5%xgnT*dVF(7t&Os_1F%FJ5|oqCzr1nEPASxywlZ+ zhFwWq{b=?0RYUTSYzKZ#UvElbP=;Q@*|y(-u66_D1n$!$dZ~InvrSl7bxbtq(w|mf zy4D)t%&mN#P8sjuy2v4p$(?vdqjJBcSv!4%Ta2bF@jYMsRKp`d^k)Lb;W&7%fR8`J zgNiB3XRaNXuyKTW8)LkpG&7yvU^}QH16!pbTo~x#RvDRCm7x(&Ia^cn+wm%dt3z#5 zN_?{g;Eu9DuG-6KZZEYy>T!R;cgsEVBdSfR`>H1@YHB6*wYm{PJcOXJNH@*9qy9-J zg=_Naqv2}(FD00`g^O^8+8{Qdr zbHS)=UG}=#P^uw(=VyaOSoVOEC_zX!4o8MpceCFY}&^sY8rtNQZE>Y@T zOK2R+F`sb7HQw@kPqyaYV`465Z-DfQh2qzhVWFfh+4^GcghdU3^RttgE@}4dY8*{q zvp1I}MueKtG>lEp*MuFqH0KxK*ttCB7TR3FG>mPRHC?@M_WEh0Z>nY2Q~k`$eia>V z6?dE@_XK8;YweYFAPaJ^x@i)q=4Ez%jH>r=4WvMpVf|AnI3lDzYyG2*vF-cZytt^N z?D=qPnZSVQ8mIHb=(1J(ms&C7h{r=IsQFyScK*xZqoUn6$vY zt&`j!Pa2S|BOHw(792WehJEqD28-6rE>5ErvT^v|mh?jh3N1jFEL0XAm*8RkTbuUvpZ1 zc-}NOXdU=;TD|jgyHkj^=2VNWGT$5P`yXk$L+zK3KAPuYrtvL^Ms^g`_^}DF2akQ< zCL{gYlVxxc-|Ogg+2Bp{{njXl!>!{aL}LB<_4s%6tE!Lg65kq~YN;C`WlnthqAWc| zy@D;gAPfy>QO{CuP9?q88Bx*$QUnq^s(*n<&+hH4tg6{QT`^kP$PQXl^{PukCO!Y^|?npgGXhowEjpXqfe zD+zli#&p-q4mv01V&|rMnAe``PalbsZk;LcPq19j9-JgP#+F4~v^xbf_K+oyXi8=@C_TxVvJcr=@t#21tbQQ)I7G z8}CEA_D*;}?C?+n=b}Y@x7KKZxjdf}iQ~RhjlDzfbZ56}l0-YdLiFb@qE20QT;6Lv zapkJz*alBcMwV^_q^xP8HS?DRGs$mi?E^FB_kXUnRduw^562E(#rM12Xphrv76ci}RI&6TkJ?*cYJ%=oR{0g*qO!t{&ydcbIwJm;E6|vcMBk+LR7q zOErMDcj^+_;zy^wH$Yh34Nz6s$6?&GLWVvMIy5xcB^a0}Fdr+>*#IprS)&PhE4K@# zN{Vf5#f{gF3*VQrcHRK-om&zl2y#_SWtz=Sw2n@Oi!N4mo7WRg!_Nf9dZn22-pO7S zmV|qy+e*-kcAKbVSa!za%(+o6y*C8?rhdsc@-CfK6pm5p)?v7Z?Gwyr(GTt*o!kJ` zlA211#aH#$6lq7jhwEjNLgva=;~8H~2;;xB=ZrN*^Js_6Se2NsnipKvSBrCQ?;g+S z>T4H%m&6xyT0bRs$@0_7MjppRNd>5sbqB}GrUEn}(Yu6qH15jBD7CjQY0Kh9;W2AD zZwTsI#70TxM47JK?IT-JH;R5zStvePy{JFst&x z8uxIMx`y8>gFM8zm!O?ts9b69TQGCW-~USQmGmIn5B<5}aqx>#x64hNxbxac)ur!q z#c+KcsXRQ1S6!-tjSC_0^@$L_9?XS7oME-YBbJhm@?tLaZhuDmXgzb@GkmsES^UdN z1!>O7RiXPOHDM~D zxVo1D?}SBBB`Dtxu0RWT5JS6|YoBC@2fejX?|D%kp)l)Ge%S`jo(uFCyHY(p^%&t^ z=J>jA>S-XM^l7!9RUJ>g8@ijv&vUWa;$^tXZ!NGc$Wyjr;fEGY3bqX|1I*x;8mV1} z9;FYduV@>1&G%}_tqUufW!y3UHCupBaQXIHhb4@C2|kFo4s=5&<)shOhP?}CO4RbP zEDFo@5R#vcduZL;C4G@^q%c2hO|+LL_D)YZ@8n3s%=(Q!qL(wtZz}L3Kk?ycOG$Hz z2`NQOD@-N04lX4-fc~u6Zx|RDZq`;i&UD?uK12IlgmcTKycZ+wF6QH2%1vA+9*qjP zn7YZO!ks;9tx3gcSDF$BjYyAXUbJ2U2djzF5Umnl3VCZe zJ7?)asLl%u4k0H|F(lC-*AKDb?^=G(GFopMgg_CRC_J7Yq^< z=_ZmAo%MK6!6CRVx+PS8Kt%iHN4><9Q4jr?nTQ9tZ+z*Mn!6yQf7zt{Bk{ z8HVxS=F(@a*P4kM zaIw~eLItm2aJO?33)jH3hlfPi=ee@@-aBl!3Rb$h29VA7x@f!?ik&1{B}H&3_(Zbe2Qj8Lm%f26eo37d19PJ^&MxraE|*M zpn~v4f=duennuikBD*n}L@R=9$t6>fq4Ci{X;JkdVTfFV05KlGHWd zmT1)mF^G2|lHRBzdTSt%^>PcNocvtB@0i}J%?%Zxcy0AVj@IePkoXf&p+Clt^yFr~ zwduI>p4eNZGJ;BapBPq0V5tjDsPYc3a(0)q7sC156zZ}Ss%DSEvCBpZYF);@`jLVj z`UchbWsyKg`rK#*{hicNbHz`HoQSf5>Tu)Cbcbe`-stJ5q`?L&y}{Oq$6s<(jhBpD zwbWf2th57z`=OQ&OO6l56;-pt9dgj@T*Sz?r>vBwvD%g}L9rqZ<7^7!a{ z!phGKiE1xUrRG2KWa>W=tO(9^d7u?VOoR>Bqz9zdcnA9u>u4qR)4etYU)?{0AlhPm#91rF%IjMZ;3#($|dI%dPw#!EK1$z>57x*50 zye<|$GDwVl{cDMT-U?5;Oe1n25HS~~9xIZkwzBd(%%)zfiyIXVp0XY&3uw&ZX^O&j z<_mm$6Uc$|PAw|RS5I(;R8B{TVP3`; z%DvU9nS3<1khcN4Wm;MfE$Hu}nQlv1Lg9Lg(q4B}DyqkbE+Iq8dWYg7ma!h`;AuAq z^Dx(xd&BXSJKqrhN2Xp;Id<7ec)vPKtxPCX{TG|MA*zn!eEKskKYP6l1~`V^Jt$#< zIWfV$J#bWCxxMgH*PHy4Ls?X|joMNsMxQimU8~hT+bBm%`GUuhM_i z4`(aawbAzrT|f=V{8GW|-%Sb)mtm>MADwKH&1fR>EwlJn4*?y+#8ZmDJ_7hh`u^!b zr>E6X@heT(tEkCe?;Ebnto`&*4+EK53!(utsR&2({%ZP?2r2F#I0>hlyO|uQ_eeD? z@Gj47>oI=APsToX&naK3?Zhv?w z(uu%9cRfkFMs59VU64hWs#{l4s)5FG#8JB!?U;{ihQ_CI%iB+v)(xGYXEP)ll&+&an$1<4~>gm5r`xnSEcr4kXp^UPRndm0I2wXJ1?is`CMCXfBFOVgWXkI z-u^%87Jm^8Pdo$q_NXl&qCT(^_si~Yn&*m*Ae+ewm(vh< zK$9GpJCNpglkRu^jV&ZpM*rVHJN!60N~}LLaj6q^)e#c+`33(*l%ewE)8-B{XgJ%{ zt0g-F&htk|>a_jy*cVqKv~KomEJ9AZ7E$at$&PnQA9uCyxb@mM9%fa}MXzmDV zp_6qGbsQOk``l`FziMY#R@p3bnG&ShLXPeRU5T+D2ml)EC2v){kea>n67g-~O8H1L zKgI}ylT2Ic=&P=adRSqroL{G5w8Qx86nvcp_s+K-C#~m0_qBtIa$-z}eo8cXV?7nr z8FQ$>`GM;BXa9_*keg^ilrf`j9dznlwL2uc%jdKR4}vz=P9Xe>w@#8`h3K3vuTGzG z^(x%ld`i2pO8xi$?d1J)=?B*Z4>BX>H%dcsVHf2Y;32c9KSJb3B8b%qx@wJyLEJqJ z1(tTa0mw)~9$L2>`CT;vF}Q-} z1iMAckGOGi$yI}$lZ|_;icI8T)uSpk?~2T3m2K(yhL!OBxuh%XTF06;)pDf6U5v5) zAfIfM(kAMnv27W4O%o`SeHRq=By|bm51V7U1SwVYaE;@NtX~Wi+r3mAT7rPWO?uok zyh^^!&W(=+KiPyBoWphQgICWl_{m846FVp``!R>4_5LQ9U$_*NN27LGjf1J&IMwwEoO7 zuPd0r!>;O#eDGBIHlEkV{{a^2cKN71-S14+E7{mbc5N{eu6?bO{2lKI-4Fa|n>dQO z@&!G$(D*}{r$=3TBBGMO+CAZwN}L}ouDV3mi(^+o{(13)qS9oZEexGLdTD;xZMb=2 zta%KYKxdUX_=!b-y_y{|J>@qaV>|Xr8QeV^Iz2bTJ6lH0gp2<#=lr5n#6R*5N*qUj ze_MxTk9IgUSiWeWm~I=oraNA#ex*c^D<6@Rc<{2*FBO7Zq55h?M0XHMtv)hJ4HsuC zPREo=!x>W+axLiuLmDsQzof0f#DQl{I~pf@^GAQ&XnDqP@6Fz7T|26*`-Yjif6V~> zHOK)L|7tWghXO%nJ4c#P;sZocjGU1@>hN(V#@Lcm9Spehij<4bgnjcJ69zWcQ>_BcexgZ~5- z#>%BndmUD23#j$zPj$6b<-18&>0?7_k9pEJWGh>~T`kFd#|4*BCz#K77&wdeDf_}j zC2rOa39)2p!PCD^-G~Xa%IQ6N(zrfch^eDc839*w0Ba0u*O`N8y^%bo^la{=-rORW zDCu~&MfksZ!oMrq{zo%{^4iDz?oZ$#0Z}YW-@@(YXn&W8B=;M!Y9%f7jwPZCeMP`M+E+++#suv(Hok@#L2zk28oKvpDo`!)SoLxTLYC1ZeIRR9V*&R?9yuMN8M{Lb3rH{K0bL_f?zFG^IYvYuI9fk|f{^~vuL;NyQT@bSJ@ z&2`#!hZy}KMh?`DBCaqgUlb8LIQl|udcPXqWUpeNY_P()b__l&Gwe{M>`T>&k#w6A zY7L~L!)}ZoJ|?Yuup@d8|9Cg*O=1X*ltPRo^&T(o(|K2$N^4`yp6soz*C8`sU4WDo z*7unWGK&XNG`y#}L@dYSHl@x3X7W}|TzXUOjqZgLqo~<(t9Jv-+fV?D+znWZnO0nj ze6yH}3?C-sNqdCgq5r$}T4Aa+#4P`f>`E67B;$Tv>mS0(_@}i%-+sN3L0n$w0`?^S zVU9#P4NtM1Z~9nodXj?(NNZ^%Js^!3atnA%jpJ0Bvs9G(d0$8ckZH;S<*R%!ujr;QiPu2Ea9(H<~qPvSY^n@vr=fHTNViEnlw^V zR#!%`(e@J&VD9qN?c3kFM!NVUEAQ|Qb%@)pS87PNkhO`paJpQx%H0)0oe#kGJndSo zFg*_m^K{aOL^_SvF(V7IF>IlhNLa|m9*1d?Qobhx2EO;^m$NAY?(RpTwuyO zrnT@SxZG*ea20m~fAn>25(o^DS)cexL=PU_NKG&>@`&6SGlW>Q`vt}+I>z$VgCZGa zc16<&cP$b!M_rUmd`tcTp#fG}K9Fwz7(|^Je>5{!ztw@0VJ^y@Ox-44+8J7~6B_#a zO>ewTC?%`7mQ!8Y7y5lu9GXgtnxtdRqH8ub+VWK{n7rAW+s;3?_tj>oxY)&>5)fL0 zS5jAt(T-}CU+~}=3mY`k*Q#df$z1s9_M)KEmaqvEG83r#QSt9D5I=Pkec4?8NiXK- z_P~JOEleGZDtlz^m9u80;FbjxoAT(NQH|nkFyx5sXxRpm*NAW8Q-!eT$;-#DT^X$A z*vqgr?jZqY9MXiOCl{|Z-L<&)+}0&TkS}z=L}av__=sBa72fq=M^y;Y&Mw_Xlv-bU zmLo_$DH^zYHR?_8x69Kk!Ey7dy`KOysr`(0&I49)(+iQ71ucd9oaV}nr+ZXm_!%3I z>U4+9?ome`;C*jjG=EWja@th6GpSUn;UGiUm^dDqMf`&Tx65QR+1+Kz!x!h;r<+P0 zN#l=Su%beR$eu`RdEjBXR#i>DT1-)iu*^dyg>K$*-0s@bF-(nhJ1XyeZZ5vVU!<;+ zq(NXHt2!wp2z`1d_858871Y`O#uK0c%R*5Sbf>(10>@X%xTtPx-NiUfEwdVM2*|ZA z>djVDbrnsDW=S(#IwZfDRuu3=#Kg`)Srs7k+&OvJYo6H&-b~FiqR?bb zD-&`1+62~}0o5l&1Y9dp#aH--XjjYqC#K(oz7mntxer9ji#Gl9 z9RHbiqq6lU4d6=S1{$ZjU^8Etu}dlY>R+H{G!o5{GdnMKV8yZ~V=E&Zj%$Gu6l*oT zT(23{_R-{Ak?6qLRvmf#eP*jS$A{l zgedp>O*{?t%*<3JsX!ZK0d0C4?t$B=oV?uC(p$DU4{~tJIbg7Ixpk9q?I=ZgvGb_Q zsbH%0bVJ#pc*hfzo?`!p%)3xNJoa`(z*|7|08OqW{HPO^h!R5}voj6?Z#xUR)uxqH z4?!Q^0#Z=0LE?$gzY>D&@4Ve>|LYVaf33s-z8zm{Wq*-3H?tiXxEXyP9K*%Zjtlx2 zELZ2{fCfi_#}U4H)I?P5kz92!45Rr;j1p1OLBr=(XZEEwB5^^{PipGv?aO}`J!i<4 zYF6jx|81ow3{MkEVky5+Ac;#q)+hMfOoD*QD;3IN>@D=_I2x~VZde= z@ruX4(|hvhmJe($9R-ZjPIxg!#;_Ma!Gm7oE0&eJ)X?BJ)8e{A=A$Q(@z@LNbz#9v zp6i>3mF!&#jZr3U*q(`VOfLMyCX850Q>1wKnqIE=`AULXe?YlL@JV4`Bg^%@`HV-q zi_Z_*`F@_U>Ao)3@i+xY zL$171Vp)V(`eImerm5%FSH@BVRF=QYJX~|2SDn7 z4);NWo@6K~=_OcQTs+G3RmeoaK&JLpLeHF7Jw-gx<%;{~#b!aD`53AjKCrGz{p%Fn zIS!K!a~`yvhG>2+@*NQHfKPUgqcR_$s&h%G{5)-om!D|jQ1_H}*OU9-U|RNe@~@#lY;X&a0A~qc6#Em4PRv z+sK;%4L&bjvq2RXkG*8x9oaep@!v2lTVzXI=`Hj5XrB>M;xI3=bF!2}=Srtl-L>h9 z%1IFSsWg83O8(&oD?0A<``MCZl-7BWn+M(=w(P z(oA%cNW^c}J!W9WpX0+h#1VC!OcS!<46}x=yaSK95M92)*3!98J7R*9bZ3rr=1Zj< z+miyHe{e^QT+@-3-37V>krU@sSchiG$ra8r3n4NYAxdYmdY|MvPHwxti4=U-018^` z`s(n?je3>$rDw3n&ebP$DP%AFqbuFTO0KwhO&>#um;u=K+(f54yEz8D9Dz1ip~P|^ z|DyKyWRB^vV8R5vZbl$&4K#A zUyk&+;x@+>=}Okd_P~R8mUa*6?+1!E=KFp~pN9;WFW5OkX0-OzM5V5g2`hC^cf&5E)IW7jqB*9ou`c3^*phB|fzg0x>?nYV+#&o59b;Oao z{J0dpqJqfvwOFv50#1m(BajIW?`xV5LL_axEAr|)2*Dqd zf=v4yA|CUU-xR1tRF3Xp);yzp>nmvDVnw7@D|fNL4Lgo>t7#u4dH%OL=2E*$SsdHa z(TiF$%h8E(Gtj|!dG5Oh&?>m(v8vNW$`q-P-uITT!_1QsGz)b~+prE+BC2uI_X$=E z@zZLzU7A|b83XuZ0Bnvyf1LZUa&G2?>c>*OEzACW#2UXSgk8Gg8LS3?bJRH-rl*OY zI|JSj6H$%Q$e4GSnqCynL)seXEn%GFp+VN}>4=I z&g^IB$J+vE^{*MR#S4zR@15?}sQiGy7PtC3>~oc#z3_wif{GRj3WyKP(vrz zX<&HE3;^%Nc~UQ))y6z~V1Fssur-;8mV--w?sM`dV|m7p-hF}^tY0_n6XgD3Ht0;r zZ7i=^hF`sYS~*+T{M2B1jG!clo%nqiRjl#@C`x)!Ks|G{#O^EjHtceY*0;5YtPcOY zml7W|Q6>wq#U~2}RDLz4cQ4hPr#X`4Jh~BlXh{UL!#&46jkOy!A5&Cu$;^Be(Snmh zL0nENy_q3AAh94~Mib%1(=aeNqO?2_Oe~s`irz6K2V`zr<^GA6`RW(p7f=hWwjUUi zHqCU|>3EEf7|0P$8ZW+ex(m$iqxCLz3D>EJO`H<`^uzr9Wlc#zbc0zR6G{E0)ZDVHO(U zofjawQSLv1mPOB&@wE=uTY`o!e{Wy14|@1t3n2i1ak-#@#5aSMrNqnbbZw$R4Od!t z{7lI&czlXqx_pH#3`$YuOwm4_0}dx&=Qf{RN*pXi-H*7`y42q*II)18ZX6`TJ0dST zE`NkwY(R37x`n@IVp zM{UU0phyR)1-qKRC*>VrW_Jd!hB*ahjHxdMlVrazGg%DA#C-A%83dkw3uF-d*Qxhq zoo(ubPY%8t^GfZaEn%nD^t6|kPmOd3 zgAoGD)w);GR2M%pD_zFyEiv4$`bu+`IFm?n-pgDpiCw(lh#k@D*NbE{uD8Yv*X``?%5*K;WaKYE-?$9AU<_)-ia?$!{FZ}Cv zY?qKV?S|!tW45?INh(dzd}N2KWg_w~7{*R6XfeFdS`We4xojo0_+J_8+ zw6O~c8k^sXy(X9raFhlOI@Qp(>1Z{UI%^}*sa}fDv(`)-AryrCOxMj`x_{+{St!#i z{%xhG8i%BICJj<8wn04G23YN%7)Zo=gx(vIW}^jIjL&=rgbUVhO+yhKR|YU{Ih~gg zwu{oHa`G#{=Q#%s%QmiazyCedoMCv645U7+Jo!#coB#`XnHx(npe%Y_Z9mv>Ld=YQ zwT_6ufD+CZSUydr{m^@Udq+t_FD?m@tFU>k*Zek1O961RV7wxkb8=eTVE3<6ZMiqn zHhc(97OqhryLs9pKR1BQzI~>YeoPB;YY?be1*>v~T)pjq=nSp5M*gSy1+)75xl`Xr zBA6UsgZN(Xgh%c_OgU`Kjp{$ zFozgI>x$ON?{hFaDpK|k>&uMCwI62t4s&eo2UJJE+tutAOy&Q}kp*ybv7@|U=AXLL z(6sUJOOBsmZ$)oM2Qvq^zJhXFki4h~fEBu0%WiChdZZEtX?50i2iuQDiVg-TT68s& zO)k*1>D*33g5AvctZvm>LT0sdf6tYt#TiH14jmk-+mx>z{!tNS?9Wl%qUL5q=7@^y zVQ}vbcwZ+N_k}h&Vb$nm&~qWCPIt`a6WYsN`Bqb8zy?;}?o0!P8L%Y>M&=rE?a4ox1f_3z5_w85JyJIkv=i-dno= zn&pFiy!eWcyruW1vR31<(`{2CTv^LTiA9z*yTKnH{<+tPL;JCyQRRMWlykA&iTagU z4axQ$CXyBde|X9L=bc5`tC%L@-$TtmS5FlUKe=|#sLv5bzfWwh{oFsY{ZPYs);1zK zY&trcBoAUOwjbswSDR`y|>Y4v*j8I$Jxf8Sdu_ZupGO1Djw zh(WlzSGp^Mof|DHgJCwbGu589YCJ>R{hb@!>V2glwmoXrLVu2^IDD_*$wa>re||gu zhE#iU7}SiA`Z~GdSmo+rG@IO}bZs*+&2sF>=1T%5cmmOhU=y4YI=E5~d)Tni@0p8< zfws=M*_mk%n_vR7{>d4#_c#ZcBiL|*u{4qnA)cQ$a`Y0>;sgT_THdm;qvXTcx^)Uk zmLpelbG{e8w?3M^b8wkCGgmNI=aZlr{M0A!Yt=cuOJ7~f+BIk*Wx8fdPlDf#`+)tC z5e=5nTyK;+KX_ zDyK)EF;$co*qrfs-f^J+3IGkUO-lm}oKj)dCykbfE34 zkhcNSV97FjKl7B+k!X;$ErbpV5AU$Zo`ttKW*l{5u$*@H@6>d!gFBRS4v6V6G@Dpo zl#ymlmzjO8Ug`3Q9UmUvTdBA1#YzE&^C8Tpwgte7Cq%VqZOH6=p%DpMitEv*?8|r( z5VJA@6rzsG@ziz!Z&;db;th)>I()G8I7in-=K17m-kw8Zt%(_|S|0 zM;&d>#TsA7JOkU}JLg#CDzQ`#v#&GVn2IMi(G=~82n?lVM;Z74B!rgD9Zb)honv5R z#2YZyM7f)r_`2r6I{g&L{%F@j3h8Igi&ZO;!>rV9>}6Yvb=)@p{iPkUY~1|39(kf> z)if{tvF+gy0}5AyUgXQKc0w9R9EUpKn-*PPrI&Szela?UrEmrE&y9Osh522*vbv4k z9@ZnZ>Q9N6M1BCIDdE8sau+b;|eRgDYAtC<2OhBwJIk0)K@ul|bw~_2Uq0Yvbw=BKZOhta&k3oA( zaHb1;76Q5YFl!!6wG$RVMB%#s1~4xc_0tAasaPoj13w?8-~Ma}H5)<4JE8_R}) zCS)XG77}lloHIIO+rhetbUQ?1Qbfk;UI*dv;pt8@{Lt_i^Hh3(O>m}PUPI+0#9%k0 zYFa6IS1+B=fy3VEOl;B5yw4a#={NM`X6_Iu3=@sbR=rmFx?c6B0x!h@cZ#l?4nuqu zCDnl2$MZBaQ;jXdq>;t>YF_68Cq@I?_~Ccj?U*Zrta0|htJ6)3^z|mYDo2yv%ys|W zq>A`i1mHnNMoiz7dLuXAi-g*G_xEhoDR91+(*R8==m&y1Y|qDBd44PwsNP>kzE^VI zXTVPrIx^;hDei#mEDBrnd!y^GR20;uD9Q0eS`=x3P7PGv<-|^R)AXy((BpqJs7=Cq zpeTTU&YnZu7pC}=V|88Pwm2Z4S6{IE{z(*iiyU-YY_?)I!f)Im!EU>>lkh>!c7X!X z=wA^Z@0oD5%^6X@v}GaLVnC_KPj6vWN!0{us&#zPJjb|+++Y|oMlRX0M?MoN;DES4I)>FOzu7KI zgj-qzq*5c&DqI=Vx=kE4!x0}?F%0~i1i6G&dsyacu@ZRPRrC!P71_UPjCqoSc_iS< zb4=;}4FElHP-A1aIzoT2n5^F7#(bf@-M`nr~wK&(2aoA3rJsn?!^`N0-i&~AUas!&eS%%$E9!M1g-ncdZ#rbM$3 zcN4m?iEt1DbV*XP-&Fv~b#%Atkc~U_-n!-bBBKhG@V(Zq-CP2`Qp95Td+_@nYnfQ?g+A}Y$0NZt zxuDWs0V-J7qfHd$R)g}>RNTTDz*{RxZd@ce_hI&Ez2~o1wFO1HCOA=QsRDKdkZ%j8 zj)kW^%+gHE!@J^zLnM26>t@_~Ru8FhOQ0*<3T7DP>hze}OytZCcttVvGyy1li5@96 znbCof2_6k94HlwEE1#G;VIg2QlBB`9wZzu?>$XmJ%}QO40Ae-}BC_zl@GZ<6$1#k+ z>r7=};rD-?y29gDPzTa?!Y+*Bha;d4$L9ijZJCCvwTHdh)r0pM^u#qwmLf*R{3ky9 z2A9)qy5V3w)nLER?cHobJ#W+N%pdsh@R)3wL+dfXSWS-(PFM>GQc?tGIA_)bCWQ9232tu9pp1XC6|LmuW`S?!4mTdABG9Sg?6 zt>(=Vj2+~=fRI;0^o2gbhiPCO(OET@1MeP)Yj(|3*9bi?;Wdog*i(X!Op_t};8)u9=1Ym((* zVe@QYgeNUuBvVJqq!^9=`L`hX9dq7$vf!^%Go4d+cQ(&1FHL<1b zGo!hcd4@i*20{KN)KcsPfUao63c?)F`7Vc*>Pq&meQA!MpyM;28%iL~!1|gn3S>2W zY0if+xLcg!_U&6_?vp>q?BWt^ndv)so$HG_=&bp(K1Rkx70r1uNdkBg(91GU5^Y_L zb-Zm&8DH4veXxHInQ%L}SD!kWYtSO0VBaY|t5%n=mXmn&n)P@vB5+745A0si!OJ7d zhSP=P22z^|R-z0EZa%YADn-o@7VV`d%Fksv4c!$6(hyfG*fu3Gylw}Ipa}p1XpdK{ zS*Mh#FE^hf+?~mb5GGBhOx@q;jA4BZV}BI|-Z>CfZ$&dGGTV!rx;j7%jmSZK&24vE zl;!#-1+#L=GBNtq?|731Dvu2$ji+CwOfGSO?3!+%%Wm&^Rz^x_dH@80^&hXuc+}4> zgmmge*}LU+y(qLHH*!>~htHLu?Cn3{a*?x~I`7c(WP~#^EM&*LHIEwTU;ZS0QlP5> zy-B&C1QHRF!qz=~Y+F0S`*fem_7$VdWt$KGj(C|im_HZWGo*TK;6X{M?m_Dg-S!( z(X-RUuL8G|QQ@%f)rcgm9(IcYiUIfoVwV4-#7cEG;{gHzk8Di*FglhnknG~g+O=pb z>Dfq(zk#?ky)hH~mC=4>GTl#g_!%*BYxjQA`29MUaDBm;@Q>$tVtWTKrYiLuT?M%xE@} z%5`P6H>dnxk{@@p6uBX-9x=6pSW~EUw!Se=+Y^tT*>ugty^{Xr%RC8Oqb89H-UL8@Re^y0Pb31^$a&JBXdU?iF zXJz^6*w%71a$^AP+|oC8!tvjUU+VI>CK ztKPHu>Z~Y)-GoiBVueBz$tnC{a*5DxQaiJQ!{tU)>`OYBnwh`&e+e}I58x^1C#uHg z+B0Y6WH(z(j@Ft&#$E=s11WcclIv;tYi_ta44ZVBoe@9zFVa%LD_1l)Y*hH-{`$a( z<>(rccaL#5Xq1pc{xIf>zC=W_YbJA+S5PuIn*4`jgjYqj1zpci^?h-J6;;H}zK!Xq z%Mku`>P$@f*=iIi-J*CqfV7Ox6hT)R+T()>b6eC$W_R~iSH z{fc4M5V;WjtZVkgYOtupcOr3=figwPSzhdD;#5k*8Y7Xb&x6U)ZN@g!HDaD=auj^cR~Ha#>|Of z`e`Q&$1TL5w?w_V9?`9F-4<$0T1dKHz!C@h`>9gy-w7n{QzjJjs{qv^0pVapOx`t{ z_?pwVnv*spcjFp^&N5Wo z!_c12CW0izgs5DK zN}fn2z@zv-`24{C7% zKw$E=*ZK?+WQCh8(`<;yt80K zh16V8E=jRP&{1VdEo!*Tx@z{e*tY)-Q6ZD7uL)&2^-1M8Gug*?WB{@4LoWYGBiI6U z-10(CY~LFkJgVzZ3k^c}!zue~um2bo<`Rr>d2wfXC-cV^P!jChB^Z8lpU}yWK+fX{&)XZEnam+ZEdQig?Aqz?>_u#O__AqXZl0&ZP`HM{e%s_e zt-R*6szKdcMW=gB`-*D<(Vr(jTdDucCq%LnPq)MQC8*03e=dAeeX#Wp`OE~LYerdu z&|0h9*$m5s)+@7>r*Aj76;-q(!OM#2e?7nK>nGC;_V4bf^oP7M5Ax~uTyU$xGYd)M0sMgE4$G+ z64`0n3L780pe8;4v2yJZptiL1RkkS8PqR60R&P#520UCl7P6^@`N$*0>QtrC&1^5i zSb0_Li+i*;2X%_w)xB7bQ}VCtlEfN!L{@MU04CBR6f4o4y!@Wa!v?Q+{GvQgk#iE( z?t%k@AHE0*k?|KBis(6~&8SF>tiYGF#%b`RkGet*pS+_P=>l8e)84=z$(At#iLC2$ zJ=vtww0&)sR*LbHVj4)&a_W}WD9Ab!O8J?xaK~(KPB@*tvoK#D%*JD|Cs!R~vc?n- z0>=;@cd&PNzGN9&ly;kf1Jmz#&^m+5IPGQ zzp(p!N8l>2Ul4}TAxgx81F=qJd#w@yK}mmYAH3Pd1RI?Nr12U zmSYtKXBg3I`Mq-d;A$OfTf2lnmLK z-$d&^FhX|s^|nx72HPd*1`uwjB3gw9Pn~c(E_D%uV4<5*V<=l}(Xgi2U_`Nkc;c?K zeZ!bg+O4_F9-V9^hm+eLRW7OsdMnjJt5frMEuVs&IE7~*!T2B|8Wy7&a>*BSkV1|| zl&jUy1g^ZU%GZ@BW;$J1ji(@T=hgKH{mO4P)xw9^Bzt6(0%f7h8|k8+)=|ZUm4??i zLG`W#5-c-gNbbw8Yhb4d1XrL?Pe(e^6};JQ==VGmoJ6~*q7_rxVqi#oIICEBxPkMz z7Ct0iZby?U)1AEHnjh*x;6I+SEb<$`b~l))pJW|hlGMl!K)uyN0XiKM1GLe%zxwN5>Pe+$3hy4z`Obhp6H6f}8FKbWfgIy2ig#-Ut~uV;Bvx!G z&sTRLi&q)4p2>0JcBa7P1K#TBePPWE3Sbt~ZMb+pBEY|>@3&J@J$U6t+xwSU#sCeE z4|1r2P%*ol2QJ%Rn8eXcbRTvQKtMn&^wF~tS@xQiA`P8f-P*gFX#K6rBzp79gg+c_ zwg1u*Es2U(ELzKUf1u}XgH`i+kqYexbBbFs@GiusQ>#Ag>>#Jf+PM-@d_K;4nG4`c zG5SsJKsUtkQ_P&i{FZ3(0oS10Wtfh(Zts{A({ycxs+xLIW}>FA!|d#lXwxXiDm@Ng;IyFAnTD%M`X);j!0#rRKe{!6P+4>|23;D7xa+BvXNcrh<39 z&El}$7djqS;SC@LKh`(39qE^6(Cq2%(06p}oTsUAGRs!m*-l|&7cXKmQ+^((2@K5K zl#8pqQtia931&RZZp~b(_~e#p?1_!K9XMIY4sSPm<_MEO@BUIllUDr~KPu4=)wP%8 zRRkDHw8f>Fr_V>HmfKi8uN~_S(G(k*unR=Q9;t6Gdz9`>QJwrNS3Q5;Tu@%m$g-dW zM~2*69?M5K9*q#Qu~;9#pX-*xs<+L~_W|759^VpFJj^k`TDg`-u^FZU!%1*)Hy{yVsQ zRc23MlR})%SU;dC<0H3#gaNIb@?WdSP8D<%I(H^dyMKOH>-}(t9^b;RL|3dtl!O#( zicmq0W_st~0!qzIyNw^iPW!C0j$d*{1Od3GCA8WyrFhb5g{A(S2g!Ca?as4#A4N=; zx6UI0xn7#lgg;oH1d(3bZN9MD#4-F!t-J+*lRi=)eP14Ra{RalrE_Fjg!+v?{ zQp?A2_kGM999P&T83(2HFr%uZ;zB)jul0i);1m`umyfat!pXebD7lS`nHhqEB-Fr4 zu(kHa8MxW3FDF;f?(m^G>zYX{vSwkf(>=hse;XqdQ=kO^5Pk$De3<7L2`Di%8sn4( z$Z*eMnuHK~)R!<}(vGuBn44v*IGm5~Qd!3aMvJdrIcgj7+keC5fB5on$|qX}Pi-py zzLEBez=)a_WXjGg^_{fOl&v|jj5WgO9M_OzSq!KS4PbDlFck;D#=5mT&g$~d)!W~( z70HrO8eyYMyc0zEK=V=JG>K1B_a^HxYcWyQ7s%ln0@He;{T$Yl41!J;AyKmigAIh5 z!YAqk=dr!9PZYJ=re3MvCyWg@m}AkS{aSdF8cKZ1=SQqFzl$#5yN2(2_EI+}Q4zKj zjd!cZT^o(Mt=GcbBG4xcNe$w*?9kKxo16SXE_Nt;!uQjIvRtJ;pIQvB+D-2TWx!M3 zZpvg^LmS;TU!U^0-S&MEVyk__cwPoG&%Eh)ddNweA+C`da~d&7Fuk1Z(tceI-q){c zwQs+4QTa2b`g;F$x%-1;rK3Nu5hRCx{cXTRlPY}hw1yMU-?ex`8udmSHo}&Htp(^#h#Mdi7NuhTn|~s0>M(1J!W}r4LZlA`#u8x=a@GTkwDgAhaK4ADF_th?vWG=v3EpJ-U^uVJfm9E0b z6mtoA9C@C%Q^WbBp;1t1O)%7aTqc=e19f3QoV@D3iZTTX?O*4rlhn#ljo_Gvi7sBd z5?P=78;mK+qm>0Y-&o?g4zNa*jrqf9(#x${<`Y5OiLJr8fIhIcubUWOVe@LZ)zYh3 z?iOdBE>>-9Wd1TkrRBzkU0rB3{Y0u@6fy6t&sCfD3Xc8ocI$tL%>QTDzN)3)|BrY!(+EO&NhW6qRN`n_%-fUK7dIYxft&v@(lVlGUHZWn*X`> zdV{{>?&Bu=4%5dMyq&!WXkhWx%ZM5)bpF9zvPy_*<$n(HV-9_VI_zD0a0!_lG1(l3 zU>z|8cAqomqi*CIWBD}cVkY@oeW?lWQbgy9NYDj%UVq45gXcq`DbBB~*~=n*JJC0v z&*Sm}-JL9VD&Kw3F4#z?s&Bw4w>l4UP8@an;#jd{6u_1yYvDOwFY&Mc9QDGV(IfIZ zf13UNpKt#k-%wxc2-l28da7Eu=C`vPRj`H??5&OZ7<3+u?i(gHpB+q*@;?%}LnYwd zdOzwc<8G8l8QYSb+WEZ~)B!g!b(%%>%eE)6TA>xBPT^X1)D=c`QxS@0

oo}L432$8Vf7RMovW&4~4rdusPip4aS15s{ya?{M>glwW zzNi%&nx*UC^LYO+H{ze<_0*)G62S(#CggG~@!-wIbYON$4cJ>UWRAnZtr7En%Eo`0GFwzK$&G;0vy;oUlKpVze5nS&TtsO?*M)FibdRMe)Tv2lzWZU+uEhz3YP4;;hzv2W z8jWPABFl@1p_h@)2uQVMcmy2#B*NbSSC{rX1Lc4!4Rh=~EA@(^l`3cM9nDc%A*TCz cyt3BlQ1{Q|>G}BcfBT!K{{P?bpZ*&8KUGn@KmY&$ literal 0 HcmV?d00001 diff --git a/backend/connection_manager.py b/backend/connection_manager.py new file mode 100644 index 0000000..f71a5e2 --- /dev/null +++ b/backend/connection_manager.py @@ -0,0 +1,100 @@ +import queue +import select +import socket +import ssl + +import utils +from core import messages + +logger = utils.Logger() + + +def recv_all(sock: ssl.SSLSocket) -> str: + try: + result = "" + while result[-2:] != "\r\n": + result += sock.read(1).decode() + finally: + return result + + +def send(sock: ssl.SSLSocket, response: messages.Response) -> None: + message_str = response.json_response + + if isinstance(message_str, str): + message_str = message_str.encode() + sock.write(message_str) + + host, port = sock.getpeername() + logger.write(f"{host}:{port} | {response.status}: {response.message} ") + + +class ConnectionManager(object): + def __init__(self, server_socket): + self.server_socket = server_socket + self.inputs = [server_socket] + self.outputs = [] + self.message_queue = {} + + self.readable, self.writable, _ = (None, None, None) + + def get_secure_socket(self, raw_socket: socket.socket) -> ssl.SSLSocket: + ssock = ssl.wrap_socket(raw_socket, server_side=True, ca_certs="backend/certs/client.pem", + certfile="backend/certs/server.pem", cert_reqs=ssl.CERT_REQUIRED, + ssl_version=ssl.PROTOCOL_TLS) + cert = ssock.getpeercert() + if not cert or ("commonName", 'proton') not in cert['subject'][5]: + raise Exception + return ssock + + + def handle_unexpected_error(self, e): + for conn, val in self.message_queue.items(): + error_response = messages.Response(status="ERROR", message="SERVER ERROR") + send(conn, error_response) + logger.write(error_response.message) + + def process(self): + while self.inputs: + self.readable, self.writable, _ = select.select(self.inputs, self.outputs, self.inputs) + try: + self.read_input() + self.write_output() + except Exception as e: + self.handle_unexpected_error(e) + break + + def read_input(self): + for sock in self.readable: + try: + if sock is self.server_socket: + conn, c_addr = sock.accept() + secure_client = self.get_secure_socket(conn) + self.inputs.append(secure_client) + self.message_queue[secure_client] = queue.Queue() + logger.write(f"Connected by {c_addr[0]}:{c_addr[1]}") + else: + message = recv_all(sock) + if message: + self.message_queue[sock].put(message) + if sock not in self.outputs: + self.outputs.append(sock) + else: + if sock in self.outputs: + self.outputs.remove(sock) + self.inputs.remove(sock) + sock.close() + del self.message_queue[sock] + except ssl.SSLWantReadError: + message = messages.Response(status="ERROR", message="SYNTAX ERROR") + send(sock, message) + + def write_output(self): + for sock in self.writable: + try: + raw_message = self.message_queue[sock].get_nowait() + response = messages.Response(status="OK", message=raw_message) + except queue.Empty: + self.outputs.remove(sock) + else: + send(sock, response) diff --git a/backend/server.py b/backend/server.py index 480263e..0dc524b 100644 --- a/backend/server.py +++ b/backend/server.py @@ -1,6 +1,7 @@ import socket import ssl import threading +from time import sleep from core import messages, controllers from utils import Logger @@ -9,28 +10,39 @@ def recv_all(sock: ssl.SSLSocket) -> str: - result = "" - while result[-2:] != "\r\n": - result += sock.read(1).decode() - return result + try: + result = "" + while result[-2:] != "\r\n": + result += sock.read(1).decode() + except ssl.SSLWantReadError as e: + print(e) + finally: + return result def send(sock: ssl.SSLSocket, response: messages.Response) -> None: + lock = threading.Lock() + lock.acquire() + message_str = response.json_response if isinstance(message_str, str): message_str = message_str.encode() sock.write(message_str) - host, port = sock.getpeername() - logger.write(f"{host}:{port} | {response.status}: {response.message} ") + lock.release() + + log = f"{host}:{port} | {response.status}" + if response.message: + log += f': {response.message}' + logger.write(log) class ClientThread(threading.Thread): def __init__(self, secure_socket: ssl.SSLSocket): super().__init__() self.secure_socket = secure_socket - self.socket_authorized = False + self.auth_token = None def get_request(self): raw_message = recv_all(self.secure_socket) @@ -38,25 +50,27 @@ def get_request(self): return request def get_response(self, request): - controller = controllers.Controller(self.socket_authorized) + controller = controllers.Controller(self.auth_token) response = getattr(controller, request.action)(request) if request.action == "login" and response.status == "OK": - self.socket_authorized = True + self.auth_token = response.data[0]["token"] elif request.action == "logout" and response.status == "OK": - self.socket_authorized = False + self.auth_token = None return response + def run(self) -> None: while True: request = self.get_request() try: response = self.get_response(request) + send(self.secure_socket, response) except PermissionError as e: response = messages.Response(status="ERROR", message=str(e)) - finally: send(self.secure_socket, response) + class Server(object): def __init__(self, address=("127.0.0.1", 6666)): self.address = address @@ -82,7 +96,6 @@ def process(self, server_socket: socket.socket): while True: conn, c_addr = server_socket.accept() secure_client = self.get_secure_socket(conn) - secure_client.setblocking(False) try: logger.write(f"Connected by {c_addr[0]}:{c_addr[1]}") c = ClientThread(secure_client) diff --git a/core/controllers.py b/core/controllers.py index 3d2857f..c5e021d 100644 --- a/core/controllers.py +++ b/core/controllers.py @@ -8,8 +8,8 @@ class Controller(object): - def __init__(self, socket_authorized, db_name=settings.DATABASE): - self.socket_authorized = socket_authorized + def __init__(self, auth_token, db_name=settings.DATABASE): + self.auth_token = auth_token self.db_name = db_name self.post_model = models.Post(self.db_name) self.user_model = models.User(self.db_name) @@ -34,7 +34,7 @@ def register(self, request): password = params.get("password") self.user_model.create(username=username, password=password) users = self.user_model.first(username=username) - return ModelResponse("OK", self.user_model, users, ) + return ModelResponse("OK", self.user_model, users) def login(self, request): params = request.params @@ -49,13 +49,13 @@ def login(self, request): @validate_auth def logout(self, request): - token = request.opts["auth_token"] + token = self.auth_token self.auth_model.delete(token=token) return Response("OK") @validate_auth def create(self, request): - user_id = self.auth_model.first(token=request.opts["auth_token"])[1] + user_id = self.auth_model.first(token=self.auth_token)[1] post = self.post_model.create(user_id=user_id, **request.params) return ModelResponse(status="OK", model=self.post_model, raw_instance=post, message="Post created successfully.") @@ -66,10 +66,9 @@ def get(self, request): if getattr(request, "params", None) is not None and request.params.get("id", None) is not None: post_id = request.params["id"] if post_id is not None: - instance = self.post_model.first(id=post_id) + instance = self.post_model.filter(id=post_id) else: instance = self.post_model.all() - return ModelResponse("OK", self.post_model, raw_instance=instance) @validate_auth diff --git a/core/messages.py b/core/messages.py index f862b98..44f759e 100644 --- a/core/messages.py +++ b/core/messages.py @@ -83,7 +83,7 @@ def __init__(self, status, model, raw_instance: Union[list, tuple], message=""): model = model() self.model = model - if not isinstance(raw_instance[0], tuple): + if raw_instance and not isinstance(raw_instance[0], tuple): raw_instance = [raw_instance] self.raw_instance = raw_instance diff --git a/core/models.py b/core/models.py index 47e24c8..4e5293e 100644 --- a/core/models.py +++ b/core/models.py @@ -1,8 +1,10 @@ +import base64 import datetime import os import sqlite3 import abc from time import strptime +from uuid import uuid4 from backend import crypto import settings @@ -109,6 +111,17 @@ def delete(self, **kwargs): class Post(Model): fields = ["image", "content", "title", "user_id"] + def create(self, **kwargs): + image = kwargs.get("image") + image = base64.b64decode(image) + filename = uuid4().hex + ".jpeg" + filename = os.path.join(settings.MEDIA_ROOT, filename) + with open(filename, "wb") as file: + + file.write(image) + kwargs["image"] = filename + return super(Post, self).create(**kwargs) + class User(Model): fields = ["username", "password"] diff --git a/requests.json b/requests.json index a62a350..3671c0b 100644 --- a/requests.json +++ b/requests.json @@ -14,10 +14,7 @@ } }, { - "action": "logout", - "opts": { - "auth_token": "gsF23!a4..." - } + "action": "logout" }, { "action": "create", diff --git a/settings.py b/settings.py index c871835..20288f8 100644 --- a/settings.py +++ b/settings.py @@ -11,3 +11,4 @@ "minutes": 15 } DATABASE = "core/db/sqlite3.db" +MEDIA_ROOT = "assets" diff --git a/tests.py b/tests.py index f3286fe..439569a 100644 --- a/tests.py +++ b/tests.py @@ -162,13 +162,14 @@ def setUpClass(cls) -> None: def setUp(self) -> None: super(ControllerTests, self).setUp() - self.controller = Controller(self.db_name) + self.controller = Controller(None, self.db_name) def _login(self, request, create_user=True): if create_user: self._request_action(self.requests[0]) token = self._request_action(self.requests[1]) - request["opts"]["auth_token"] = token.data[0]["token"] + self.controller.auth_token = token.data[0]["token"] + # request["opts"]["auth_token"] = token.data[0]["token"] return request def _request_action(self, request): @@ -219,8 +220,7 @@ def test_proper_logout(self): user = self._request_action(self.requests[0]) token = self._request_action(self.requests[1]) logout_request = self.requests[2].copy() - logout_request["opts"]["auth_token"] = token.data[0]["token"] - + self.controller.auth_token = token.data[0]["token"] # check if token does not exist anymore self._request_action(logout_request) self.assertIsNone(self.auth_token_model.first(user_id=user.data[0]["id"])) @@ -228,7 +228,6 @@ def test_proper_logout(self): with self.assertRaises(PermissionError): self._request_action(logout_request) logout_request = self.requests[2].copy() - del logout_request["opts"]["auth_token"] self._request_action(logout_request) def _create_post(self, create_user=True): diff --git a/utils.py b/utils.py index 73315cf..7c495cc 100644 --- a/utils.py +++ b/utils.py @@ -23,8 +23,8 @@ def validate_auth(fn): def wrapper(*args, **kwargs): controller, message = args try: - assert controller.socket_authorized - token = message.opts["auth_token"] + token = controller.auth_token + assert controller.auth_token is not None token_model = models.AuthToken(controller.db_name) assert token_model.is_valid(token=token) except (KeyError, AssertionError, ProtonError): From 2f0c4f55faa98d05e88cd31ddb24f3148c11a8f4 Mon Sep 17 00:00:00 2001 From: DanielKusyDev Date: Mon, 8 Jun 2020 17:26:05 +0200 Subject: [PATCH 24/42] Better messages for wrong input. --- .gitignore | 1 + core/controllers.py | 21 +++++++++++++-------- core/messages.py | 20 ++++++++++++++++++-- core/models.py | 15 +++++++++------ utils.py | 9 ++++++++- 5 files changed, 49 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index deb3f3f..e8b2670 100644 --- a/.gitignore +++ b/.gitignore @@ -133,3 +133,4 @@ core/db/sqlite3.db config.ini /backend/certs test_client.py +assets diff --git a/core/controllers.py b/core/controllers.py index c5e021d..0e7772f 100644 --- a/core/controllers.py +++ b/core/controllers.py @@ -3,7 +3,7 @@ from backend import crypto from utils import validate_auth -from core.messages import ModelResponse, Response +from core.messages import ModelResponse, Response, PostModelResponse class Controller(object): @@ -51,14 +51,14 @@ def login(self, request): def logout(self, request): token = self.auth_token self.auth_model.delete(token=token) - return Response("OK") + return Response("OK", message="Logged out.") @validate_auth def create(self, request): user_id = self.auth_model.first(token=self.auth_token)[1] post = self.post_model.create(user_id=user_id, **request.params) - return ModelResponse(status="OK", model=self.post_model, raw_instance=post, - message="Post created successfully.") + return PostModelResponse(status="OK", model=self.post_model, raw_instance=post, + message="Post created successfully.") @validate_auth def get(self, request): @@ -69,16 +69,21 @@ def get(self, request): instance = self.post_model.filter(id=post_id) else: instance = self.post_model.all() - return ModelResponse("OK", self.post_model, raw_instance=instance) + if instance: + return PostModelResponse("OK", self.post_model, raw_instance=instance) + return Response("WRONG", "Not Found.") @validate_auth def alter(self, request): post_id = request.params.pop("id") instance = self.post_model.update(data=request.params, where={"id": post_id}) - return ModelResponse("OK", self.post_model, instance) + return PostModelResponse("OK", self.post_model, instance) @validate_auth def delete(self, request): post_id = request.params.pop("id") - self.post_model.delete(id=post_id) - return Response("OK") + obj = self.post_model.delete(id=post_id) + if obj is None: + return Response("WRONG", "Not Found.") + return Response("OK", data={"id": post_id}) + diff --git a/core/messages.py b/core/messages.py index 44f759e..a559ead 100644 --- a/core/messages.py +++ b/core/messages.py @@ -90,11 +90,27 @@ def __init__(self, status, model, raw_instance: Union[list, tuple], message=""): data = self.create_data() super(ModelResponse, self).__init__(status, message, data=data) + def get_record(self, instance, table_schema): + return {col_name: val for col_name, val in zip(table_schema, instance) if + col_name not in self.model.write_only} + def create_data(self): table_schema = self.model.get_table_cols() data = [] for instance in self.raw_instance: - single_obj_data = {col_name: val for col_name, val in zip(table_schema, instance) if - col_name not in self.model.write_only} + single_obj_data = self.get_record(instance, table_schema) data.append(single_obj_data) return data + + +class PostModelResponse(ModelResponse): + def get_record(self, instance, table_schema): + + record_data = {} + for col_name, val in zip(table_schema, instance): + if col_name not in self.model.write_only: + if col_name == "image": + val = utils.get_image_base64(val) + record_data[col_name] = val + return record_data + diff --git a/core/models.py b/core/models.py index 4e5293e..ab340e4 100644 --- a/core/models.py +++ b/core/models.py @@ -22,6 +22,10 @@ def __init__(self, db_name=settings.DATABASE): def __del__(self): self.conn.close() + def fetch(self, cursor, many=True): + results = cursor.fetchall() if many else cursor.fetchone() + return results + def get_fields(self): return ",".join(self.fields) @@ -29,7 +33,7 @@ def get_table_cols(self): sql = f"PRAGMA table_info({self.table_name})" cursor = self.conn.cursor() cursor.execute(sql) - raw_cols = cursor.fetchall() + raw_cols = self.fetch(cursor, True) def map_col_type(col): c_type = col[2].lower() @@ -64,7 +68,7 @@ def create(self, **kwargs): def all(self): sql = f"SELECT * FROM {self.table_name}" cursor = self.execute_sql(sql) - users = cursor.fetchall() + users = self.fetch(cursor) return users def first(self, **kwargs): @@ -74,7 +78,7 @@ def first(self, **kwargs): else: sql = f"SELECT * FROM {self.table_name} LIMIT 1" cursor = self.execute_sql(sql, kwargs) - return cursor.fetchone() + return self.fetch(cursor, False) def last(self, **kwargs): if kwargs: @@ -83,13 +87,13 @@ def last(self, **kwargs): else: sql = f"SELECT * FROM {self.table_name} ORDER BY id DESC LIMIT 1" cursor = self.execute_sql(sql, kwargs) - return cursor.fetchone() + return self.fetch(cursor, False) def filter(self, **kwargs): conditions = self.get_conditions(kwargs) sql = f"SELECT * FROM {self.table_name} WHERE {conditions}" cursor = self.execute_sql(sql, kwargs) - objects = cursor.fetchall() + objects = self.fetch(cursor, True) return objects def update(self, data: dict, where: dict): @@ -117,7 +121,6 @@ def create(self, **kwargs): filename = uuid4().hex + ".jpeg" filename = os.path.join(settings.MEDIA_ROOT, filename) with open(filename, "wb") as file: - file.write(image) kwargs["image"] = filename return super(Post, self).create(**kwargs) diff --git a/utils.py b/utils.py index 7c495cc..3da0c2c 100644 --- a/utils.py +++ b/utils.py @@ -1,3 +1,4 @@ +import base64 import os import secrets import sqlite3 @@ -50,6 +51,13 @@ def create_db(db_name=settings.DATABASE): cursor.executescript(script.read()) +def get_image_base64(path): + with open(path, "rb") as file: + image = file.read() + image = base64.b64encode(image).decode() + return image + + class Logger(object): def __init__(self, log_dir="logs", max_log_dir_size=5 * 10 ** 6): self.log_dir = log_dir @@ -88,4 +96,3 @@ def write(self, message): with open(filename, "a") as file: file.write(log + "\n") print(log) - From 373b5f15d9f63b42b6f40eea8e162d9b043cb92b Mon Sep 17 00:00:00 2001 From: DanielKusyDev Date: Mon, 8 Jun 2020 21:12:07 +0200 Subject: [PATCH 25/42] Make tests pass. --- assets/4b4cbc2c6e3b4389b6e3282faf302bed.jpeg | Bin 45881 -> 0 bytes assets/corgi.jpeg | Bin 45881 -> 0 bytes backend/server.py | 2 +- core/controllers.py | 4 +++- requests.json | 2 +- tests.py | 12 ++++++++---- 6 files changed, 13 insertions(+), 7 deletions(-) delete mode 100644 assets/4b4cbc2c6e3b4389b6e3282faf302bed.jpeg delete mode 100644 assets/corgi.jpeg diff --git a/assets/4b4cbc2c6e3b4389b6e3282faf302bed.jpeg b/assets/4b4cbc2c6e3b4389b6e3282faf302bed.jpeg deleted file mode 100644 index eeef5c31230b1e1ec439b5e41ff7b0791f32d911..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 45881 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 diff --git a/assets/corgi.jpeg b/assets/corgi.jpeg deleted file mode 100644 index eeef5c31230b1e1ec439b5e41ff7b0791f32d911..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 45881 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 diff --git a/backend/server.py b/backend/server.py index 0dc524b..6b59564 100644 --- a/backend/server.py +++ b/backend/server.py @@ -61,8 +61,8 @@ def get_response(self, request): def run(self) -> None: while True: - request = self.get_request() try: + request = self.get_request() response = self.get_response(request) send(self.secure_socket, response) except PermissionError as e: diff --git a/core/controllers.py b/core/controllers.py index 0e7772f..d94d15d 100644 --- a/core/controllers.py +++ b/core/controllers.py @@ -77,7 +77,9 @@ def get(self, request): def alter(self, request): post_id = request.params.pop("id") instance = self.post_model.update(data=request.params, where={"id": post_id}) - return PostModelResponse("OK", self.post_model, instance) + if instance: + return PostModelResponse("OK", self.post_model, instance) + return Response("WRONG", "Not Found.") @validate_auth def delete(self, request): diff --git a/requests.json b/requests.json index 3671c0b..b915ebb 100644 --- a/requests.json +++ b/requests.json @@ -46,7 +46,7 @@ "action": "alter", "params": { "id": 1, - "image": "data:image/jpeg;base64", + "image": "assets/corgi.jpeg", "content": "consectetur adipiscing elit.", "title": "Proin nibh augue" }, diff --git a/tests.py b/tests.py index 439569a..59bae78 100644 --- a/tests.py +++ b/tests.py @@ -156,9 +156,14 @@ class ControllerTests(BaseControllerTest): @classmethod def setUpClass(cls) -> None: - with open("assets/corgi.jpeg", "rb") as img: - img_as_str = img.read() - cls.image_str = base64.b64encode(img_as_str).decode() + cls.image_str = "assets/corgi.jpeg" + + @classmethod + def tearDownClass(cls) -> None: + assets = os.listdir("assets/") + for path in assets: + if path != "corgi.jpeg": + os.remove(os.path.join("assets", path)) def setUp(self) -> None: super(ControllerTests, self).setUp() @@ -169,7 +174,6 @@ def _login(self, request, create_user=True): self._request_action(self.requests[0]) token = self._request_action(self.requests[1]) self.controller.auth_token = token.data[0]["token"] - # request["opts"]["auth_token"] = token.data[0]["token"] return request def _request_action(self, request): From 51748a8e428c167fc74189aea55c9a84614a202c Mon Sep 17 00:00:00 2001 From: DanielKusyDev Date: Mon, 8 Jun 2020 21:14:42 +0200 Subject: [PATCH 26/42] Create test assets directory and populate it with some cutie. --- tests.py | 2 +- tests_assets/corgi.jpeg | Bin 0 -> 45881 bytes 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 tests_assets/corgi.jpeg diff --git a/tests.py b/tests.py index 59bae78..fd4ed46 100644 --- a/tests.py +++ b/tests.py @@ -156,7 +156,7 @@ class ControllerTests(BaseControllerTest): @classmethod def setUpClass(cls) -> None: - cls.image_str = "assets/corgi.jpeg" + cls.image_str = "test_assets/corgi.jpeg" @classmethod def tearDownClass(cls) -> None: diff --git a/tests_assets/corgi.jpeg b/tests_assets/corgi.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..eeef5c31230b1e1ec439b5e41ff7b0791f32d911 GIT binary patch literal 45881 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 literal 0 HcmV?d00001 From aef58b9a7b383d930c1c1c6a15ea04a85472ba5d Mon Sep 17 00:00:00 2001 From: DanielKusyDev Date: Mon, 8 Jun 2020 21:25:01 +0200 Subject: [PATCH 27/42] Rename assets directory made for tests only. --- core/models.py | 2 ++ requests.json | 2 +- {tests_assets => test_assets}/corgi.jpeg | Bin tests.py | 10 ++++------ 4 files changed, 7 insertions(+), 7 deletions(-) rename {tests_assets => test_assets}/corgi.jpeg (100%) diff --git a/core/models.py b/core/models.py index ab340e4..58c18af 100644 --- a/core/models.py +++ b/core/models.py @@ -119,6 +119,8 @@ def create(self, **kwargs): image = kwargs.get("image") image = base64.b64decode(image) filename = uuid4().hex + ".jpeg" + if not os.path.exists(settings.MEDIA_ROOT): + os.mkdir(settings.MEDIA_ROOT) filename = os.path.join(settings.MEDIA_ROOT, filename) with open(filename, "wb") as file: file.write(image) diff --git a/requests.json b/requests.json index b915ebb..da93c8f 100644 --- a/requests.json +++ b/requests.json @@ -46,7 +46,7 @@ "action": "alter", "params": { "id": 1, - "image": "assets/corgi.jpeg", + "image": "test_assets/corgi.jpeg", "content": "consectetur adipiscing elit.", "title": "Proin nibh augue" }, diff --git a/tests_assets/corgi.jpeg b/test_assets/corgi.jpeg similarity index 100% rename from tests_assets/corgi.jpeg rename to test_assets/corgi.jpeg diff --git a/tests.py b/tests.py index fd4ed46..4b4adb1 100644 --- a/tests.py +++ b/tests.py @@ -153,17 +153,15 @@ def test_opts(self): class ControllerTests(BaseControllerTest): - - @classmethod - def setUpClass(cls) -> None: - cls.image_str = "test_assets/corgi.jpeg" + media_root = "test_assets" + image_str = os.path.join(media_root, "corgi.jpeg") @classmethod def tearDownClass(cls) -> None: - assets = os.listdir("assets/") + assets = os.listdir(cls.media_root) for path in assets: if path != "corgi.jpeg": - os.remove(os.path.join("assets", path)) + os.remove(os.path.join(cls.media_root, path)) def setUp(self) -> None: super(ControllerTests, self).setUp() From 6ebad4937a38ab72e4850478f99a950da5fe4867 Mon Sep 17 00:00:00 2001 From: DanielKusyDev Date: Wed, 10 Jun 2020 11:55:59 +0200 Subject: [PATCH 28/42] Create client side tests --- core/messages.py | 7 -- requests.json | 17 +---- runserver.py | 1 + tests.py | 167 +++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 162 insertions(+), 30 deletions(-) diff --git a/core/messages.py b/core/messages.py index a559ead..e4c07bd 100644 --- a/core/messages.py +++ b/core/messages.py @@ -23,7 +23,6 @@ def __init__(self, json_string): self.obj = self.deserialize_json() self.action = self.get_action() self.params = self.get_params() - self.opts = self.get_opts() except (KeyError, AssertionError, json.JSONDecodeError) as e: raise utils.ProtonError("Syntax Error") @@ -47,12 +46,6 @@ def get_params(self): assert self.required_action_params.get(self.action, None) is None return params - def get_opts(self): - opts = self.obj.get("opts", None) - assert isinstance(opts, dict) or opts is None - return opts - - class Response(object): def __init__(self, status, message=None, data=None): self.message = message diff --git a/requests.json b/requests.json index da93c8f..2258136 100644 --- a/requests.json +++ b/requests.json @@ -22,24 +22,15 @@ "image": "data:image/jpeg;base64...", "content": "Lorem ipsum...", "title": "dolor sit amet" - }, - "opts": { - "auth_token": "gsF23!a4..." } }, { - "action": "get", - "opts": { - "auth_token": "gsF23!a4..." - } + "action": "get" }, { "action": "get", "params": { "id": 1 - }, - "opts": { - "auth_token": "gsF23!a4..." } }, { @@ -49,18 +40,12 @@ "image": "test_assets/corgi.jpeg", "content": "consectetur adipiscing elit.", "title": "Proin nibh augue" - }, - "opts": { - "auth_token": "gsF23!a4..." } }, { "action": "delete", "params": { "id": 1 - }, - "opts": { - "auth_token": "gsF23!a4..." } } ] \ No newline at end of file diff --git a/runserver.py b/runserver.py index d55a7ed..3576f21 100644 --- a/runserver.py +++ b/runserver.py @@ -1,4 +1,5 @@ from backend.server import Server +import settings server = Server(("localhost", 6666)) server.runserver() diff --git a/tests.py b/tests.py index 4b4adb1..0fc73b5 100644 --- a/tests.py +++ b/tests.py @@ -2,11 +2,15 @@ import base64 import json import os +import socket import sqlite3 +import ssl +import threading import unittest - +import shutil import settings from backend import crypto +from backend.server import Server from core import models import utils from core.controllers import Controller @@ -146,11 +150,6 @@ def test_empty_params(self): self.message.required_action_params[self.message.action] = None self.assertIsNone(self.message.get_params()) - def test_opts(self): - self.assertIsNone(self.message.get_opts()) - self.message.obj["opts"] = {"example": "test"} - self.assertIsInstance(self.message.get_opts(), dict) - class ControllerTests(BaseControllerTest): media_root = "test_assets" @@ -276,5 +275,159 @@ def test_post_deletion(self): self.assertListEqual(self.post_model.all(), []) +class ThreadedServer(threading.Thread): + def run(self) -> None: + server = Server(("localhost", 1234)) + server.runserver() + + class ClientRequestTests(unittest.TestCase): - pass + + @classmethod + def setUpClass(cls) -> None: + shutil.move(settings.DATABASE, "sqlite3.db.bak") + utils.create_db() + + @classmethod + def tearDownClass(cls) -> None: + shutil.move("sqlite3.db.bak", settings.DATABASE) + + + def setUp(self) -> None: + self.connect() + + def tearDown(self) -> None: + self.secure_sock.close() + + def recv_all(self, sock: ssl.SSLSocket) -> str: + result = "" + while result[-2:] != "\r\n": + result += sock.read(1).decode() + return result + + def send(self, req): + sock = self.secure_sock + if req[-2:] != "\r\n": + req += "\r\n" + for i in req: + sock.sendall(i.encode("utf-8")) + return self.recv_all(sock) + + def connect(self): + + host = "localhost" + port = 6666 + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setblocking(True) + sock.connect((host, port)) + + context = ssl.SSLContext(ssl.PROTOCOL_TLS) + context.verify_mode = ssl.CERT_REQUIRED + context.load_verify_locations("backend/certs/server.pem") + context.load_cert_chain("backend/certs/client.pem") + + if ssl.HAS_SNI: + self.secure_sock = context.wrap_socket(sock, server_side=False, server_hostname=host) + else: + self.secure_sock = context.wrap_socket(sock, server_side=False) + + cert = self.secure_sock.getpeercert() + + if not cert or ("commonName", "proton") not in cert["subject"][5]: + raise Exception + + def get(self, id=None): + if id is not None: + req = """{ + "action": "get", + "params": {"id":%s} + }""" % str(id) + else: + req = """{ + "action": "get" + }""" + + return self.send(req) + + def delete(self, id): + req = """{ + "action": "delete", + "params": { + "id": %s + } + }""" % str(id) + return self.send(req) + + def alter(self, id): + req = """{ + "action": "alter", + "params": { + "id": %s, + "content": "dupa", + "title": "tu tez" + } + }\r\n""" % str(id) + return self.send(req) + + def logout(self, **kwargs): + req = """{ + "action": "logout" + }""" + return self.send(req) + + def login(self): + req = """{ + "action": "login", + "params": { + "username": "Test", + "password": "passwd" + } + }""" + return self.send(req) + + def register(self): + req = """{ + "action": "register", + "params": { + "username": "Test", + "password": "passwd" + } + }""" + return self.send(req) + + def _check_auth(self, method, **kwargs): + result = method(**kwargs) + result = json.loads(result) + self.assertEqual(result["status"].upper(), "ERROR") + self.assertIn("permission", result["message"].lower()) + + def test_unauthorized_get(self): + self._check_auth(self.get) + self._check_auth(self.get, id=1) + + def test_unauthorized_delete(self): + self._check_auth(self.delete, id=1) + + def test_unauthorized_alter(self): + self._check_auth(self.alter, id=1) + + def test_unauthorized_logout(self): + self._check_auth(self.logout) + + def test_deletion(self): + result = json.loads(self.register()) + print(result) + print(self.login()) + post_id = result["data"][0]["id"] + result = self.delete(id=post_id) + print(result) + + # def test_registration(self): + # result = self.register() + # print(result) + # result = json.loads(result) + # self.assertEqual(result["status"].upper(), "OK") + # self.assertEqual(len(result["data"]), 1) + # post_id = result["data"][0].get("id", None) + # self.assertIsNotNone(post_id) + # self.delete(id=post_id) From 6fe024381d84fed9ecb9d849960ea7546d05c9d2 Mon Sep 17 00:00:00 2001 From: DanielKusyDev Date: Fri, 12 Jun 2020 16:57:30 +0200 Subject: [PATCH 29/42] Add generation of db in runserver: create db when it does not exist in settings.DATABASE --- backend/server.py | 8 ++++---- core/models.py | 1 + runserver.py | 5 +++++ settings.py | 2 ++ utils.py | 2 +- 5 files changed, 13 insertions(+), 5 deletions(-) diff --git a/backend/server.py b/backend/server.py index 6b59564..5842e7f 100644 --- a/backend/server.py +++ b/backend/server.py @@ -3,7 +3,7 @@ import threading from time import sleep -from core import messages, controllers +from core import messages, controllers, models from utils import Logger logger = Logger() @@ -53,12 +53,13 @@ def get_response(self, request): controller = controllers.Controller(self.auth_token) response = getattr(controller, request.action)(request) if request.action == "login" and response.status == "OK": - self.auth_token = response.data[0]["token"] + token_id = response.data[0]["id"] + token = models.AuthToken().first(id=token_id)[2] + self.auth_token = token elif request.action == "logout" and response.status == "OK": self.auth_token = None return response - def run(self) -> None: while True: try: @@ -70,7 +71,6 @@ def run(self) -> None: send(self.secure_socket, response) - class Server(object): def __init__(self, address=("127.0.0.1", 6666)): self.address = address diff --git a/core/models.py b/core/models.py index 58c18af..7321818 100644 --- a/core/models.py +++ b/core/models.py @@ -139,6 +139,7 @@ def create(self, **kwargs): class AuthToken(Model): fields = ["token", "user_id", "expires"] + write_only = ["token", "expires"] def get_fresh_expiration(self): expires = datetime.datetime.now() + datetime.timedelta(**settings.EXPIRATION) diff --git a/runserver.py b/runserver.py index 3576f21..e722683 100644 --- a/runserver.py +++ b/runserver.py @@ -1,5 +1,10 @@ +import os + +import utils from backend.server import Server import settings +if not os.path.exists(settings.DATABASE): + utils.create_db(settings.DATABASE) server = Server(("localhost", 6666)) server.runserver() diff --git a/settings.py b/settings.py index 20288f8..a222cc3 100644 --- a/settings.py +++ b/settings.py @@ -12,3 +12,5 @@ } DATABASE = "core/db/sqlite3.db" MEDIA_ROOT = "assets" +PORT = 6666 +HOST = "0.0.0.0" diff --git a/utils.py b/utils.py index 3da0c2c..6528c94 100644 --- a/utils.py +++ b/utils.py @@ -47,7 +47,7 @@ def create_conn(db_name=settings.DATABASE): def create_db(db_name=settings.DATABASE): conn = create_conn(db_name) cursor = conn.cursor() - with open(os.path.join("core/db/create_db.sql"), "r") as script: + with open("core/db/create_db.sql", "r") as script: cursor.executescript(script.read()) From 5bbcb540488a3ab2b67b387c498b73f558aa5d8a Mon Sep 17 00:00:00 2001 From: DanielKusyDev Date: Fri, 12 Jun 2020 17:25:17 +0200 Subject: [PATCH 30/42] Add CI action responsible for running and killing test server --- .github/workflows/python-app.yml | 11 ++++++++--- pid | 1 + runserver.py | 2 +- tests.py | 16 +++++++++++----- 4 files changed, 21 insertions(+), 9 deletions(-) create mode 100644 pid diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index a647ba9..05eb608 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -25,7 +25,12 @@ jobs: python -m pip install --upgrade pip if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Prepare test environment - run: cp config_example.ini config.ini - - name: Test with pytest run: | - python3 -m unittest tests.py + cp config_example.ini config.ini + python runserver.py > /dev/null & echo $! > runserver.pid + - name: Test with unittest + run: | + python -m unittest tests.py + - name: Kill temporary processes + run: | + kill -9 $(cat runserver.pid) \ No newline at end of file diff --git a/pid b/pid new file mode 100644 index 0000000..d6ff78a --- /dev/null +++ b/pid @@ -0,0 +1 @@ +3428 diff --git a/runserver.py b/runserver.py index e722683..2fea101 100644 --- a/runserver.py +++ b/runserver.py @@ -6,5 +6,5 @@ if not os.path.exists(settings.DATABASE): utils.create_db(settings.DATABASE) -server = Server(("localhost", 6666)) +server = Server((settings.HOST, settings.PORT)) server.runserver() diff --git a/tests.py b/tests.py index 0fc73b5..8d73512 100644 --- a/tests.py +++ b/tests.py @@ -166,11 +166,17 @@ def setUp(self) -> None: super(ControllerTests, self).setUp() self.controller = Controller(None, self.db_name) + def get_token(self, token_instance): + token_id = token_instance.data[0]["id"] + token_instance = self.auth_token_model.last(id=token_id)[2] + return token_instance + def _login(self, request, create_user=True): if create_user: self._request_action(self.requests[0]) - token = self._request_action(self.requests[1]) - self.controller.auth_token = token.data[0]["token"] + token_instance = self._request_action(self.requests[1]) + token = self.get_token(token_instance) + self.controller.auth_token = token return request def _request_action(self, request): @@ -221,7 +227,7 @@ def test_proper_logout(self): user = self._request_action(self.requests[0]) token = self._request_action(self.requests[1]) logout_request = self.requests[2].copy() - self.controller.auth_token = token.data[0]["token"] + self.controller.auth_token = self.get_token(token) # check if token does not exist anymore self._request_action(logout_request) self.assertIsNone(self.auth_token_model.first(user_id=user.data[0]["id"])) @@ -315,8 +321,8 @@ def send(self, req): def connect(self): - host = "localhost" - port = 6666 + host = settings.HOST + port = settings.PORT sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.setblocking(True) sock.connect((host, port)) From 8514e0179d7e7119f545606a01e5bd9187d9c7e8 Mon Sep 17 00:00:00 2001 From: DanielKusyDev Date: Fri, 12 Jun 2020 17:31:11 +0200 Subject: [PATCH 31/42] Fix failing actions because of missing certs --- .github/workflows/python-app.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 05eb608..e34e281 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -27,10 +27,17 @@ jobs: - name: Prepare test environment run: | cp config_example.ini config.ini + cd backend + mkdir certs + cd backend + openssl req -new -x509 -days 365 -nodes -out server.pem -keyout server.pem -subj "/C=PL/ST=Lubelskie/L=Lublin/O=UMCS/OU=MFI/CN=proton" + openssl req -new -x509 -days 365 -nodes -out client.pem -keyout client.pem -subj "/C=PL/ST=Lubelskie/L=Lublin/O=UMCS/OU=MFI/CN=proton" python runserver.py > /dev/null & echo $! > runserver.pid - name: Test with unittest run: | python -m unittest tests.py - name: Kill temporary processes run: | - kill -9 $(cat runserver.pid) \ No newline at end of file + kill -9 $(cat runserver.pid) + cd backend + rm -r certs \ No newline at end of file From 5c73e33adf336ec222deb74c610608aec993c1eb Mon Sep 17 00:00:00 2001 From: DanielKusyDev Date: Fri, 12 Jun 2020 17:34:37 +0200 Subject: [PATCH 32/42] Fix failing actions because of missing certs --- .github/workflows/python-app.yml | 7 ++----- .gitignore | 2 +- tests.py | 4 ++-- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index e34e281..aa25ab2 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -27,9 +27,6 @@ jobs: - name: Prepare test environment run: | cp config_example.ini config.ini - cd backend - mkdir certs - cd backend openssl req -new -x509 -days 365 -nodes -out server.pem -keyout server.pem -subj "/C=PL/ST=Lubelskie/L=Lublin/O=UMCS/OU=MFI/CN=proton" openssl req -new -x509 -days 365 -nodes -out client.pem -keyout client.pem -subj "/C=PL/ST=Lubelskie/L=Lublin/O=UMCS/OU=MFI/CN=proton" python runserver.py > /dev/null & echo $! > runserver.pid @@ -39,5 +36,5 @@ jobs: - name: Kill temporary processes run: | kill -9 $(cat runserver.pid) - cd backend - rm -r certs \ No newline at end of file + rm server.pem + rm client.pem \ No newline at end of file diff --git a/.gitignore b/.gitignore index e8b2670..5e82ea6 100644 --- a/.gitignore +++ b/.gitignore @@ -131,6 +131,6 @@ dmypy.json .idea core/db/sqlite3.db config.ini -/backend/certs +*.pem test_client.py assets diff --git a/tests.py b/tests.py index 8d73512..3950761 100644 --- a/tests.py +++ b/tests.py @@ -329,8 +329,8 @@ def connect(self): context = ssl.SSLContext(ssl.PROTOCOL_TLS) context.verify_mode = ssl.CERT_REQUIRED - context.load_verify_locations("backend/certs/server.pem") - context.load_cert_chain("backend/certs/client.pem") + context.load_verify_locations("server.pem") + context.load_cert_chain("client.pem") if ssl.HAS_SNI: self.secure_sock = context.wrap_socket(sock, server_side=False, server_hostname=host) From 54de0a3f0da40e0d5b94dfc01ec59eef7e99bc82 Mon Sep 17 00:00:00 2001 From: DanielKusyDev Date: Fri, 12 Jun 2020 17:37:44 +0200 Subject: [PATCH 33/42] Temporary disable client tests due to github actions restriction --- pid | 1 - tests.py | 300 +++++++++++++++++++++++++++---------------------------- 2 files changed, 150 insertions(+), 151 deletions(-) delete mode 100644 pid diff --git a/pid b/pid deleted file mode 100644 index d6ff78a..0000000 --- a/pid +++ /dev/null @@ -1 +0,0 @@ -3428 diff --git a/tests.py b/tests.py index 3950761..b7100cb 100644 --- a/tests.py +++ b/tests.py @@ -287,153 +287,153 @@ def run(self) -> None: server.runserver() -class ClientRequestTests(unittest.TestCase): - - @classmethod - def setUpClass(cls) -> None: - shutil.move(settings.DATABASE, "sqlite3.db.bak") - utils.create_db() - - @classmethod - def tearDownClass(cls) -> None: - shutil.move("sqlite3.db.bak", settings.DATABASE) - - - def setUp(self) -> None: - self.connect() - - def tearDown(self) -> None: - self.secure_sock.close() - - def recv_all(self, sock: ssl.SSLSocket) -> str: - result = "" - while result[-2:] != "\r\n": - result += sock.read(1).decode() - return result - - def send(self, req): - sock = self.secure_sock - if req[-2:] != "\r\n": - req += "\r\n" - for i in req: - sock.sendall(i.encode("utf-8")) - return self.recv_all(sock) - - def connect(self): - - host = settings.HOST - port = settings.PORT - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.setblocking(True) - sock.connect((host, port)) - - context = ssl.SSLContext(ssl.PROTOCOL_TLS) - context.verify_mode = ssl.CERT_REQUIRED - context.load_verify_locations("server.pem") - context.load_cert_chain("client.pem") - - if ssl.HAS_SNI: - self.secure_sock = context.wrap_socket(sock, server_side=False, server_hostname=host) - else: - self.secure_sock = context.wrap_socket(sock, server_side=False) - - cert = self.secure_sock.getpeercert() - - if not cert or ("commonName", "proton") not in cert["subject"][5]: - raise Exception - - def get(self, id=None): - if id is not None: - req = """{ - "action": "get", - "params": {"id":%s} - }""" % str(id) - else: - req = """{ - "action": "get" - }""" - - return self.send(req) - - def delete(self, id): - req = """{ - "action": "delete", - "params": { - "id": %s - } - }""" % str(id) - return self.send(req) - - def alter(self, id): - req = """{ - "action": "alter", - "params": { - "id": %s, - "content": "dupa", - "title": "tu tez" - } - }\r\n""" % str(id) - return self.send(req) - - def logout(self, **kwargs): - req = """{ - "action": "logout" - }""" - return self.send(req) - - def login(self): - req = """{ - "action": "login", - "params": { - "username": "Test", - "password": "passwd" - } - }""" - return self.send(req) - - def register(self): - req = """{ - "action": "register", - "params": { - "username": "Test", - "password": "passwd" - } - }""" - return self.send(req) - - def _check_auth(self, method, **kwargs): - result = method(**kwargs) - result = json.loads(result) - self.assertEqual(result["status"].upper(), "ERROR") - self.assertIn("permission", result["message"].lower()) - - def test_unauthorized_get(self): - self._check_auth(self.get) - self._check_auth(self.get, id=1) - - def test_unauthorized_delete(self): - self._check_auth(self.delete, id=1) - - def test_unauthorized_alter(self): - self._check_auth(self.alter, id=1) - - def test_unauthorized_logout(self): - self._check_auth(self.logout) - - def test_deletion(self): - result = json.loads(self.register()) - print(result) - print(self.login()) - post_id = result["data"][0]["id"] - result = self.delete(id=post_id) - print(result) - - # def test_registration(self): - # result = self.register() - # print(result) - # result = json.loads(result) - # self.assertEqual(result["status"].upper(), "OK") - # self.assertEqual(len(result["data"]), 1) - # post_id = result["data"][0].get("id", None) - # self.assertIsNotNone(post_id) - # self.delete(id=post_id) +# class ClientRequestTests(unittest.TestCase): +# +# @classmethod +# def setUpClass(cls) -> None: +# shutil.move(settings.DATABASE, "sqlite3.db.bak") +# utils.create_db() +# +# @classmethod +# def tearDownClass(cls) -> None: +# shutil.move("sqlite3.db.bak", settings.DATABASE) +# +# +# def setUp(self) -> None: +# self.connect() +# +# def tearDown(self) -> None: +# self.secure_sock.close() +# +# def recv_all(self, sock: ssl.SSLSocket) -> str: +# result = "" +# while result[-2:] != "\r\n": +# result += sock.read(1).decode() +# return result +# +# def send(self, req): +# sock = self.secure_sock +# if req[-2:] != "\r\n": +# req += "\r\n" +# for i in req: +# sock.sendall(i.encode("utf-8")) +# return self.recv_all(sock) +# +# def connect(self): +# +# host = settings.HOST +# port = settings.PORT +# sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +# sock.setblocking(True) +# sock.connect((host, port)) +# +# context = ssl.SSLContext(ssl.PROTOCOL_TLS) +# context.verify_mode = ssl.CERT_REQUIRED +# context.load_verify_locations("server.pem") +# context.load_cert_chain("client.pem") +# +# if ssl.HAS_SNI: +# self.secure_sock = context.wrap_socket(sock, server_side=False, server_hostname=host) +# else: +# self.secure_sock = context.wrap_socket(sock, server_side=False) +# +# cert = self.secure_sock.getpeercert() +# +# if not cert or ("commonName", "proton") not in cert["subject"][5]: +# raise Exception +# +# def get(self, id=None): +# if id is not None: +# req = """{ +# "action": "get", +# "params": {"id":%s} +# }""" % str(id) +# else: +# req = """{ +# "action": "get" +# }""" +# +# return self.send(req) +# +# def delete(self, id): +# req = """{ +# "action": "delete", +# "params": { +# "id": %s +# } +# }""" % str(id) +# return self.send(req) +# +# def alter(self, id): +# req = """{ +# "action": "alter", +# "params": { +# "id": %s, +# "content": "dupa", +# "title": "tu tez" +# } +# }\r\n""" % str(id) +# return self.send(req) +# +# def logout(self, **kwargs): +# req = """{ +# "action": "logout" +# }""" +# return self.send(req) +# +# def login(self): +# req = """{ +# "action": "login", +# "params": { +# "username": "Test", +# "password": "passwd" +# } +# }""" +# return self.send(req) +# +# def register(self): +# req = """{ +# "action": "register", +# "params": { +# "username": "Test", +# "password": "passwd" +# } +# }""" +# return self.send(req) +# +# def _check_auth(self, method, **kwargs): +# result = method(**kwargs) +# result = json.loads(result) +# self.assertEqual(result["status"].upper(), "ERROR") +# self.assertIn("permission", result["message"].lower()) +# +# def test_unauthorized_get(self): +# self._check_auth(self.get) +# self._check_auth(self.get, id=1) +# +# def test_unauthorized_delete(self): +# self._check_auth(self.delete, id=1) +# +# def test_unauthorized_alter(self): +# self._check_auth(self.alter, id=1) +# +# def test_unauthorized_logout(self): +# self._check_auth(self.logout) +# +# def test_deletion(self): +# result = json.loads(self.register()) +# print(result) +# print(self.login()) +# post_id = result["data"][0]["id"] +# result = self.delete(id=post_id) +# print(result) +# +# # def test_registration(self): +# # result = self.register() +# # print(result) +# # result = json.loads(result) +# # self.assertEqual(result["status"].upper(), "OK") +# # self.assertEqual(len(result["data"]), 1) +# # post_id = result["data"][0].get("id", None) +# # self.assertIsNotNone(post_id) +# # self.delete(id=post_id) From 5e48de67a1341aebc65d4128d08a35f14b0d354c Mon Sep 17 00:00:00 2001 From: DanielKusyDev Date: Sun, 14 Jun 2020 23:54:08 +0200 Subject: [PATCH 34/42] Get rid of PostModelResponse class because custom get_record was not needed anymore. --- core/controllers.py | 8 ++++---- core/messages.py | 13 ------------- core/models.py | 12 ------------ 3 files changed, 4 insertions(+), 29 deletions(-) diff --git a/core/controllers.py b/core/controllers.py index d94d15d..22153b9 100644 --- a/core/controllers.py +++ b/core/controllers.py @@ -3,7 +3,7 @@ from backend import crypto from utils import validate_auth -from core.messages import ModelResponse, Response, PostModelResponse +from core.messages import ModelResponse, Response class Controller(object): @@ -57,7 +57,7 @@ def logout(self, request): def create(self, request): user_id = self.auth_model.first(token=self.auth_token)[1] post = self.post_model.create(user_id=user_id, **request.params) - return PostModelResponse(status="OK", model=self.post_model, raw_instance=post, + return ModelResponse(status="OK", model=self.post_model, raw_instance=post, message="Post created successfully.") @validate_auth @@ -70,7 +70,7 @@ def get(self, request): else: instance = self.post_model.all() if instance: - return PostModelResponse("OK", self.post_model, raw_instance=instance) + return ModelResponse("OK", self.post_model, raw_instance=instance) return Response("WRONG", "Not Found.") @validate_auth @@ -78,7 +78,7 @@ def alter(self, request): post_id = request.params.pop("id") instance = self.post_model.update(data=request.params, where={"id": post_id}) if instance: - return PostModelResponse("OK", self.post_model, instance) + return ModelResponse("OK", self.post_model, instance) return Response("WRONG", "Not Found.") @validate_auth diff --git a/core/messages.py b/core/messages.py index e4c07bd..81e0738 100644 --- a/core/messages.py +++ b/core/messages.py @@ -94,16 +94,3 @@ def create_data(self): single_obj_data = self.get_record(instance, table_schema) data.append(single_obj_data) return data - - -class PostModelResponse(ModelResponse): - def get_record(self, instance, table_schema): - - record_data = {} - for col_name, val in zip(table_schema, instance): - if col_name not in self.model.write_only: - if col_name == "image": - val = utils.get_image_base64(val) - record_data[col_name] = val - return record_data - diff --git a/core/models.py b/core/models.py index 7321818..e64ab47 100644 --- a/core/models.py +++ b/core/models.py @@ -115,18 +115,6 @@ def delete(self, **kwargs): class Post(Model): fields = ["image", "content", "title", "user_id"] - def create(self, **kwargs): - image = kwargs.get("image") - image = base64.b64decode(image) - filename = uuid4().hex + ".jpeg" - if not os.path.exists(settings.MEDIA_ROOT): - os.mkdir(settings.MEDIA_ROOT) - filename = os.path.join(settings.MEDIA_ROOT, filename) - with open(filename, "wb") as file: - file.write(image) - kwargs["image"] = filename - return super(Post, self).create(**kwargs) - class User(Model): fields = ["username", "password"] From f93116451e6021d4f1a385a1c861d9616a522f9d Mon Sep 17 00:00:00 2001 From: DanielKusyDev Date: Mon, 15 Jun 2020 00:56:16 +0200 Subject: [PATCH 35/42] Better logs, more specific and depent on debug mode --- backend/connection_manager.py | 100 ---------------------------------- backend/server.py | 22 +++++--- config_example.ini | 2 + core/controllers.py | 25 ++++----- core/messages.py | 7 ++- settings.py | 2 + utils.py | 25 ++++++++- 7 files changed, 56 insertions(+), 127 deletions(-) delete mode 100644 backend/connection_manager.py diff --git a/backend/connection_manager.py b/backend/connection_manager.py deleted file mode 100644 index f71a5e2..0000000 --- a/backend/connection_manager.py +++ /dev/null @@ -1,100 +0,0 @@ -import queue -import select -import socket -import ssl - -import utils -from core import messages - -logger = utils.Logger() - - -def recv_all(sock: ssl.SSLSocket) -> str: - try: - result = "" - while result[-2:] != "\r\n": - result += sock.read(1).decode() - finally: - return result - - -def send(sock: ssl.SSLSocket, response: messages.Response) -> None: - message_str = response.json_response - - if isinstance(message_str, str): - message_str = message_str.encode() - sock.write(message_str) - - host, port = sock.getpeername() - logger.write(f"{host}:{port} | {response.status}: {response.message} ") - - -class ConnectionManager(object): - def __init__(self, server_socket): - self.server_socket = server_socket - self.inputs = [server_socket] - self.outputs = [] - self.message_queue = {} - - self.readable, self.writable, _ = (None, None, None) - - def get_secure_socket(self, raw_socket: socket.socket) -> ssl.SSLSocket: - ssock = ssl.wrap_socket(raw_socket, server_side=True, ca_certs="backend/certs/client.pem", - certfile="backend/certs/server.pem", cert_reqs=ssl.CERT_REQUIRED, - ssl_version=ssl.PROTOCOL_TLS) - cert = ssock.getpeercert() - if not cert or ("commonName", 'proton') not in cert['subject'][5]: - raise Exception - return ssock - - - def handle_unexpected_error(self, e): - for conn, val in self.message_queue.items(): - error_response = messages.Response(status="ERROR", message="SERVER ERROR") - send(conn, error_response) - logger.write(error_response.message) - - def process(self): - while self.inputs: - self.readable, self.writable, _ = select.select(self.inputs, self.outputs, self.inputs) - try: - self.read_input() - self.write_output() - except Exception as e: - self.handle_unexpected_error(e) - break - - def read_input(self): - for sock in self.readable: - try: - if sock is self.server_socket: - conn, c_addr = sock.accept() - secure_client = self.get_secure_socket(conn) - self.inputs.append(secure_client) - self.message_queue[secure_client] = queue.Queue() - logger.write(f"Connected by {c_addr[0]}:{c_addr[1]}") - else: - message = recv_all(sock) - if message: - self.message_queue[sock].put(message) - if sock not in self.outputs: - self.outputs.append(sock) - else: - if sock in self.outputs: - self.outputs.remove(sock) - self.inputs.remove(sock) - sock.close() - del self.message_queue[sock] - except ssl.SSLWantReadError: - message = messages.Response(status="ERROR", message="SYNTAX ERROR") - send(sock, message) - - def write_output(self): - for sock in self.writable: - try: - raw_message = self.message_queue[sock].get_nowait() - response = messages.Response(status="OK", message=raw_message) - except queue.Empty: - self.outputs.remove(sock) - else: - send(sock, response) diff --git a/backend/server.py b/backend/server.py index 5842e7f..5962cf7 100644 --- a/backend/server.py +++ b/backend/server.py @@ -32,10 +32,16 @@ def send(sock: ssl.SSLSocket, response: messages.Response) -> None: host, port = sock.getpeername() lock.release() - log = f"{host}:{port} | {response.status}" - if response.message: - log += f': {response.message}' - logger.write(log) + host = f"{host}:{port}" + message = response.message if response.message is not None else "" + log_args = (response.action, message, host) + + if response.status.upper() == "OK": + logger.success(*log_args) + elif response.status.upper() == "ERROR": + logger.warning(*log_args) + else: + logger.error(*log_args) class ClientThread(threading.Thread): @@ -49,7 +55,7 @@ def get_request(self): request = messages.Request(raw_message) return request - def get_response(self, request): + def get_response(self, request) -> messages.Response: controller = controllers.Controller(self.auth_token) response = getattr(controller, request.action)(request) if request.action == "login" and response.status == "OK": @@ -97,7 +103,7 @@ def process(self, server_socket: socket.socket): conn, c_addr = server_socket.accept() secure_client = self.get_secure_socket(conn) try: - logger.write(f"Connected by {c_addr[0]}:{c_addr[1]}") + logger.info(f"Connected by {c_addr[0]}:{c_addr[1]}") c = ClientThread(secure_client) c.start() except Exception as e: @@ -105,11 +111,11 @@ def process(self, server_socket: socket.socket): send(secure_client, response) secure_client.close() except Exception as e: - logger.write(str(e)) + logger.info(str(e)) finally: server_socket.close() def runserver(self): - logger.write(f"Starting server at {self.address[0]}:{self.address[1]}") + logger.info(f"Starting server at {self.address[0]}:{self.address[1]}") server_socket = self.get_raw_socket() self.process(server_socket) diff --git a/config_example.ini b/config_example.ini index 7cd9a06..342163c 100644 --- a/config_example.ini +++ b/config_example.ini @@ -1,4 +1,6 @@ [SECRET] KEY = ... SALT = ... + +[GENERAL] DEBUG = False \ No newline at end of file diff --git a/core/controllers.py b/core/controllers.py index 22153b9..6cef36c 100644 --- a/core/controllers.py +++ b/core/controllers.py @@ -28,13 +28,13 @@ def register(self, request): params = request.params users = self.user_model.filter(username=params.get("username")) if len(users) > 0: - return Response(status="ERROR", message="Given user already exists.") + return Response(status="ERROR", message="Given user already exists.", action="register") username = params.get("username") password = params.get("password") self.user_model.create(username=username, password=password) users = self.user_model.first(username=username) - return ModelResponse("OK", self.user_model, users) + return ModelResponse("OK", self.user_model, users, action="register") def login(self, request): params = request.params @@ -42,23 +42,22 @@ def login(self, request): password = params["password"] user = self.user_model.first(username=username) if not user or not crypto.compare(password, user[2]): - return Response(status="ERROR", message="Incorrect username or/and password.") + return Response(status="ERROR", message="Incorrect username or/and password.", action="login") token = self._get_token(user[0]) - return ModelResponse("OK", self.auth_model, token) + return ModelResponse("OK", self.auth_model, token, action="login") @validate_auth def logout(self, request): token = self.auth_token self.auth_model.delete(token=token) - return Response("OK", message="Logged out.") + return Response("OK", action="logout") @validate_auth def create(self, request): user_id = self.auth_model.first(token=self.auth_token)[1] post = self.post_model.create(user_id=user_id, **request.params) - return ModelResponse(status="OK", model=self.post_model, raw_instance=post, - message="Post created successfully.") + return ModelResponse(status="OK", model=self.post_model, raw_instance=post, action="create") @validate_auth def get(self, request): @@ -70,22 +69,22 @@ def get(self, request): else: instance = self.post_model.all() if instance: - return ModelResponse("OK", self.post_model, raw_instance=instance) - return Response("WRONG", "Not Found.") + return ModelResponse("OK", self.post_model, raw_instance=instance, action="get") + return Response("WRONG", "Not Found.", action="get") @validate_auth def alter(self, request): post_id = request.params.pop("id") instance = self.post_model.update(data=request.params, where={"id": post_id}) if instance: - return ModelResponse("OK", self.post_model, instance) - return Response("WRONG", "Not Found.") + return ModelResponse("OK", self.post_model, instance, action="alter") + return Response("WRONG", "Not Found.", action="alter") @validate_auth def delete(self, request): post_id = request.params.pop("id") obj = self.post_model.delete(id=post_id) if obj is None: - return Response("WRONG", "Not Found.") - return Response("OK", data={"id": post_id}) + return Response("WRONG", "Not Found.", action="delete") + return Response("OK", data={"id": post_id}, action="delete") diff --git a/core/messages.py b/core/messages.py index 81e0738..5842f36 100644 --- a/core/messages.py +++ b/core/messages.py @@ -47,7 +47,8 @@ def get_params(self): return params class Response(object): - def __init__(self, status, message=None, data=None): + def __init__(self, status, message=None, data=None, action=""): + self.action = action.upper() self.message = message self.status = status self.data = data @@ -70,7 +71,7 @@ def __repr__(self): class ModelResponse(Response): - def __init__(self, status, model, raw_instance: Union[list, tuple], message=""): + def __init__(self, status, model, raw_instance: Union[list, tuple], message="", action=""): if not isinstance(model, models.Model): model = model() @@ -81,7 +82,7 @@ def __init__(self, status, model, raw_instance: Union[list, tuple], message=""): self.raw_instance = raw_instance data = self.create_data() - super(ModelResponse, self).__init__(status, message, data=data) + super(ModelResponse, self).__init__(status, message, data=data, action=action) def get_record(self, instance, table_schema): return {col_name: val for col_name, val in zip(table_schema, instance) if diff --git a/settings.py b/settings.py index a222cc3..c744c22 100644 --- a/settings.py +++ b/settings.py @@ -6,6 +6,8 @@ SECRET_KEY = parser.get("SECRET", "KEY") SALT = parser.get("SECRET", "SALT").encode() +DEBUG = parser.get("GENERAL", "DEBUG") + EXPIRATION = { "minutes": 15 diff --git a/utils.py b/utils.py index 6528c94..23dff92 100644 --- a/utils.py +++ b/utils.py @@ -84,15 +84,34 @@ def get_log_filename(self): filename = f"{self.log_dir}/{self.filename_prefix}{file_number}.log" return filename - def _get_message(self, message): + def _get_log_body(self, message): now = datetime.now() log_without_date = self.log_template.format(message=message) full_log = now.strftime(log_without_date) return full_log - def write(self, message): + def _write(self, message): + log = self._get_log_body(message) + log = log.strip("| :") filename = self.get_log_filename() - log = self._get_message(message) with open(filename, "a") as file: file.write(log + "\n") print(log) + + def error(self, action, message="", host=""): + if settings.DEBUG: + message = f"{host} | ERROR: {message} | {action}" + else: + message = f"{host} | ERROR: Unexpected error" + self._write(message) + + def success(self, action, message="", host=""): + message = f"{host} | OK: {message} | {action}" + self._write(message) + + def warning(self, action, message="", host=""): + message = f"{host} | WRONG: {message} | {action}" + self._write(message) + + def info(self, message): + self._write(message) \ No newline at end of file From beeb63cb6bc858503f587071cea931eb83e9a6c4 Mon Sep 17 00:00:00 2001 From: DanielKusyDev Date: Mon, 15 Jun 2020 10:24:48 +0200 Subject: [PATCH 36/42] Add README.MD --- README.MD | 113 +++++++++++++++++++++++++++++++++++++++ tests.py | 154 +----------------------------------------------------- 2 files changed, 114 insertions(+), 153 deletions(-) create mode 100644 README.MD diff --git a/README.MD b/README.MD new file mode 100644 index 0000000..7369257 --- /dev/null +++ b/README.MD @@ -0,0 +1,113 @@ +# Proton Protocol + +## Opis protokołu + + +Proton jest protokołem tekstowym, umożliwiającym komunikację pomiędzy serwerem a klientami za pomocą formatu **JSON**. +Każdy JSON jest konwertowany do postaci tekstowej (typ string), a na jego końcu jest dodawany znak końca linii **\r\n**. + +Protokół ten działa w oparciu o szyfrowane gniazda TCP. Każdy klient musi sprawdzać klucz serwera. + +*** + +#### Odbiór / wysyłanie wiadomości +Wszystkie wiadomość wysyłane przez klienta mają następujący format tekstowy: + +`"{ "action": "{action}", "params": {params}}\r\n"` + +gdzie: + +`{Action}` - jedna z akcji (wyjaśnionych szczegółowo niżej): login, register, logout, get, create, alter, delete. + +`{Params}` - obiekt z danymi wymaganymi do zapytania. Opcjonalne. + + +Wszystkie wiadomości wysyłane przez serwer mają następujący format tekstowy: + +`"{"status": "{status}","message": "{message}", "data": {data}}\r\n"` + +gdzie: + +`{status}` - informacja o powodzeniu akcji lub jego braku. Jedno z wartości +- OK - polecenie wykonane prawidłowo, +- WRONG - polecenie wykonane, ale nie znaleziono danych potrzebnych w zapytaniu +- ERROR - błąd wykonania polecenia. + +`{message}` - Opcjonalne. Wiadomość z opisem wykonania akcji. +`{data}` - Opcjonalne. Tablica z danymi będącymi wynikiem akcji. + +Proces odbioru wiadomości polega na nasłuchiwaniu gniazda, dopóki wiadomość nie będzie zakończona znakiem końca linii **\r\n**. + +*** + +### Akcje + +##### Register +Umożliwia zarejestrowanie użytkownika + +Przykładowy request: +`{action: "register", params: {username: "Test1234", password: "Test1234"}}\r\n` + +Przykładowy response: +`{"data": [{"id": 6, "username": "Test1234"}], "message": "", "status": "OK"}\r\n` + +##### Login +Uwierzytelnienie użytkownika, umożliwia dostęp do pozostałych akcji w komunikacji. + +Przykładowy request: +`{action: "login", params: {username: "Qwerty", password: "Qwerty"}}\r\n` + +Przykładowy response: +`{"data": [{"user_id": 5}], "message": "", "status": "OK"}\r\n` + +##### Logout + +Opis umożliwia bezpieczne wylogowanie użytkownika, zakończenie sesji oraz zamknięcie połączenia klienta. + +Przykładowy request: +`{"action":"logout"}` + +Przykładowy response: +`{"message": "Logged out.", "status": "OK"}` + +##### Get +Pobranie listy postów bloga. Z opcjonalnym parametrem id, umożliwia pobranie pojedynczego posta. + +Przykładowy request: +`{"action":"get"}\r\n` + +Przykładowy response: +`{"data": [ { "id": 1, "image": "base64…","content": "Lorem ipsum dolor sit amet...","title": "Lorem ipsum dolor sit amet...","user_id": 1} ], "message": "","status": "OK" }\r\n` + +gdzie *image* to zdjęcie zapisane w formacie base64. + +##### Create +Tworzy nowy post. + +Przykładowy request: +`{"action": "create", "params": {"image": "base64…", "content": "Lorem ipsum dolor sit amet...", "title": "Lorem ipsum dolor sit amet..."}}\r\n` + + +Przykładowy response: +`{"data": [{"content": ""Lorem ipsum dolor sit amet..", "id": 14, "image": "base64", "title": ""Lorem ipsum dolor sit amet..", "user_id": 5}], "message": "Post created successfully.", "status": "OK"}\r\n` + +##### Alter +Zmienia parametry posta. + +Przykładowy request: +`{"action": "alter", "params": {id: 14, "image": "base64…", "content": "Lorem ipsum dolor sit amet...", "title": "Lorem ipsum dolor sit amet..."}}\r\n` + +Przykładowy response: +`{"data": [{"content": "Gghggff", "id": 14, "image": base64", "title": "Hbvggv", "user_id": 5}], "message": "", "status": "OK"}\r\n` + +##### Delete +Usuwa post. + +Przykładowy request: +`{"action": "delete", "params": {"id": 14}}\r\n` + +Przykładowy response: +`{"data": {"id": 14}, "status": "OK"}\r\n` + +Repozytorium zawiera implementację serwera obsługującego protokół. Klient w postaci aplikacji mobilnej dostępny pod adresem +https://github.com/lukaszkurantdev/proton-blog-app \ No newline at end of file diff --git a/tests.py b/tests.py index b7100cb..d7723a1 100644 --- a/tests.py +++ b/tests.py @@ -284,156 +284,4 @@ def test_post_deletion(self): class ThreadedServer(threading.Thread): def run(self) -> None: server = Server(("localhost", 1234)) - server.runserver() - - -# class ClientRequestTests(unittest.TestCase): -# -# @classmethod -# def setUpClass(cls) -> None: -# shutil.move(settings.DATABASE, "sqlite3.db.bak") -# utils.create_db() -# -# @classmethod -# def tearDownClass(cls) -> None: -# shutil.move("sqlite3.db.bak", settings.DATABASE) -# -# -# def setUp(self) -> None: -# self.connect() -# -# def tearDown(self) -> None: -# self.secure_sock.close() -# -# def recv_all(self, sock: ssl.SSLSocket) -> str: -# result = "" -# while result[-2:] != "\r\n": -# result += sock.read(1).decode() -# return result -# -# def send(self, req): -# sock = self.secure_sock -# if req[-2:] != "\r\n": -# req += "\r\n" -# for i in req: -# sock.sendall(i.encode("utf-8")) -# return self.recv_all(sock) -# -# def connect(self): -# -# host = settings.HOST -# port = settings.PORT -# sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) -# sock.setblocking(True) -# sock.connect((host, port)) -# -# context = ssl.SSLContext(ssl.PROTOCOL_TLS) -# context.verify_mode = ssl.CERT_REQUIRED -# context.load_verify_locations("server.pem") -# context.load_cert_chain("client.pem") -# -# if ssl.HAS_SNI: -# self.secure_sock = context.wrap_socket(sock, server_side=False, server_hostname=host) -# else: -# self.secure_sock = context.wrap_socket(sock, server_side=False) -# -# cert = self.secure_sock.getpeercert() -# -# if not cert or ("commonName", "proton") not in cert["subject"][5]: -# raise Exception -# -# def get(self, id=None): -# if id is not None: -# req = """{ -# "action": "get", -# "params": {"id":%s} -# }""" % str(id) -# else: -# req = """{ -# "action": "get" -# }""" -# -# return self.send(req) -# -# def delete(self, id): -# req = """{ -# "action": "delete", -# "params": { -# "id": %s -# } -# }""" % str(id) -# return self.send(req) -# -# def alter(self, id): -# req = """{ -# "action": "alter", -# "params": { -# "id": %s, -# "content": "dupa", -# "title": "tu tez" -# } -# }\r\n""" % str(id) -# return self.send(req) -# -# def logout(self, **kwargs): -# req = """{ -# "action": "logout" -# }""" -# return self.send(req) -# -# def login(self): -# req = """{ -# "action": "login", -# "params": { -# "username": "Test", -# "password": "passwd" -# } -# }""" -# return self.send(req) -# -# def register(self): -# req = """{ -# "action": "register", -# "params": { -# "username": "Test", -# "password": "passwd" -# } -# }""" -# return self.send(req) -# -# def _check_auth(self, method, **kwargs): -# result = method(**kwargs) -# result = json.loads(result) -# self.assertEqual(result["status"].upper(), "ERROR") -# self.assertIn("permission", result["message"].lower()) -# -# def test_unauthorized_get(self): -# self._check_auth(self.get) -# self._check_auth(self.get, id=1) -# -# def test_unauthorized_delete(self): -# self._check_auth(self.delete, id=1) -# -# def test_unauthorized_alter(self): -# self._check_auth(self.alter, id=1) -# -# def test_unauthorized_logout(self): -# self._check_auth(self.logout) -# -# def test_deletion(self): -# result = json.loads(self.register()) -# print(result) -# print(self.login()) -# post_id = result["data"][0]["id"] -# result = self.delete(id=post_id) -# print(result) -# -# # def test_registration(self): -# # result = self.register() -# # print(result) -# # result = json.loads(result) -# # self.assertEqual(result["status"].upper(), "OK") -# # self.assertEqual(len(result["data"]), 1) -# # post_id = result["data"][0].get("id", None) -# # self.assertIsNotNone(post_id) -# # self.delete(id=post_id) + server.runserver() \ No newline at end of file From d353675ba7d595bfed64cf9d60acd7d6a9284023 Mon Sep 17 00:00:00 2001 From: DanielKusyDev Date: Mon, 15 Jun 2020 11:11:23 +0200 Subject: [PATCH 37/42] Disable checking client's cert by server --- backend/certs/server.key | 15 +++++++++++++++ backend/server.py | 11 +++++++---- settings.py | 1 + 3 files changed, 23 insertions(+), 4 deletions(-) create mode 100644 backend/certs/server.key diff --git a/backend/certs/server.key b/backend/certs/server.key new file mode 100644 index 0000000..8d99399 --- /dev/null +++ b/backend/certs/server.key @@ -0,0 +1,15 @@ +-----BEGIN RSA PRIVATE KEY----- +MIICXQIBAAKBgQDXe2VCBbR2OK7qRduCPX9dAvPI+UVyklZziJE5gDvTaqV3vDel +aLpdNwcW9Mo+ubg7oMTZFB6vujY3mtswnTKKw+5wOvx+O9S/U2PUl13pFuQs23mu +6j4yOoScJg8z+82REuu0RHFYlcxMxVq6YcdxSPqf9KQufwHPrCy54RKeNQIDAQAB +AoGAFc8WY4VCS4jXIzzox5jD0D0hQWEBR2RKPa0/zYsOAwrTLngtRZ+A5ThRjmA+ +K/UOEXLnGXVw2aZGIICa2KPAXp2C9m/WA0YVuMXk5+7Z+VC51KIuXyzbTWwMcaR7 ++O3ELL8LVAewJVSWf4MD0wjqw5991uCs30l9OwK3UoD87Q0CQQDvOc4k5wANNdxZ +kri8yznSoEMSiyLur+r4VgNr7P0plPn1CyvQeoBC+9bnDx2YJ3t3zhdWBAPRGf6R +m5H9mctbAkEA5pdhVPANNgfyi17f4v2CRs6SIqoudVfFhwpeLm9Zc22PsrI3XU1f +w2b6CT0PbKhQgVeDnvuPl0666eyxxrPBrwJBALIe61Pkv9AWQ3xaV70S4HnopChB +ewAX8i9389I/QfzdFQQUjkoLfEbjtw6R3ao186OvywZbtO/TmA2YtSoLgjMCQQCN +NSSYhAxDCyjfajEGayINRFC/Q6IBn8dJg/La0rtfcTdvQa2fyFMdcQErCSZZ7tSl +8Dac9AYhrUfPGnBfcxLnAkAjhleXW8NbGfXqdGPaZqbXeLtegDxnIFE4vtIgqqKO +Q1gucVF/5qQWU/QCgpKJLEbRRItpVFHA77KciBAAa54z +-----END RSA PRIVATE KEY----- diff --git a/backend/server.py b/backend/server.py index 5962cf7..4792167 100644 --- a/backend/server.py +++ b/backend/server.py @@ -1,8 +1,10 @@ +import os import socket import ssl import threading from time import sleep +import settings from core import messages, controllers, models from utils import Logger @@ -89,12 +91,13 @@ def get_raw_socket(self) -> socket.socket: return raw_socket def get_secure_socket(self, raw_socket: socket.socket) -> ssl.SSLSocket: - ssock = ssl.wrap_socket(raw_socket, server_side=True, ca_certs="backend/certs/client.pem", - certfile="backend/certs/server.pem", cert_reqs=ssl.CERT_REQUIRED, - ssl_version=ssl.PROTOCOL_TLS) + context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2) + context.load_cert_chain(os.path.join(settings.CERTS_DIR, "server.pem")) + + ssock = context.wrap_socket(raw_socket, server_side=True) cert = ssock.getpeercert() if not cert or ("commonName", 'proton') not in cert['subject'][5]: - raise Exception + raise Exception("Wrong CA CommonName.") return ssock def process(self, server_socket: socket.socket): diff --git a/settings.py b/settings.py index c744c22..eb71340 100644 --- a/settings.py +++ b/settings.py @@ -16,3 +16,4 @@ MEDIA_ROOT = "assets" PORT = 6666 HOST = "0.0.0.0" +CERTS_DIR = "backend/certs" \ No newline at end of file From 29e5629b42fe44cdbfc2f28db6fd705a5f385215 Mon Sep 17 00:00:00 2001 From: DanielKusyDev Date: Mon, 15 Jun 2020 11:20:52 +0200 Subject: [PATCH 38/42] Added server.key to wrap socket fun --- .gitignore | 1 + backend/certs/server.key | 15 --------------- backend/server.py | 2 +- 3 files changed, 2 insertions(+), 16 deletions(-) delete mode 100644 backend/certs/server.key diff --git a/.gitignore b/.gitignore index 5e82ea6..f26ca94 100644 --- a/.gitignore +++ b/.gitignore @@ -132,5 +132,6 @@ dmypy.json core/db/sqlite3.db config.ini *.pem +*.key test_client.py assets diff --git a/backend/certs/server.key b/backend/certs/server.key deleted file mode 100644 index 8d99399..0000000 --- a/backend/certs/server.key +++ /dev/null @@ -1,15 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -MIICXQIBAAKBgQDXe2VCBbR2OK7qRduCPX9dAvPI+UVyklZziJE5gDvTaqV3vDel -aLpdNwcW9Mo+ubg7oMTZFB6vujY3mtswnTKKw+5wOvx+O9S/U2PUl13pFuQs23mu -6j4yOoScJg8z+82REuu0RHFYlcxMxVq6YcdxSPqf9KQufwHPrCy54RKeNQIDAQAB -AoGAFc8WY4VCS4jXIzzox5jD0D0hQWEBR2RKPa0/zYsOAwrTLngtRZ+A5ThRjmA+ -K/UOEXLnGXVw2aZGIICa2KPAXp2C9m/WA0YVuMXk5+7Z+VC51KIuXyzbTWwMcaR7 -+O3ELL8LVAewJVSWf4MD0wjqw5991uCs30l9OwK3UoD87Q0CQQDvOc4k5wANNdxZ -kri8yznSoEMSiyLur+r4VgNr7P0plPn1CyvQeoBC+9bnDx2YJ3t3zhdWBAPRGf6R -m5H9mctbAkEA5pdhVPANNgfyi17f4v2CRs6SIqoudVfFhwpeLm9Zc22PsrI3XU1f -w2b6CT0PbKhQgVeDnvuPl0666eyxxrPBrwJBALIe61Pkv9AWQ3xaV70S4HnopChB -ewAX8i9389I/QfzdFQQUjkoLfEbjtw6R3ao186OvywZbtO/TmA2YtSoLgjMCQQCN -NSSYhAxDCyjfajEGayINRFC/Q6IBn8dJg/La0rtfcTdvQa2fyFMdcQErCSZZ7tSl -8Dac9AYhrUfPGnBfcxLnAkAjhleXW8NbGfXqdGPaZqbXeLtegDxnIFE4vtIgqqKO -Q1gucVF/5qQWU/QCgpKJLEbRRItpVFHA77KciBAAa54z ------END RSA PRIVATE KEY----- diff --git a/backend/server.py b/backend/server.py index 4792167..feffdcc 100644 --- a/backend/server.py +++ b/backend/server.py @@ -92,7 +92,7 @@ def get_raw_socket(self) -> socket.socket: def get_secure_socket(self, raw_socket: socket.socket) -> ssl.SSLSocket: context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2) - context.load_cert_chain(os.path.join(settings.CERTS_DIR, "server.pem")) + context.load_cert_chain(os.path.join(settings.CERTS_DIR, "server.pem"), os.path.join(settings.CERTS_DIR, "server.key")) ssock = context.wrap_socket(raw_socket, server_side=True) cert = ssock.getpeercert() From afe4bafd69f05fc239d32b507929512686655475 Mon Sep 17 00:00:00 2001 From: DanielKusyDev Date: Mon, 15 Jun 2020 11:33:35 +0200 Subject: [PATCH 39/42] Ommit checking client ca --- backend/server.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/backend/server.py b/backend/server.py index feffdcc..d02317e 100644 --- a/backend/server.py +++ b/backend/server.py @@ -93,11 +93,7 @@ def get_raw_socket(self) -> socket.socket: def get_secure_socket(self, raw_socket: socket.socket) -> ssl.SSLSocket: context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2) context.load_cert_chain(os.path.join(settings.CERTS_DIR, "server.pem"), os.path.join(settings.CERTS_DIR, "server.key")) - ssock = context.wrap_socket(raw_socket, server_side=True) - cert = ssock.getpeercert() - if not cert or ("commonName", 'proton') not in cert['subject'][5]: - raise Exception("Wrong CA CommonName.") return ssock def process(self, server_socket: socket.socket): From dc12ccf1a38de8d5adc6faedb4db8c5eef223a8c Mon Sep 17 00:00:00 2001 From: DanielKusyDev Date: Mon, 15 Jun 2020 14:40:42 +0200 Subject: [PATCH 40/42] Fix bug with client ability to close the server --- backend/server.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/backend/server.py b/backend/server.py index d02317e..f1dba6c 100644 --- a/backend/server.py +++ b/backend/server.py @@ -99,8 +99,12 @@ def get_secure_socket(self, raw_socket: socket.socket) -> ssl.SSLSocket: def process(self, server_socket: socket.socket): try: while True: - conn, c_addr = server_socket.accept() - secure_client = self.get_secure_socket(conn) + try: + conn, c_addr = server_socket.accept() + secure_client = self.get_secure_socket(conn) + except Exception as e: + logger.info(str(e)) + continue try: logger.info(f"Connected by {c_addr[0]}:{c_addr[1]}") c = ClientThread(secure_client) @@ -111,8 +115,6 @@ def process(self, server_socket: socket.socket): secure_client.close() except Exception as e: logger.info(str(e)) - finally: - server_socket.close() def runserver(self): logger.info(f"Starting server at {self.address[0]}:{self.address[1]}") From 3649128cd5d9c97d8740739ddf70df226f1f6b2b Mon Sep 17 00:00:00 2001 From: Daniel Kusy <36250676+DanielKusyDev@users.noreply.github.com> Date: Mon, 22 Jun 2020 11:43:17 +0200 Subject: [PATCH 41/42] Update README.MD --- README.MD | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.MD b/README.MD index 7369257..1f72672 100644 --- a/README.MD +++ b/README.MD @@ -110,4 +110,7 @@ Przykładowy response: `{"data": {"id": 14}, "status": "OK"}\r\n` Repozytorium zawiera implementację serwera obsługującego protokół. Klient w postaci aplikacji mobilnej dostępny pod adresem -https://github.com/lukaszkurantdev/proton-blog-app \ No newline at end of file +https://github.com/lukaszkurantdev/proton-blog-app + +Ogólny sposób funkcjonowania serwera: +![alt text](https://www.planttext.com/api/plantuml/img/VL913i8m3Bll5Ja24X_O0I7W1S1z2fq54wKTRSRxAQnZxK2SkiPE7BjRUs4dtKqNHHi-6jMqR8Iske6Hh7I0Uy3zO1ql3bndm1xt3ZxltreZpcezcR67RwtnA6eMFh47xJO5AsaUB1X4Uo5QhcAX91SL-dkAd26LX-eSAc_L5JARZwnLjZCj5bJIEu50-eXcjZ9-a8dMGkiND3hi1wi02FxHIhgmgJMgQ6SMptIPCRQaCOpPRUXjbgmZrXAAhmGdf27TVA64ifmayafsU13yMYAjfZcbG7orDKmT_gmd "Request flow") From 5fd77ccdb6b2d0621ab385194c3976dfc37ab16d Mon Sep 17 00:00:00 2001 From: Daniel Kusy <36250676+DanielKusyDev@users.noreply.github.com> Date: Fri, 19 Jan 2024 12:58:38 +0100 Subject: [PATCH 42/42] Update requirements.txt --- requirements.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/requirements.txt b/requirements.txt index d72f455..8f172be 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,8 @@ cffi==1.14.0 cryptography==2.9.2 + + + + pycparser==2.20 six==1.14.0