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:
@@ -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"})
|
||||
Reference in New Issue
Block a user