diff --git a/.vscode/launch.json b/.vscode/launch.json index 96d5521..f19048b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -8,9 +8,17 @@ "name": "Python Debugger: Current File", "type": "debugpy", "request": "launch", - "program": "${cwd}/backend_py/main.py", + "program": "${file}", "console": "integratedTerminal" }, + { + "name": "Python Debugger: Main", + "type": "debugpy", + "request": "launch", + "program": "${cwd}/backend_py/main.py", + "console": "integratedTerminal", + "justMyCode": true + }, { "type": "node", "request": "launch", diff --git a/backend_py/__pycache__/auth.cpython-312.pyc b/backend_py/__pycache__/auth.cpython-312.pyc new file mode 100644 index 0000000..7d7a195 Binary files /dev/null and b/backend_py/__pycache__/auth.cpython-312.pyc differ diff --git a/backend_py/__pycache__/config.cpython-312.pyc b/backend_py/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000..0c8d4e8 Binary files /dev/null and b/backend_py/__pycache__/config.cpython-312.pyc differ diff --git a/backend_py/__pycache__/db_handler.cpython-312.pyc b/backend_py/__pycache__/db_handler.cpython-312.pyc index ed90643..5f795fe 100644 Binary files a/backend_py/__pycache__/db_handler.cpython-312.pyc and b/backend_py/__pycache__/db_handler.cpython-312.pyc differ diff --git a/backend_py/__pycache__/main.cpython-312.pyc b/backend_py/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000..86eedc1 Binary files /dev/null and b/backend_py/__pycache__/main.cpython-312.pyc differ diff --git a/backend_py/__pycache__/messages.cpython-312.pyc b/backend_py/__pycache__/messages.cpython-312.pyc new file mode 100644 index 0000000..8c45080 Binary files /dev/null and b/backend_py/__pycache__/messages.cpython-312.pyc differ diff --git a/backend_py/__pycache__/utils.cpython-312.pyc b/backend_py/__pycache__/utils.cpython-312.pyc new file mode 100644 index 0000000..a76d02d Binary files /dev/null and b/backend_py/__pycache__/utils.cpython-312.pyc differ diff --git a/backend_py/auth.py b/backend_py/auth.py new file mode 100644 index 0000000..f04eb99 --- /dev/null +++ b/backend_py/auth.py @@ -0,0 +1,117 @@ +from functools import wraps +from bottle import Bottle, request, response +from config import JWT_SECRET +from config import hash_context + + +import jwt +from bottle import request, response + + +import time +from json import dumps, loads + +from utils import read_keys_from_request + +app = Bottle() + + + +def user_guard(reyection_msg: str = "Requires authentication", allow_anonymous: bool = False): + def user_guard_decorator(fn: callable): + @wraps(fn) + def wrapper(*args, **kwargs): + username = username_by_token(request) + if not username and not allow_anonymous: + response.status = 401 + return dumps({"error": reyection_msg}) + if username: + user = request.db_connector.get_user(username) + return fn(user, *args, **kwargs) + return wrapper + return user_guard_decorator + +def admin_guard(reyection_msg: str = "Requires admin priveledges"): + def admin_guard_decorator(fn: callable): + @wraps(fn) + @user_guard(reyection_msg) + def wrapper(user, *args, **kwargs): + if user.role != "admin": + response.status = 401 + return dumps({"error": reyection_msg}) + return fn(user, *args, **kwargs) + return wrapper + return admin_guard_decorator + + +@app.route("/token", method=["POST"]) +def token(): + body = request.body.read() + try: + data = loads(body.decode('utf-8')) + username = data.get("user") + password = data.get("password") + except: + response.status = 400 + return dumps({"error": "Invalid JSON format"}) + + user = request.db_connector.get_user(username) + if not user or not hash_context.verify(password, user.hash): + response.status = 401 + return dumps({"error": "Invalid username or password"}) + + jwt_content = { + "sub": { + "user": user.name, + "role": user.role + }, + "iat": time.time(), + "exp": time.time() + 60 * 20 # 20 minutes expiration + } + token = jwt.encode(jwt_content, JWT_SECRET, algorithm="HS256") + response.set_cookie("oauth2", token, max_age=60*20, path='/', samesite='lax') + response.status = 200 + return dumps(jwt_content) + + +def username_by_token(request) -> str | None: + token = request.get_cookie("oauth2") + if not token: + return None + + try: + decoded = jwt.decode(token, JWT_SECRET, algorithms=["HS256"], options={"verify_sub": False}) + curent_time = time.time() + if decoded.get("exp", float("inf")) + decoded.get("iat", float("inf")) < curent_time: + return None + return decoded["sub"]["user"] + except (jwt.ExpiredSignatureError, jwt.InvalidTokenError) as e: + print(f"Token error: {e}") + return None + + +@app.route("/", method=["GET"]) +def get_user(): + username = username_by_token(request) + if not username: + response.status = 401 + response.content_type = 'application/json' + return dumps({"error": "Unauthorized"}) + + return dumps({"name": username}) + +@app.route("/add", method=["POST"]) +@admin_guard() +def add_user(user): + data = read_keys_from_request(["new_user", "new_password", "new_admin"]) + role = "admin" if data.get("new_admin", False) else "user" + response.content_type = 'application/json' + try: + request.db_connector.add_user(data["new_user"], hash_context.hash(data["new_password"]), role) + response.status = 200 + return dumps({"message": "User created successfully"}) + except ValueError as e: + response.status = 400 + return dumps({"error": str(e)}) + + diff --git a/backend_py/config.py b/backend_py/config.py new file mode 100644 index 0000000..b0a8547 --- /dev/null +++ b/backend_py/config.py @@ -0,0 +1,5 @@ +from passlib.context import CryptContext + + +JWT_SECRET = "F&M2eb%*T2dnhZqxw^ts6qotqF&M2eb%*T2dnhZqxw^ts6qotq" +hash_context = CryptContext(schemes=["bcrypt"]) \ No newline at end of file diff --git a/backend_py/db_handler.py b/backend_py/db_handler.py index def35b5..1b5f502 100644 --- a/backend_py/db_handler.py +++ b/backend_py/db_handler.py @@ -2,7 +2,7 @@ from sqlalchemy import create_engine from sqlalchemy.orm import declarative_base, sessionmaker from sqlalchemy import Column, Integer, String from dataclasses import dataclass - +import time Base = declarative_base() @@ -14,6 +14,16 @@ class User(Base): hash = Column(String) role = Column(String) +class Message(Base): + __tablename__ = 'messages' + + id = Column(Integer, primary_key=True, autoincrement=True) + room = Column(String) + content = Column(String) + user = Column(String) + timestamp = Column(Integer) + + class DbConnector: def __init__(self, db_url: str): self.engine = create_engine(db_url) @@ -22,8 +32,10 @@ class DbConnector: self._create_defaults() def _create_defaults(self): - self.add_user(name="admin", hash="$2b$12$IcUr5w7pIFaXaGVFP5yVV.b.sIYjDbETR3l2PKgWO4nkrHU.1HmFa", role="admin") - + try: + self.add_user(name="admin", hash="$2b$12$IcUr5w7pIFaXaGVFP5yVV.b.sIYjDbETR3l2PKgWO4nkrHU.1HmFa", role="admin") + except ValueError: + print("Default admin user already exists") def get_user(self, name: str) -> User | None: return self.session.query(User).filter(User.name==name).first() @@ -31,6 +43,23 @@ class DbConnector: def add_user(self, name: str, hash: str, role: str = "user"): if self.get_user(name): raise ValueError("User already exists") - new_user = User(name, hash, role) + new_user = User() + new_user.name = name + new_user.hash = hash + new_user.role = role self.session.add(new_user) 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) + self.session.commit() + self.session.refresh(new_msg) # Refresh to get the auto-incremented ID + return new_msg + + + def get_messages_from_room(self, room: str, since: int| None = None) -> list[Message]: + query = self.session.query(Message).filter(Message.room == room) + if since is not None: + query = query.filter(Message.timestamp >= since) + return query.all() diff --git a/backend_py/main.py b/backend_py/main.py index 0b6f5ba..68d131b 100644 --- a/backend_py/main.py +++ b/backend_py/main.py @@ -1,121 +1,18 @@ -from bottle import response, request, Bottle -from json import dumps, loads -import time +from bottle import request, Bottle from db_handler import DbConnector -from functools import wraps -import jwt -from passlib.context import CryptContext +import auth +import messages + import bcrypt # Needet because of: https://github.com/pyca/bcrypt/issues/684 if not hasattr(bcrypt, '__about__'): bcrypt.__about__ = type('about', (object,), {'__version__': bcrypt.__version__}) -def user_guard(reyection_msg: str = "Requires authentication", allow_anonymous: bool = False): - def user_guard_decorator(fn: callable): - @wraps(fn) - async def wrapper(*args, **kwargs): - username = username_by_token(request) - if not username and not allow_anonymous: - response.status = 401 - return dumps({"error": reyection_msg}) - if username: - user = request.db_connector.get_user(username) - return await fn(user, *args, **kwargs) - return wrapper - return user_guard_decorator - -def admin_guard(reyection_msg: str = "Requires admin priveledges"): - def admin_guard_decorator(fn: callable): - @wraps(fn) - @user_guard(reyection_msg) - async def wrapper(user, *args, **kwargs): - if user.role != "admin": - response.status = 401 - return dumps({"error": reyection_msg}) - return await fn(user, *args, **kwargs) - return wrapper - return admin_guard_decorator - -def read_keys_from_request(keys: None|dict = None): - result = {} - try: - body = request.body.read() - data = loads(body.decode('utf-8')) - except: - return result - if keys: - missing_keys = [key for key in keys if key not in data] - if missing_keys: - raise ValueError(f"Missing required keys: {', '.join(missing_keys)}") - data = {key: data[key] for key in keys} - return data - - -hash_context = CryptContext(schemes=["bcrypt"]) - app = Bottle() -@app.route("/auth/token", method=["POST"]) -def token(): - body = request.body.read() - try: - data = loads(body.decode('utf-8')) - username = data.get("user") - password = data.get("password") - except: - response.status = 400 - return dumps({"error": "Invalid JSON format"}) - - user = request.db_connector.get_user(username) - if not user or not hash_context.verify(password, user.hash): - response.status = 401 - return dumps({"error": "Invalid username or password"}) - - jwt_content = { - "sub": { - "user": user.name, - }, - "iat": time.time(), - "exp": 60 * 20 - } - token = jwt.encode(jwt_content, "secret", algorithm="HS256") - response.set_cookie("oauth2", token, max_age=60*20, path='/') - response.status = 200 - return dumps(jwt_content) - -def username_by_token(request) -> str | None: - token = request.get_cookie("oauth2") - if not token: - return None - - try: - decoded = jwt.decode(token, "secret", algorithms=["HS256"]) - curent_time = time.time() - if decoded.get("exp", float("inf")) + decoded.get("iat", float("inf")) < curent_time: - return None - return decoded["sub"]["user"] - except (jwt.ExpiredSignatureError, jwt.InvalidTokenError): - return None - -@app.route("/user", method=["GET"]) -def get_user(): - username = username_by_token(request) - if not username: - response.status = 401 - return dumps({"error": "Unauthorized"}) - - return dumps({"name": username}) - -@app.route("/user_add", method=["POST"]) -@admin_guard() -def add_user(user): - data = read_keys_from_request(["new_user", "new_password"]) - request.db_connector.add_user(data["new_user"], hash_context.genhash) - - def initialize_app(): db = DbConnector("sqlite:///./data/db.sqlite") @@ -128,4 +25,10 @@ def initialize_app(): if __name__ == "__main__": initialize_app() - app.run(host='localhost', port=8080, debug=True) \ No newline at end of file + + app.mount('/user', auth.app) + app.mount('/messages', messages.app) + + root_app = Bottle() + root_app.mount('/api', app) + root_app.run(host='localhost', port=8080, debug=True) \ No newline at end of file diff --git a/backend_py/messages.py b/backend_py/messages.py new file mode 100644 index 0000000..9fa1e1f --- /dev/null +++ b/backend_py/messages.py @@ -0,0 +1,37 @@ + +from bottle import Bottle, request, response +from json import dumps, loads + +from db_handler import User, Message +from auth import user_guard +from utils import read_keys_from_request +app = Bottle() + +def serialize_message(messages: list[Message]) -> str: + return dumps({getattr(msg, "id"): + {key: getattr(msg, key) + for key in msg.__dict__.keys() if key[0] != '_' and key != 'id'} + for msg in messages}) + +@app.route('/', method=['POST']) +@user_guard() +def recive_msg(user: User, room: str): + msg = read_keys_from_request(keys=["content"]) + if not msg: + response.status = 400 + return {"error": "Missing 'content' in request body"} + new_msg = request.db_connector.add_msg_to_room(room, msg["content"], user.name) + return serialize_message([new_msg]) + +@app.route('/', method=['GET']) +@user_guard() +def return_msgs(user: User, room: str): + since = request.query.get('since', None) + if since: + try: + since = int(since) + except ValueError: + response.status = 400 + return {"error": "Invalid 'since' parameter"} + messages = request.db_connector.get_messages_from_room(room, since) + return serialize_message(messages) \ No newline at end of file diff --git a/backend_py/utils.py b/backend_py/utils.py new file mode 100644 index 0000000..7c3043f --- /dev/null +++ b/backend_py/utils.py @@ -0,0 +1,17 @@ +from json import loads +from bottle import request + + +def read_keys_from_request(keys: None|dict = None): + result = {} + try: + body = request.body.read() + data = loads(body.decode('utf-8')) + except: + return result + if keys: + missing_keys = [key for key in keys if key not in data] + if missing_keys: + raise ValueError(f"Missing required keys: {', '.join(missing_keys)}") + data = {key: data[key] for key in keys} + return data \ No newline at end of file diff --git a/data/db.sqlite b/data/db.sqlite index 945c2d1..214de2c 100644 Binary files a/data/db.sqlite and b/data/db.sqlite differ diff --git a/frontend/src/composable/auth.ts b/frontend/src/composable/auth.ts index 2a435f3..9b0a69c 100644 --- a/frontend/src/composable/auth.ts +++ b/frontend/src/composable/auth.ts @@ -1,37 +1,11 @@ import { API_URL } from '@/main' +import { getJsonOrError } from '@/composable/utils' export interface User { user: string + role: string } -function getJsonOrError(response: Response) { - if (!response.ok) { - return response.json().then((data) => { - throw new Error(data.error || 'Request failed') - }) - } - return response.json() -} - -/* -const getUser = async (token: string | undefined) => { - if (!token) { - throw new Error('No token provided') - } - return fetch(`${API_URL}/user`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }).then(async (response) => { - let data = await getJsonOrError(response) - return { - name: data.name, - } as User - }) -} -*/ - const readToken = () => { const token = document.cookie.split('; ').find((row) => row.startsWith('oauth2=')) if (token) { @@ -57,7 +31,7 @@ export const getSessionFromJWT = (): User => { } export const requestToken = async (user: string, password: string): Promise => { - return fetch(`${API_URL}/auth/token`, { + return fetch(`${API_URL}/user/token`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -70,7 +44,8 @@ export const requestToken = async (user: string, password: string): Promise { let data = await getJsonOrError(response) return { - name: data.sub.user, + user: data.sub.user as string, + role: data.sub.role as string, } as User }) } diff --git a/frontend/src/composable/message.ts b/frontend/src/composable/message.ts new file mode 100644 index 0000000..942b9f3 --- /dev/null +++ b/frontend/src/composable/message.ts @@ -0,0 +1,57 @@ +import { getJsonOrError } from '@/composable/utils' +import { API_URL } from '@/main' +import type { Ref } from 'vue' +import { ref } from 'vue' + +interface Message { + id: number + user: string + content: string + timestamp: string +} + +export const messageHandler = (room: string = 'general') => { + let messages: Ref> = ref({}) + + function requestMessages(since: string | undefined): Promise> { + let query = since ? `?since=${since}` : '' + + return fetch(`${API_URL}/messages/${room}${query}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }).then(async (response) => { + let data = await getJsonOrError(response) + messages.value = { ...messages.value, ...(data as Record) } + return messages.value + }) + } + + function sendMessage(message: string): Promise> { + return fetch(`${API_URL}/messages/${room}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ content: message }), + }).then(async (response) => { + let data = await getJsonOrError(response) + messages.value = { ...messages.value, ...(data as Record) } + return messages.value + }) + } + + return { + requestMessages, + sendMessage, + messages, + lastMsg: () => { + const highestKey = Math.max(...Object.keys(messages).map(Number)) + if (highestKey === -Infinity) { + return undefined + } + return messages.value[highestKey] + }, + } +} diff --git a/frontend/src/composable/utils.ts b/frontend/src/composable/utils.ts new file mode 100644 index 0000000..bd2dca1 --- /dev/null +++ b/frontend/src/composable/utils.ts @@ -0,0 +1,8 @@ +export function getJsonOrError(response: Response) { + if (!response.ok) { + return response.json().then((data) => { + throw new Error(data.error || 'Request failed') + }) + } + return response.json() +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 1d21f46..3e144cc 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -6,7 +6,7 @@ import router from './router' const app = createApp(App) -export const API_URL = 'http://localhost:8080' +export const API_URL = document.location.origin + '/api' app.use(router) app.mount('#app') diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index a094090..4126e12 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -10,11 +10,16 @@ const router = createRouter({ component: HomeView, }, { - path: '/chat', + path: '/', name: 'chat', component: () => import('../views/Chat.vue'), meta: { requiresAuth: true }, }, + { + path: '/user', + name: 'user', + component: () => import('../views/User.vue'), + }, { path: '/*', name: 'any', diff --git a/frontend/src/views/Chat.vue b/frontend/src/views/Chat.vue index d08fff2..6c92695 100644 --- a/frontend/src/views/Chat.vue +++ b/frontend/src/views/Chat.vue @@ -1,15 +1,54 @@ - diff --git a/frontend/src/views/User.vue b/frontend/src/views/User.vue new file mode 100644 index 0000000..ac10c77 --- /dev/null +++ b/frontend/src/views/User.vue @@ -0,0 +1,64 @@ + + + diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index d6b83b7..23bfba2 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -14,4 +14,11 @@ export default defineConfig({ '@': fileURLToPath(new URL('./src', import.meta.url)), }, }, + server: { + port: 8081, + host: '0.0.0.0', + proxy: { + '/api': 'http://localhost:8080', + }, + }, }) diff --git a/shell.nix b/shell.nix index 2fbd88f..7413c22 100644 --- a/shell.nix +++ b/shell.nix @@ -10,8 +10,12 @@ pkgs.mkShell { sqlite ]; + shellHook = '' + source .venv/bin/activate + ''; exitHook = '' echo "closing env.." + ''; }