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'
>
router.push('/')" class="hover:cursor-pointer">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)
+ })
+}
@@ -35,6 +51,19 @@ const onNewUserCreation = async () => {
Name: {{ primaryUser.currentUser.value.user }}
Role: {{ primaryUser.currentUser.value.role }}
+
+
+
Change Own Password
+
+
+
onChangePassword()">Change Password
+
+
+ {{ changePassMsg.message }}
+
+
+
+
Users
@@ -88,9 +117,6 @@ const onNewUserCreation = async () => {
-
-
You need Admin rights to see the rest...
-
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