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
+138
View File
@@ -0,0 +1,138 @@
from dataclasses import asdict
from uuid import uuid4
import json
import rsa
from sanic import Sanic, Request, response, Websocket
from sanic.response import HTTPResponse, json as json_response
from .models import Message, UserSession
from .logger import logger
from .helpers import (
require_auth,
extract_pubkey,
get_client_ip,
get_param,
verify_password,
send_state,
utcnow,
)
async def get_key(request: Request, app: Sanic) -> HTTPResponse:
if err := require_auth(request, app):
return err
pubkey_bytes = extract_pubkey(request)
if not pubkey_bytes:
return response.text("Bad request: pubkey is required", status=400)
try:
public_key = rsa.PublicKey.load_pkcs1(pubkey_bytes)
if public_key.n.bit_length() < 2048:
raise ValueError("RSA key must be at least 2048 bits")
except Exception as e:
logger.warning(f"Invalid public key: {e}")
return response.text(f"Bad pubkey: {e}", status=400)
username = get_param(request, "username") or "unknown"
if await app.ctx.session_store.username_exists(username):
return response.text(f"Username '{username}' is already taken", status=409)
session = UserSession(
user_id=str(uuid4()),
ip=get_client_ip(request),
username=get_param(request, "username") or "unknown",
fernet_key=app.ctx.fernet_key,
)
await app.ctx.session_store.add(session)
try:
encrypted_key = rsa.encrypt(app.ctx.fernet_key, public_key)
logger.info(f"Key exchange: user={session.username}, session={session.user_id}")
return response.raw(
encrypted_key,
content_type="application/octet-stream",
headers={"X-User-Id": session.user_id},
)
except Exception as e:
logger.error(f"Encryption failed: {e}")
return response.text("Key encryption failed", status=500)
async def chat_ws(request: Request, ws: Websocket, app: Sanic) -> None:
user_id = request.args.get("user_id")
if not user_id:
await ws.close(code=4002, reason="user_id required")
return
if not verify_password(request.args.get("password"), app.ctx.admin_password):
await ws.close(code=4001, reason="Unauthorized")
return
session = await app.ctx.session_store.get(user_id)
if not session:
await ws.close(code=4002, reason="Invalid session")
return
manager = app.ctx.connection_manager
await manager.connect(user_id, ws)
try:
await send_state(ws, app)
async for data in ws:
if data is None:
break
await app.ctx.session_store.update_activity(user_id)
message = Message(
text=str(data),
user_ip=session.ip,
username=session.username,
)
await app.ctx.message_store.add(message)
await manager.broadcast(
json.dumps(
{
"type": "message",
"data": asdict(message),
}
)
)
except Exception as e:
logger.error(f"WebSocket error for {user_id}: {e}")
finally:
await manager.disconnect(user_id)
await manager.broadcast(
json.dumps(
{
"type": "user_left",
"user_id": user_id,
}
)
)
async def health(request: Request, app: Sanic) -> HTTPResponse:
return json_response(
{
"status": "ok",
"messages": await app.ctx.message_store.count(),
"users": await app.ctx.session_store.count(),
"timestamp": utcnow().isoformat(),
}
)
async def clear_messages(request: Request, app: Sanic) -> HTTPResponse:
if err := require_auth(request, app):
return err
await app.ctx.message_store.clear()
return json_response({"status": "cleared"})