added rudermentery comunication

This commit is contained in:
Kyattsukuro 2025-08-03 21:24:12 +02:00
parent 10ee2249ae
commit bff37eab52
23 changed files with 424 additions and 149 deletions

10
.vscode/launch.json vendored
View File

@ -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",

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

117
backend_py/auth.py Normal file
View File

@ -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)})

5
backend_py/config.py Normal file
View File

@ -0,0 +1,5 @@
from passlib.context import CryptContext
JWT_SECRET = "F&M2eb%*T2dnhZqxw^ts6qotqF&M2eb%*T2dnhZqxw^ts6qotq"
hash_context = CryptContext(schemes=["bcrypt"])

View File

@ -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()

View File

@ -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)
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)

37
backend_py/messages.py Normal file
View File

@ -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('/<room>', 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('/<room>', 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)

17
backend_py/utils.py Normal file
View File

@ -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

Binary file not shown.

View File

@ -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<User> => {
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<User
}).then(async (response) => {
let data = await getJsonOrError(response)
return {
name: data.sub.user,
user: data.sub.user as string,
role: data.sub.role as string,
} as User
})
}

View File

@ -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<Record<number, Message>> = ref({})
function requestMessages(since: string | undefined): Promise<Record<number, Message>> {
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<number, Message>) }
return messages.value
})
}
function sendMessage(message: string): Promise<Record<number, Message>> {
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<number, Message>) }
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]
},
}
}

View File

@ -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()
}

View File

@ -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')

View File

@ -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',

View File

@ -1,15 +1,54 @@
<script lang="ts">
<script setup lang="ts">
import { getSessionFromJWT, type User } from '@/composable/auth'
import { messageHandler } from '@/composable/message'
import router from '@/router'
import { ref, onMounted, type Ref } from 'vue'
import { ref, onMounted, onBeforeUnmount, type Ref } from 'vue'
const user: Ref<User> = ref(getSessionFromJWT())
console.log('User:', user.value)
const msg = messageHandler()
const newMessage = ref('')
const msgTimer = ref()
// Instantiate polling for messages
// ToDo: Try using a WebSocket instead
onMounted(() => {
msgTimer.value = setInterval(() => {
console.log('Polling for messages...')
msg.requestMessages(msg.lastMsg()?.timestamp)
}, 3000) // 3s
})
// Clean up polling
onBeforeUnmount(() => {
clearInterval(msgTimer.value)
msgTimer.value = null
})
</script>
<template>
<main>
<h1>Chat</h1>
<h3>Hello {{ user.name }}</h3>
<div class="max-w-128">
<div v-if="msg" v-for="message in msg.messages.value" :key="message.id" class="mb-4">
<p>
<a>{{ message.user }}</a
><a>{{ message.timestamp }}</a
>: {{ message.content }}
</p>
</div>
<input
v-model="newMessage"
type="text"
placeholder="Type your message here..."
class="w-full p-2 border rounded"
/>
<button
@click="() => msg.sendMessage(newMessage)"
class="mt-2 bg-blue-500 text-white p-2 rounded hover:bg-blue-600"
>
Send
</button>
</div>
</main>
</template>

View File

@ -0,0 +1,64 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
import { API_URL } from '@/main.ts'
import { type User, getSessionFromJWT } from '@/composable/auth.ts'
const user = ref<User | undefined>()
const new_user_name = ref('')
const new_user_passwd = ref('')
const new_admin = ref(false)
const msg = ref('')
onMounted(() => {
user.value = getSessionFromJWT()
})
const onNewUserCreation = async () => {
try {
const response = await fetch(`${API_URL}/user/add`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
credentials: 'include',
},
body: JSON.stringify({
new_user: new_user_name.value,
new_password: new_user_passwd.value,
new_admin: new_admin.value,
}),
})
const data = await response.json()
if (response.ok) {
msg.value = data.message
} else {
throw new Error(data.error || 'Failed to create user')
}
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
msg.value = `Error creating user: ${errorMessage}`
console.error('Error creating user:', error)
}
}
</script>
<template>
<div v-if="user" class="user-profile">
<h1>User Profile</h1>
<p><strong>Name:</strong> {{ user.user }}</p>
<p><strong>Role:</strong> {{ user.role }}</p>
<div v-if="user.role === 'admin'">
<h2>New user</h2>
<input v-model="new_user_name" placeholder="Username" />
<input v-model="new_user_passwd" type="password" placeholder="Password" />
<input v-model="new_admin" type="checkbox" />
<button @click="() => onNewUserCreation()">Create User</button>
</div>
</div>
<div v-else>
<p>No user information...</p>
</div>
<p>{{ msg }}</p>
</template>

View File

@ -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',
},
},
})

View File

@ -10,8 +10,12 @@ pkgs.mkShell {
sqlite
];
shellHook = ''
source .venv/bin/activate
'';
exitHook = ''
echo "closing env.."
'';
}