feat: complete client-server architecture refactoring

Server:
- Split into views, routes, helpers, models modules
- Merged /ws/talk and /ws/update into single /ws/chat endpoint
- Replaced polling with push-based broadcast model
- Added username uniqueness validation on connect
- Fixed run_server arguments bug (workers parameter)
- Removed deprecated loop argument from Sanic listeners
- Replaced datetime.utcnow() with timezone-aware datetime.now(timezone.utc)

Client:
- Rewrote client as single-file module
- Migrated from websocket-client to websockets (asyncio)
- Fixed websocket-client conflict with asyncio event loop on Windows
- Added progress indicators for key generation, exchange, connection
- Added animated 3D spinning cube in UI
- Updated RSA key from 512 to 2048 bits

CLI:
- Removed unnecessary asyncio.run() wrapper
- Simplified entry point
This commit is contained in:
mirai
2026-01-02 14:42:33 +03:00
parent faaadd839b
commit 95f8a192b5
22 changed files with 682 additions and 441 deletions
+169 -145
View File
@@ -1,161 +1,185 @@
import ast
import time
import threading
import asyncio
import json
from typing import Optional
from websocket import create_connection, WebSocketConnectionClosedException
from cmd_chat.client.core.crypto import RSAService
from cmd_chat.client.core.default_renderer import DefaultClientRenderer
from cmd_chat.client.core.rich_renderer import RichClientRenderer
from cmd_chat.client.config import RENDER_TIME
import rsa
import requests
from cryptography.fernet import Fernet
import websockets
from rich.console import Console
from rich.panel import Panel
class Client(RSAService, RichClientRenderer):
class Client:
def __init__(
self,
server: str,
port: int,
username: str,
password: str | None = None
self, server: str, port: int, username: str, password: Optional[str] = None
):
super().__init__()
self.server = server
self.port = port
self.username = username
self.password = password or ""
self.base_url = f"http://{self.server}:{self.port}"
self.ws_url = f"ws://{self.server}:{self.port}"
self.close_response = str({
"action": "close",
"username": self.username
})
self.__stop_threads = False
self.user_id: Optional[str] = None
def _ws_full(self, path: str) -> str:
if self.password:
return f"{self.ws_url}{path}?password={self.password}"
return f"{self.ws_url}{path}"
self.public_key: Optional[rsa.PublicKey] = None
self.private_key: Optional[rsa.PrivateKey] = None
self.fernet: Optional[Fernet] = None
def _connect_ws(self, path: str, retries: int = 5, backoff: float = 0.5):
last_exc: Exception = ConnectionError("Failed to connect")
for attempt in range(retries):
try:
return create_connection(self._ws_full(path))
except Exception as exc:
last_exc = exc
time.sleep(backoff * (2 ** attempt))
print(f"Can't connect to {path}: {last_exc}")
raise last_exc
self.console = Console()
self.messages: list[dict] = []
self.users: list[dict] = []
self.connected = False
self.running = False
def send_info(self):
ws = self._connect_ws("/talk")
try:
while not self.__stop_threads:
try:
user_input = input("You're message: ")
if user_input == "q":
self.__stop_threads = True
try:
if ws:
ws.send(self.close_response)
ws.close()
except Exception:
pass
break
message = f'{self.username}: {user_input}'
socket_message = str({
"text": self._encrypt(message),
"username": self.username
})
ws.send(socket_message)
except (WebSocketConnectionClosedException, ConnectionResetError, ConnectionAbortedError, OSError):
try:
if ws:
try:
ws.close()
except Exception:
pass
ws = self._connect_ws("/talk")
continue
except Exception:
print("Can't establish channel")
self.__stop_threads = True
break
except KeyboardInterrupt:
self.__stop_threads = True
try:
ws.send(self.close_response)
ws.close()
except Exception:
pass
break
finally:
try:
ws.close()
except Exception:
pass
@property
def base_url(self) -> str:
return f"http://{self.server}:{self.port}"
def update_info(self):
ws = self._connect_ws("/update")
last_try = None
try:
while not self.__stop_threads:
try:
time.sleep(RENDER_TIME)
raw = ws.recv()
if isinstance(raw, bytes):
raw = raw.decode("utf-8")
response = ast.literal_eval(raw)
if last_try == response:
continue
last_try = response
self.clear_console()
if len(last_try["messages"]) > 0:
self.print_chat(response=last_try)
except (WebSocketConnectionClosedException, ConnectionResetError, ConnectionAbortedError, OSError):
try:
if ws:
try:
ws.close()
except Exception:
pass
ws = self._connect_ws("/update")
continue
except Exception:
print("Connection lost: can't establish update channel")
self.__stop_threads = True
break
except KeyboardInterrupt:
self.__stop_threads = True
try:
ws.send(self.close_response)
ws.close()
except Exception:
pass
break
finally:
try:
ws.close()
except Exception:
pass
@property
def ws_url(self) -> str:
return f"ws://{self.server}:{self.port}"
def _validate_keys(self) -> None:
self._request_key(
url=f"{self.base_url}/get_key",
username=self.username,
password=self.password
def success(self, message: str) -> None:
self.console.print(f"[green]✓ {message}[/]")
def error(self, message: str) -> None:
self.console.print(f"[red]✗ {message}[/]")
def info(self, message: str) -> None:
self.console.print(f"[cyan]• {message}[/]")
def generate_keys(self) -> None:
with self.console.status(
"[cyan]Generating RSA keys (2048 bit)...[/]", spinner="dots"
):
self.public_key, self.private_key = rsa.newkeys(2048)
self.success("RSA keys generated")
def exchange_keys(self) -> None:
with self.console.status(
"[cyan]Exchanging keys with server...[/]", spinner="dots"
):
pubkey_bytes = self.public_key.save_pkcs1()
response = requests.post(
f"{self.base_url}/get_key",
files={"pubkey": ("key.pem", pubkey_bytes)},
data={"username": self.username, "password": self.password},
timeout=30,
)
response.raise_for_status()
self.user_id = response.headers.get("X-User-Id")
encrypted_key = response.content
symmetric_key = rsa.decrypt(encrypted_key, self.private_key)
self.fernet = Fernet(symmetric_key)
self.success(f"Key exchange complete (session: {self.user_id[:8]}...)")
self.public_key = None
self.private_key = None
def render_messages(self) -> None:
self.console.clear()
users_online = ", ".join(u.get("username", "?") for u in self.users) or "none"
self.console.print(f"[dim]Online: {users_online}[/]")
self.console.print("" * 60)
display_messages = (
self.messages[-15:] if len(self.messages) > 15 else self.messages
)
self._remove_keys()
def run(self):
self._validate_keys()
threads = [
threading.Thread(target=self.send_info, daemon=True),
threading.Thread(target=self.update_info, daemon=True)
]
for th in threads:
th.start()
for th in threads:
th.join()
for msg in display_messages:
username = msg.get("username", "unknown")
text = msg.get("text", "")
timestamp = str(msg.get("timestamp", ""))[:19].replace("T", " ")
style = "green" if username == self.username else "cyan"
self.console.print(f"[dim]{timestamp}[/] [{style}]{username}[/]: {text}")
if not display_messages:
self.console.print("[dim italic]No messages yet...[/]")
self.console.print("" * 60)
self.console.print("[dim]Type message and press Enter. 'q' to quit.[/]")
async def receive_loop(self, ws) -> None:
try:
async for raw in ws:
if not self.running:
break
data = json.loads(raw)
msg_type = data.get("type", "")
if msg_type == "init":
self.messages = data.get("messages", [])
self.users = data.get("users", [])
self.connected = True
self.render_messages()
elif msg_type == "message":
msg_data = data.get("data", {})
self.messages.append(msg_data)
self.render_messages()
elif msg_type == "user_left":
left_id = data.get("user_id")
self.users = [u for u in self.users if u.get("user_id") != left_id]
self.render_messages()
except websockets.ConnectionClosed:
self.connected = False
async def input_loop(self, ws) -> None:
loop = asyncio.get_event_loop()
while self.running:
try:
text = await loop.run_in_executor(None, input)
if text.lower() in ("q", "quit", "exit"):
self.running = False
break
if text.strip():
await ws.send(text)
except (EOFError, KeyboardInterrupt):
self.running = False
break
async def run_async(self) -> None:
self.console.clear()
self.console.print(Panel("[bold cyan]CMD Chat Client[/]", expand=False))
self.console.print()
try:
self.generate_keys()
self.exchange_keys()
self.info("Connecting to chat...")
url = (
f"{self.ws_url}/ws/chat?user_id={self.user_id}&password={self.password}"
)
async with websockets.connect(url) as ws:
self.success("Connected to chat server")
self.running = True
receive_task = asyncio.create_task(self.receive_loop(ws))
input_task = asyncio.create_task(self.input_loop(ws))
done, pending = await asyncio.wait(
[receive_task, input_task], return_when=asyncio.FIRST_COMPLETED
)
for task in pending:
task.cancel()
self.console.print("\n[yellow]Disconnected[/]")
except requests.exceptions.ConnectionError:
self.error(f"Cannot connect to {self.base_url}")
except requests.exceptions.HTTPError as e:
self.error(f"Server error: {e.response.status_code} - {e.response.text}")
except Exception as e:
import traceback
self.error(f"Error: {e}")
traceback.print_exc()
def run(self) -> None:
asyncio.run(self.run_async())
-10
View File
@@ -1,11 +1 @@
from colorama import Fore
COLORS = {
"text_color": Fore.WHITE,
"my_username_color": Fore.MAGENTA,
"ip_color": Fore.MAGENTA,
"username_color": Fore.GREEN
}
RENDER_TIME = 0.05
MESSAGES_TO_SHOW = 5
+1
View File
@@ -1,5 +1,6 @@
from abc import ABC, abstractmethod
class CryptoService(ABC):
@abstractmethod
+2 -3
View File
@@ -2,10 +2,9 @@ from abc import ABC, abstractmethod
class ClientRenderer(ABC):
# These attributes are expected to be provided by subclasses
# (typically via multiple inheritance with CryptoService)
username: str
@abstractmethod
def _decrypt(self, message: str) -> str:
"""Decrypt an encrypted message (provided by crypto mixin)."""
+2 -3
View File
@@ -28,17 +28,16 @@ class RSAService(CryptoService):
stream=True,
)
r.raise_for_status()
# read the full response content (server returns encrypted symmetric key)
message = r.content
self.symmetric_key = rsa.decrypt(message, self.private_key)
self.fernet = Fernet(self.symmetric_key)
def _generate_keys(self):
self.public_key, self.private_key = rsa.newkeys(512)
self.public_key, self.private_key = rsa.newkeys(2048)
def _get_generated_keys(self):
return self.private_key, self.public_key
def _remove_keys(self):
self.public_key = None
self.private_key = None
self.private_key = None
-61
View File
@@ -1,61 +0,0 @@
import os
import platform
from cmd_chat.client.core.abs.abs_renderer import ClientRenderer
from cmd_chat.client.config import COLORS
from colorama import init
init()
class DefaultClientRenderer(ClientRenderer):
def __get_os(self) -> str:
""" checking what kind of platform you need
"""
if "Linux" in str(platform.platform()):
return "Linux"
return "Windows"
def print_message(self, message: str) -> str:
""" generating string with message in required format
"""
# split only on the first ':' to keep message contents intact
parts = message.split(":", 1)
if parts[0] == self.username:
return COLORS["my_username_color"] + parts[0] + ": " + parts[1] + COLORS["text_color"]
return parts[0] + ": " + parts[1] + COLORS["text_color"]
def clear_console(self):
# For windows clear command its cls
# For linux clear command its clear
if self.__get_os() == "Linux":
os.system("clear")
else:
os.system("cls")
def print_ip(
self,
ip: str
) -> str:
return f"IP: " + COLORS["ip_color"] + ip + COLORS["text_color"]
def print_username(
self,
username: str
) -> str:
# Username label + colored username
return f"USERNAME: " + COLORS["username_color"] + username + COLORS["text_color"]
def print_chat(self, response) -> None:
for i, msg in enumerate(response["messages"]):
actual_message = self._decrypt(msg)
if i == 0:
for user in response["users_in_chat"]:
print(self.print_ip(user.split(",")[0]))
print(self.print_username(user.split(",")[1]))
print("Write 'q' to quit from chat")
print(f"\n{self.print_message(actual_message)}")
else:
print(f"{self.print_message(actual_message)}")
+22 -28
View File
@@ -1,9 +1,9 @@
import os
import os
import platform
from rich.text import Text
from rich.text import Text
from rich.style import Style
from rich.console import Console
from rich.console import Console
from rich.table import Table
from cmd_chat.client.core.abs.abs_renderer import ClientRenderer
@@ -16,26 +16,26 @@ console = Console(width=75)
class RichClientRenderer(ClientRenderer):
def __get_os(self) -> str:
""" checking what kind of platform you need
"""
"""checking what kind of platform you need"""
if "Linux" in str(platform.platform()):
return "Linux"
return "Windows"
def print_message(self, message: str) -> Text:
""" generating string with message in required format
"""
"""generating string with message in required format"""
# split only on the first ':' so message bodies containing ':' are preserved
parts = message.split(":", 1)
if parts[0] == self.username:
return \
Text(text=parts[0], style="bold") + \
Text(text=": ", style="bold") + \
Text(text=parts[1], style="underline")
return \
Text(text=parts[0], style="bold") + \
Text(text=": ", style="bold") + \
Text(text=parts[1], style="underline")
return (
Text(text=parts[0], style="bold")
+ Text(text=": ", style="bold")
+ Text(text=parts[1], style="underline")
)
return (
Text(text=parts[0], style="bold")
+ Text(text=": ", style="bold")
+ Text(text=parts[1], style="underline")
)
def clear_console(self):
# For windows clear command its cls
@@ -45,16 +45,10 @@ class RichClientRenderer(ClientRenderer):
else:
os.system("cls")
def print_ip(
self,
ip: str
) -> str:
def print_ip(self, ip: str) -> str:
return ip
def print_username(
self,
username: str
) -> str:
def print_username(self, username: str) -> str:
return username
def print_chat(self, response) -> None:
@@ -68,11 +62,11 @@ class RichClientRenderer(ClientRenderer):
table.add_column("USERNAME")
for user in response["users_in_chat"]:
table.add_row(
self.print_ip(user.split(',')[0]),
self.print_username(user.split(",")[1])
self.print_ip(user.split(",")[0]),
self.print_username(user.split(",")[1]),
)
console.print(table)
console.print("Write 'q' to quit from chat", justify="left")
console.print(f"\n{self.print_message(actual_message)}")
else:
console.print(f"{self.print_message(actual_message)}")
console.print(f"{self.print_message(actual_message)}")