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
139 lines
4.0 KiB
Python
139 lines
4.0 KiB
Python
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"})
|