diff --git a/README.md b/README.md index 477da20..23e91b2 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,36 @@ -Run tests: -`python -m unittest discover -s simple_chat_api/tests -p "*.py"` +# Simple Chat + +This is a simple one-room chat application with user management, developed as a school project for study purposes. + +**Not recommended for production use.** + +A test protocol can be found here: [simple_chat_testprotokoll.pdf](simple_chat_testprotokoll.pdf) + +## Running with Docker + +```sh +docker compose -f docker/compose.yml up +``` + +### Running in Development Mode + +With Docker: + +```sh +BUILD_TARGET=dev docker compose -f docker/compose.yml +``` + +Then, install dependencies for the frontend and start the development server: + +```sh +cd frontend +npm install +npm run dev +``` + +For the backend, install dependencies and run the server: + +```sh +pip install -r simple_chat_api/requirements.txt +python -m simple_chat_api +``` diff --git a/docker/Dockerfile b/docker/Dockerfile index 4b0e88a..1caab7f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -25,8 +25,6 @@ RUN rm /etc/nginx/nginx.conf RUN ln -s /simple-chat/docker/nginx.conf /etc/nginx/nginx.conf RUN mkdir -p /var/nginx -COPY docker/default.sqlite /simple-chat/data/data.sqlite - # Dev stage FROM base AS dev ARG ROOT_PASSWD @@ -53,6 +51,6 @@ RUN ssh-keygen -A && \ CMD ["bash", "/entrypoint.sh"] # Production stage -FROM base AS production +FROM base AS prod # Run entrypoint CMD ["bash", "/entrypoint.sh"] diff --git a/docker/compose.yml b/docker/compose.yml new file mode 100644 index 0000000..1745eab --- /dev/null +++ b/docker/compose.yml @@ -0,0 +1,18 @@ +services: + simple_chat: + build: + context: ../ + dockerfile: docker/Dockerfile + target: ${BUILD_TARGET:-prod} + container_name: simple_chat + ports: + - "8080:8080" + environment: + - ROOT_PASSWD=adminpass + - JWT_SECRET=change_me + volumes: + - data:/simple-chat/data + restart: unless-stopped + +volumes: + data: diff --git a/docker/default.sqlite b/docker/default.sqlite deleted file mode 100644 index 299dd8b..0000000 Binary files a/docker/default.sqlite and /dev/null differ diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index e24cffa..070a7a9 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -17,20 +17,24 @@ p { input, textarea { - @apply w-full p-2 border border-gray-500 bg-teal-200 rounded hover:bg-teal-300 focus:bg-teal-300 focus:outline-none focus:ring-2 focus:ring-teal-500; + @apply w-full p-2 border cursor-text border-gray-500 bg-teal-200 rounded hover:bg-teal-300 focus:bg-teal-300 focus:outline-none focus:ring-2 focus:ring-teal-500; } input::placeholder { @apply text-gray-500 hover:text-gray-700; } button { - @apply w-full bg-blue-500 text-white p-2 rounded hover:bg-blue-600; + @apply w-full cursor-pointer bg-blue-500 text-white p-2 rounded hover:bg-blue-600; } main { @apply w-full h-screen flex items-center justify-center; } +main > div { + @apply overflow-scroll max-h-full justify-center; +} + .boxed { @apply space-y-2 bg-blue-200 p-4 rounded-2xl shadow-lg; } diff --git a/frontend/src/components/Header.vue b/frontend/src/components/Header.vue index 62fd3b0..91d69a5 100644 --- a/frontend/src/components/Header.vue +++ b/frontend/src/components/Header.vue @@ -10,11 +10,8 @@ import router from '@/router' >

Simple Chat

-
diff --git a/frontend/src/composable/settings.ts b/frontend/src/composable/settings.ts index 2bae06f..3128c18 100644 --- a/frontend/src/composable/settings.ts +++ b/frontend/src/composable/settings.ts @@ -21,6 +21,29 @@ export const deleteUser = async (user: User): Promise => { }) } +export const changePassword = async (oldPassword: string, newPassword: string): Promise => { + const response = await fetch(`${API_URL}/user/changePassword`, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + old_password: oldPassword, + new_password: newPassword, + }), + }) + if (response.ok) { + const data = await response.json() + return data.message + } else { + const errorData = await response.json().catch(() => null) + throw new Error( + 'Failed to change password. ' + (errorData && errorData.error ? errorData.error : ''), + ) + } +} + export const addUser = async ( username: string, password: string, diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index e132129..a8806ac 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -16,8 +16,8 @@ const router = createRouter({ meta: { requiresAuth: true }, }, { - path: '/admin', - name: 'admin', + path: '/settings', + name: 'settings', component: () => import('../views/User.vue'), }, { diff --git a/frontend/src/views/User.vue b/frontend/src/views/User.vue index bdff190..8542bab 100644 --- a/frontend/src/views/User.vue +++ b/frontend/src/views/User.vue @@ -2,7 +2,7 @@ import { ref } from 'vue' import { API_URL } from '@/main.ts' import { type User, primaryUser } from '@/composable/auth.ts' -import { deleteUser, addUser } from '@/composable/settings' +import { deleteUser, addUser, changePassword } from '@/composable/settings' import UserInfo from '@/components/UserInfo.vue' @@ -11,6 +11,9 @@ const new_user_passwd = ref('') const new_admin = ref(false) const userCreationMsg = ref({ message: '', type: 'info' }) const userDeletionMsg = ref({ message: '', type: 'info' }) +const changePassMsg = ref({ message: '', type: 'info' }) + +const changePassField = ref({ old: '', new: '' }) const onNewUserCreation = async () => { addUser(new_user_name.value, new_user_passwd.value, new_admin.value) @@ -25,6 +28,19 @@ const onNewUserCreation = async () => { console.error(error) }) } + +const onChangePassword = async () => { + changePassword(changePassField.value.old, changePassField.value.new) + .then((data: string) => { + changePassMsg.value = { message: data, type: 'success' } + changePassField.value.old = '' + changePassField.value.new = '' + }) + .catch((error: any) => { + changePassMsg.value = { message: `${error}`, type: 'error' } + console.error(error) + }) +} diff --git a/simple_chat_api/config.py b/simple_chat_api/config.py index 3d70061..163a891 100644 --- a/simple_chat_api/config.py +++ b/simple_chat_api/config.py @@ -2,7 +2,8 @@ from passlib.context import CryptContext import os -JWT_SECRET = "F&M2eb%*T2dnhZqxw^ts6qotqF&M2eb%*T2dnhZqxw^ts6qotq" +JWT_SECRET = os.environ.get("JWT_SECRET", "F&M2eb%*T2dnhZqxw^ts6qotqF&M2eb%*T2dnhZqxw^ts6qotq") +debug = os.environ.get("DEV", "False").lower() in ("true", "1", "t") hash_context = CryptContext(schemes=["bcrypt"]) db_url = os.environ.get("DATABASE_URL", "sqlite:///./data/data.sqlite") use_optional_default_user = os.environ.get("CREATE_OPTIONAL_DEFAULT_USER", "False").lower() in ("true", "1", "t") diff --git a/simple_chat_api/db_handler.py b/simple_chat_api/db_handler.py index a8d8c2b..3d023dc 100644 --- a/simple_chat_api/db_handler.py +++ b/simple_chat_api/db_handler.py @@ -71,6 +71,13 @@ class DbConnector: self.session.delete(user) self.session.commit() + def change_password(self, name: str, new_hash: str): + user = self.get_user(name) + if not user: + raise ValueError("User does not exist") + user.hash = new_hash + self.session.commit() + def add_msg_to_room(self, room: str, msg: str, user: str): new_msg = Message(room=room, content=msg, user=user, timestamp=int(time.time())) self.session.add(new_msg) diff --git a/simple_chat_api/endpoints/auth.py b/simple_chat_api/endpoints/auth.py index 6122bd3..a712446 100644 --- a/simple_chat_api/endpoints/auth.py +++ b/simple_chat_api/endpoints/auth.py @@ -110,6 +110,22 @@ def add_user(user): response.status = 400 return dumps({"error": str(e)}) +@app.route("/changePassword", method=["POST"]) +@user_guard() +def change_password(user): + data = read_keys_from_request(["new_password", "old_password"]) + response.content_type = 'application/json' + try: + if not hash_context.verify(data["old_password"], user.hash): + response.status = 400 + return dumps({"error": "Old password is incorrect"}) + request.db_connector.change_password(user.name, hash_context.hash(data["new_password"])) + response.status = 200 + return dumps({"message": "Password changed successfully"}) + except ValueError as e: + response.status = 400 + return dumps({"error": str(e)}) + @app.route("/delete/", method=["POST"]) @admin_guard() def delete_user(_, deletion_target: str): diff --git a/simple_chat_api/main.py b/simple_chat_api/main.py index c3677c5..da885a9 100644 --- a/simple_chat_api/main.py +++ b/simple_chat_api/main.py @@ -4,6 +4,7 @@ from simple_chat_api.db_handler import DbConnector import simple_chat_api.endpoints.auth as auth import simple_chat_api.endpoints.messages as messages +import simple_chat_api.config as config import bcrypt # Needet because of: https://github.com/pyca/bcrypt/issues/684 @@ -14,7 +15,7 @@ app = Bottle() def initialize_app(): - db = DbConnector("sqlite:///./data/data.sqlite") + db = DbConnector(config.db_url) @app.hook('before_request') def attach_resources(): @@ -31,4 +32,4 @@ def main(): root_app = Bottle() root_app.mount('/api', app) - root_app.run(host='localhost', port=7000, debug=True) \ No newline at end of file + root_app.run(host='localhost', port=7000, debug=config.debug) \ No newline at end of file diff --git a/simple_chat_api/tests/auth_entpoints.py b/simple_chat_api/tests/auth_entpoints.py index b160a4a..5184ac4 100644 --- a/simple_chat_api/tests/auth_entpoints.py +++ b/simple_chat_api/tests/auth_entpoints.py @@ -5,9 +5,17 @@ import time import os import signal import requests +import unittest server_process = None API_URL = "http://localhost:7000/api" +DB_PATH = "./data/TEST.sqlite" + +users = { + "admin": "admin", + "max": "12345" + } + def setUpModule(): """Start the API server in a separate process before running tests""" def run_server(): @@ -15,7 +23,7 @@ def setUpModule(): # Needet to get python3 dir env = os.environ.copy() env.update({ - "DATABASE_URL": "sqlite:///./data/TEST.sqlite", + "DATABASE_URL": f"sqlite:///{DB_PATH}", "CREATE_OPTIONAL_DEFAULT_USER": "true" }) @@ -40,9 +48,20 @@ def tearDownModule(): if server_process: server_process.send_signal(signal.SIGTERM) server_process.wait() + os.remove(DB_PATH) + +def build_session(user: str, password: str) -> requests.Session: + session = requests.Session() + response = session.post(f"{API_URL}/user/token", json={ + "user": user, + "password": password + }) + if response.status_code != 200: + raise ValueError(f"Failed to get token for user {user}; {response.text}") + return [session, response.json()] + class TestServer(unittest.TestCase): - def test_online(self): """Test if the server is running""" response = requests.get(API_URL) @@ -50,25 +69,19 @@ class TestServer(unittest.TestCase): class TestAuthEndpoints(unittest.TestCase): + userSessions = {} def __init__(self, methodName = "runTest"): super().__init__(methodName) - self.users = { - "admin": "admin", - "max": "12345" - } - self.userSessions = {} - def test_get_token(self): + def test_1_get_token(self): """Test the /token endpoint""" - for user,password in self.users.items(): + for user,password in users.items(): with self.subTest(user=user): - self.userSessions[user] = requests.Session() - response = self.userSessions[user].post(f"{API_URL}/user/token", json={ - "user": user, - "password": password - }) - self.assertEqual(response.status_code, 200, f"Failed to get token for user {user}; {response.text}") - data = response.json() + try: + session, data = build_session(user, password) + self.userSessions[user] = session + except ValueError as e: + self.fail(str(e)) excepted = { "sub": { "user": user, @@ -76,3 +89,191 @@ class TestAuthEndpoints(unittest.TestCase): }, } self.assertEqual(data["sub"], excepted["sub"], f"Token content mismatch for user {user}") + + def test_2_get_user(self): + if self.userSessions == {}: + self.skipTest("No user sessions available. Run test_get_token first.") + for user,session in self.userSessions.items(): + with self.subTest(user=user): + response = session.get(f"{API_URL}/user") + self.assertEqual(response.status_code, 200, f"Failed to get user info for {user}; {response.text}") + data = response.json() + excepted = { + "name": user, + } + self.assertEqual(data, excepted, f"User info mismatch for {user}") + + def test_3_get_all(self): + if self.userSessions == {}: + self.skipTest("No user sessions available. Run test_get_token first.") + + for user,session in self.userSessions.items(): + with self.subTest(user=user): + response = session.get(f"{API_URL}/user/getAll") + if user == "admin": + self.assertEqual(response.status_code, 200, f"Failed to get all users for admin; {response.text}") + data = response.json() + excepted = [ + {"name": "admin", "role": "admin"}, + {"name": "max", "role": "user"} + ] + self.assertTrue(all(item in data for item in excepted), f"User list mismatch for admin") + else: + self.assertEqual(response.status_code, 401, f"Non-admin user {user} should not access /getAll; {response.text}") + + def test_4_create_user(self): + if self.userSessions == {}: + self.skipTest("No user sessions available. Run test_get_token first.") + + new_users = [{ + "new_user": "newuser", + "new_password": "newpass", + "new_admin": False + }, + { + "new_user": "admin2", + "new_password": "adminpass", + "new_admin": True + }, + { + "new_user": "invalid/user", + "new_password": "pass", + "new_admin": False + } + ] + + for user,session in self.userSessions.items(): + with self.subTest(user=user): + if user == "admin": + for new_user in new_users: + if "invalid" in new_user["new_user"]: + response = session.post(f"{API_URL}/user/add", json=new_user) + self.assertEqual(response.status_code, 400, f"Creating user with invalid name should fail; {response.text}") + continue + response = session.post(f"{API_URL}/user/add", json=new_user) + self.assertEqual(response.status_code, 200, f"Admin failed to create new user; {response.text}") + data = response.json() + self.assertIn("created successfully", data["message"], f"Created user info mismatch") + + response = session.post(f"{API_URL}/user/add", json=new_user) + self.assertEqual(response.status_code, 400, f"Creating duplicate user should fail; {response.text}") + else: + self.assertEqual(response.status_code, 400, f"Non-admin user {user} should not create users; {response.text}") + + def test_5_delete_user(self): + if self.userSessions == {}: + self.skipTest("No user sessions available. Run test_get_token first.") + + for user,session in self.userSessions.items(): + with self.subTest(user=user): + if user == "admin": + response = session.post(f"{API_URL}/user/delete/kim") + self.assertEqual(response.status_code, 200, f"Admin failed to delete user kim; {response.text}") + data = response.json() + self.assertIn("deleted successfully", data["message"], f"Deleted user info mismatch") + + response = session.post(f"{API_URL}/user/delete/nonexistent") + self.assertEqual(response.status_code, 400, f"Deleting non-existent user should fail; {response.text}") + else: + response = session.post(f"{API_URL}/user/delete/max") + self.assertEqual(response.status_code, 401, f"Non-admin user {user} should not delete users; {response.text}") + + def test_6_change_password(self): + if self.userSessions == {}: + self.skipTest("No user sessions available. Run test_get_token first.") + + for user,session in self.userSessions.items(): + with self.subTest(user=user): + pyload = { + "new_password": "newadminpass" + } + response = session.post(f"{API_URL}/user/changePassword/", json=pyload) + self.assertEqual(response.status_code, 200, f"Admin failed to change password; {response.text}") + + +class TestMessage(unittest.TestCase): + last_msg = None + userSessions = {} + messages = [ + {"content": "Admin here, managing things."}, + {"content": "Hello, this is max!"}, + ] + @classmethod + def setUpClass(cls): + for user, password in users.items(): + cls.userSessions[user], _ = build_session(user, password) + + def test_1_post_message(self): + if not self.userSessions: + self.skipTest("No user sessions available. Run test_get_token first.") + + + + for i, (user, session) in enumerate(self.userSessions.items()): + with self.subTest(user=user): + response = session.post(f"{API_URL}/messages/general", json=self.messages[i]) + self.assertEqual( + response.status_code, + 200, + f"User {user} failed to post message; {response.text}" + ) + + clear_json = response.json() + + msg_id, data = list(clear_json.items())[-1] + + TestMessage.last_msg = { + "message_id": msg_id, + "timestamp": data["timestamp"], + } + + self.assertIn( + self.messages[i]["content"], + data["content"], + f"Posted message response missing content for user {user}" + ) + time.sleep(1) # Ensure different timestamps + + def test_2_get_messages_since(self): + if not self.userSessions or self.last_msg is None: + self.skipTest("No user sessions available or no messages posted. Run previous tests first.") + + for user, session in self.userSessions.items(): + with self.subTest(user=user): + response = session.get(f"{API_URL}/messages/general?since={self.last_msg['timestamp']}") + self.assertEqual( + response.status_code, + 200, + f"User {user} failed to get messages since timestamp; {response.text}" + ) + + first_key = list(response.json().keys())[0] + + self.assertEqual( + first_key, + self.last_msg["message_id"], # match full message dict + f"User {user} did not receive the expected message" + ) + + def test_3_get_all_messages(self): + if not self.userSessions or self.last_msg is None: + self.skipTest("No user sessions available or no messages posted. Run previous tests first.") + + for user, session in self.userSessions.items(): + with self.subTest(user=user): + response = session.get(f"{API_URL}/messages/general") + self.assertEqual( + response.status_code, + 200, + f"User {user} failed to get all messages; {response.text}" + ) + + messages_data = response.json() + message_contents = [msg["content"] for _, msg in messages_data.items()] + + expected_contents = [msg["content"] for msg in self.messages] + for content in expected_contents: + self.assertIn(content, message_contents, f"Message with content '{content}' not found") + + self.assertEqual(len(messages_data), len(self.messages), "Number of messages doesn't match expected") + \ No newline at end of file diff --git a/simple_chat_testprotokoll.pdf b/simple_chat_testprotokoll.pdf new file mode 100644 index 0000000..5f10385 Binary files /dev/null and b/simple_chat_testprotokoll.pdf differ