Spaces:
Running
Running
Update app.py
Browse files
app.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
import os
|
| 2 |
import json
|
| 3 |
import time
|
|
@@ -8,11 +9,12 @@ import asyncio
|
|
| 8 |
import aiosqlite
|
| 9 |
import tempfile
|
| 10 |
import io
|
|
|
|
|
|
|
| 11 |
|
| 12 |
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, Header, Query, Request, UploadFile, File
|
| 13 |
from fastapi.staticfiles import StaticFiles
|
| 14 |
from fastapi.responses import Response, JSONResponse, FileResponse
|
| 15 |
-
import logging
|
| 16 |
|
| 17 |
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
| 18 |
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
@@ -34,85 +36,117 @@ assert len(MESSAGE_KEY) == 32
|
|
| 34 |
logging.basicConfig(level=logging.INFO)
|
| 35 |
logger = logging.getLogger("InfinityChat")
|
| 36 |
|
| 37 |
-
# --------------------------- Database ---------------------------
|
| 38 |
-
async def
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
-
# ---------------------------
|
| 84 |
async def get_db():
|
|
|
|
| 85 |
db = await aiosqlite.connect(DATABASE_URL)
|
| 86 |
db.row_factory = aiosqlite.Row
|
| 87 |
await db.execute("PRAGMA journal_mode=WAL")
|
|
|
|
| 88 |
return db
|
| 89 |
|
|
|
|
| 90 |
def encrypt_message(plain: str) -> str:
|
|
|
|
| 91 |
aesgcm = AESGCM(MESSAGE_KEY)
|
| 92 |
nonce = os.urandom(12)
|
| 93 |
ct = aesgcm.encrypt(nonce, plain.encode(), None)
|
| 94 |
return base64.urlsafe_b64encode(nonce + ct).decode()
|
| 95 |
|
| 96 |
def decrypt_message(encrypted_b64: str) -> str:
|
|
|
|
| 97 |
raw = base64.urlsafe_b64decode(encrypted_b64)
|
| 98 |
nonce, ct = raw[:12], raw[12:]
|
| 99 |
aesgcm = AESGCM(MESSAGE_KEY)
|
| 100 |
return aesgcm.decrypt(nonce, ct, None).decode()
|
| 101 |
|
|
|
|
| 102 |
def hash_password(password: str, salt: Optional[str] = None) -> tuple[str, str]:
|
|
|
|
| 103 |
if salt is None:
|
| 104 |
salt = os.urandom(16).hex()
|
| 105 |
-
kdf = PBKDF2HMAC(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
return base64.urlsafe_b64encode(kdf.derive(password.encode())).decode(), salt
|
| 107 |
|
| 108 |
def verify_password(password: str, salt: str, stored_hash: str) -> bool:
|
|
|
|
| 109 |
key, _ = hash_password(password, salt)
|
| 110 |
return key == stored_hash
|
| 111 |
|
| 112 |
def generate_token() -> str:
|
|
|
|
| 113 |
return base64.urlsafe_b64encode(os.urandom(32)).decode()
|
| 114 |
|
|
|
|
| 115 |
async def get_user_by_token(token: str):
|
|
|
|
| 116 |
db = await get_db()
|
| 117 |
try:
|
| 118 |
async with db.execute("SELECT * FROM users WHERE token = ?", (token,)) as cur:
|
|
@@ -120,62 +154,114 @@ async def get_user_by_token(token: str):
|
|
| 120 |
finally:
|
| 121 |
await db.close()
|
| 122 |
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
# --------------------------- HTTP endpoints ---------------------------
|
| 128 |
@app.post("/signup")
|
| 129 |
async def signup(username: str, password: str, display_name: str = ""):
|
|
|
|
| 130 |
db = await get_db()
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
await db.close()
|
| 133 |
-
raise HTTPException(400, "Username taken")
|
| 134 |
-
pwd_hash, salt = hash_password(password)
|
| 135 |
-
token = generate_token()
|
| 136 |
-
await db.execute("INSERT INTO users (username, display_name, password_hash, salt, token) VALUES (?,?,?,?,?)",
|
| 137 |
-
(username, display_name, pwd_hash, salt, token))
|
| 138 |
-
await db.commit()
|
| 139 |
-
await db.close()
|
| 140 |
-
return {"token": token, "username": username, "display_name": display_name}
|
| 141 |
|
| 142 |
@app.post("/login")
|
| 143 |
async def login(username: str, password: str):
|
|
|
|
| 144 |
db = await get_db()
|
| 145 |
-
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
await db.close()
|
| 148 |
-
raise HTTPException(400, "Invalid credentials")
|
| 149 |
-
token = generate_token()
|
| 150 |
-
await db.execute("UPDATE users SET token = ? WHERE id = ?", (token, user["id"]))
|
| 151 |
-
await db.commit()
|
| 152 |
-
await db.close()
|
| 153 |
-
return {"token": token, "username": user["username"], "display_name": user["display_name"], "avatar_path": user["avatar_path"]}
|
| 154 |
|
| 155 |
@app.get("/me")
|
| 156 |
async def me(token: str = Header(...)):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
user = await get_user_by_token(token)
|
| 158 |
if not user:
|
| 159 |
raise HTTPException(401)
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
|
| 162 |
@app.post("/upload_avatar")
|
| 163 |
async def upload_avatar(file: UploadFile = File(...), token: str = Header(...)):
|
|
|
|
| 164 |
user = await get_user_by_token(token)
|
| 165 |
if not user:
|
| 166 |
raise HTTPException(401)
|
|
|
|
| 167 |
data = await file.read()
|
| 168 |
ext = os.path.splitext(file.filename)[1] if file.filename else ".jpg"
|
| 169 |
-
|
| 170 |
-
|
|
|
|
|
|
|
| 171 |
db = await get_db()
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
|
|
|
|
|
|
|
|
|
| 176 |
|
| 177 |
-
# Chunked upload
|
| 178 |
-
upload_sessions: Dict[str, dict] = {}
|
| 179 |
|
| 180 |
@app.post("/upload_chunk")
|
| 181 |
async def upload_chunk(
|
|
@@ -187,9 +273,11 @@ async def upload_chunk(
|
|
| 187 |
upload_id: str = Query(...),
|
| 188 |
token: str = Header(...)
|
| 189 |
):
|
|
|
|
| 190 |
user = await get_user_by_token(token)
|
| 191 |
if not user:
|
| 192 |
raise HTTPException(401)
|
|
|
|
| 193 |
if upload_id not in upload_sessions:
|
| 194 |
upload_sessions[upload_id] = {
|
| 195 |
"chunks": {},
|
|
@@ -199,65 +287,75 @@ async def upload_chunk(
|
|
| 199 |
"user_id": user["id"],
|
| 200 |
"received": 0
|
| 201 |
}
|
|
|
|
| 202 |
ses = upload_sessions[upload_id]
|
|
|
|
| 203 |
if chunk_index in ses["chunks"]:
|
| 204 |
return {"status": "duplicate"}
|
|
|
|
| 205 |
ses["chunks"][chunk_index] = await file.read()
|
| 206 |
ses["received"] += 1
|
| 207 |
-
|
| 208 |
-
|
|
|
|
| 209 |
full_data = b"".join(ses["chunks"][i] for i in sorted(ses["chunks"]))
|
| 210 |
-
|
|
|
|
| 211 |
try:
|
| 212 |
-
|
| 213 |
-
store_file(remote, full_data)
|
| 214 |
except Exception as e:
|
| 215 |
logger.error(f"Failed to store file: {e}")
|
| 216 |
del upload_sessions[upload_id]
|
| 217 |
raise HTTPException(500, "Storage error")
|
|
|
|
| 218 |
del upload_sessions[upload_id]
|
|
|
|
| 219 |
return {
|
| 220 |
"status": "complete",
|
| 221 |
-
"file_path":
|
| 222 |
"file_name": file_name,
|
| 223 |
"file_type": file_type
|
| 224 |
}
|
| 225 |
-
|
|
|
|
| 226 |
|
| 227 |
-
#
|
| 228 |
@app.get("/download/{file_path:path}")
|
| 229 |
async def download(file_path: str, token: str = Header(...)):
|
|
|
|
| 230 |
user = await get_user_by_token(token)
|
| 231 |
if not user:
|
| 232 |
raise HTTPException(401)
|
|
|
|
| 233 |
try:
|
| 234 |
data = retrieve_file(file_path)
|
| 235 |
except Exception as e:
|
| 236 |
raise HTTPException(404, "File not found or decryption failed")
|
|
|
|
| 237 |
import mimetypes
|
| 238 |
-
|
| 239 |
-
return Response(content=data, media_type=
|
| 240 |
|
| 241 |
@app.get("/")
|
| 242 |
async def root():
|
|
|
|
| 243 |
return FileResponse("static/index.html")
|
| 244 |
|
| 245 |
-
# --------------------------- WebSocket ---------------------------
|
| 246 |
class ConnectionManager:
|
| 247 |
def __init__(self):
|
| 248 |
-
self.active: Dict[str, Set[WebSocket]] = {}
|
| 249 |
-
|
| 250 |
-
|
| 251 |
async def connect(self, ws: WebSocket, username: str):
|
| 252 |
await ws.accept()
|
| 253 |
self.active.setdefault(username, set()).add(ws)
|
| 254 |
-
|
| 255 |
def disconnect(self, ws: WebSocket, username: str):
|
| 256 |
if username in self.active:
|
| 257 |
self.active[username].discard(ws)
|
| 258 |
if not self.active[username]:
|
| 259 |
del self.active[username]
|
| 260 |
-
|
| 261 |
async def broadcast(self, msg: dict, exclude: WebSocket = None):
|
| 262 |
for uname, ws_set in self.active.items():
|
| 263 |
for w in list(ws_set):
|
|
@@ -267,38 +365,42 @@ class ConnectionManager:
|
|
| 267 |
await w.send_json(msg)
|
| 268 |
except Exception:
|
| 269 |
self.disconnect(w, uname)
|
| 270 |
-
|
| 271 |
def online_users(self) -> list:
|
| 272 |
return list(self.active.keys())
|
| 273 |
|
| 274 |
manager = ConnectionManager()
|
| 275 |
|
|
|
|
| 276 |
@app.websocket("/ws")
|
| 277 |
async def ws_endpoint(ws: WebSocket, token: str = Query(...)):
|
|
|
|
| 278 |
if not token:
|
| 279 |
await ws.close(code=4001)
|
| 280 |
return
|
|
|
|
| 281 |
user = await get_user_by_token(token)
|
| 282 |
if not user:
|
| 283 |
await ws.close(code=4001, reason="Invalid token")
|
| 284 |
return
|
| 285 |
-
|
| 286 |
username = user["username"]
|
| 287 |
uid = user["id"]
|
|
|
|
| 288 |
await manager.connect(ws, username)
|
| 289 |
-
|
| 290 |
-
# Send online users
|
| 291 |
online = manager.online_users()
|
| 292 |
await ws.send_json({"type": "online_users", "users": online})
|
| 293 |
await manager.broadcast({"type": "user_online", "username": username}, exclude=ws)
|
| 294 |
-
|
| 295 |
try:
|
| 296 |
while True:
|
| 297 |
data = await ws.receive_json()
|
| 298 |
mtype = data.get("type")
|
| 299 |
-
|
| 300 |
-
# ---- New message ----
|
| 301 |
if mtype == "chat_message":
|
|
|
|
| 302 |
content = data.get("content", "")
|
| 303 |
reply_to = data.get("reply_to_id")
|
| 304 |
client_id = data.get("client_id")
|
|
@@ -306,17 +408,21 @@ async def ws_endpoint(ws: WebSocket, token: str = Query(...)):
|
|
| 306 |
file_type = data.get("file_type")
|
| 307 |
file_name = data.get("file_name")
|
| 308 |
ts = int(time.time() * 1000)
|
| 309 |
-
|
| 310 |
encrypted = encrypt_message(content)
|
|
|
|
| 311 |
db = await get_db()
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
|
|
|
|
|
|
|
|
|
| 320 |
msg_out = {
|
| 321 |
"type": "new_message",
|
| 322 |
"id": mid,
|
|
@@ -335,113 +441,147 @@ async def ws_endpoint(ws: WebSocket, token: str = Query(...)):
|
|
| 335 |
"client_id": client_id,
|
| 336 |
"status": "sent"
|
| 337 |
}
|
| 338 |
-
|
|
|
|
| 339 |
await ws.send_json({**msg_out, "status": "delivered"})
|
| 340 |
# Broadcast to others
|
| 341 |
await manager.broadcast(msg_out, exclude=ws)
|
| 342 |
-
|
| 343 |
-
# ---- Load history (cursor‑based) ----
|
| 344 |
elif mtype == "load_messages":
|
|
|
|
| 345 |
before_id = data.get("before_id")
|
| 346 |
limit = min(data.get("limit", 50), 100)
|
|
|
|
| 347 |
db = await get_db()
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 384 |
elif mtype == "edit_message":
|
|
|
|
| 385 |
mid, new_text = data.get("message_id"), data.get("content")
|
| 386 |
db = await get_db()
|
| 387 |
-
|
| 388 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
await db.close()
|
| 390 |
-
|
| 391 |
-
new_enc = encrypt_message(new_text)
|
| 392 |
-
await db.execute("UPDATE messages SET encrypted_content = ?, is_edited = 1 WHERE id = ?", (new_enc, mid))
|
| 393 |
-
await db.commit()
|
| 394 |
-
await db.close()
|
| 395 |
-
await manager.broadcast({"type": "edit_message", "message_id": mid, "content": new_text})
|
| 396 |
-
|
| 397 |
-
# ---- Delete (permanent) ----
|
| 398 |
elif mtype == "delete_message":
|
|
|
|
| 399 |
mid = data.get("message_id")
|
| 400 |
db = await get_db()
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
await db.close()
|
| 405 |
-
|
| 406 |
-
if msg["file_path"]:
|
| 407 |
-
try:
|
| 408 |
-
delete_file(msg["file_path"])
|
| 409 |
-
except Exception:
|
| 410 |
-
pass
|
| 411 |
-
await db.execute("DELETE FROM messages WHERE id = ?", (mid,))
|
| 412 |
-
await db.execute("DELETE FROM read_receipts WHERE message_id = ?", (mid,))
|
| 413 |
-
await db.commit()
|
| 414 |
-
await db.close()
|
| 415 |
-
await manager.broadcast({"type": "delete_message", "message_id": mid})
|
| 416 |
-
|
| 417 |
-
# ---- Typing ----
|
| 418 |
elif mtype == "typing":
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
|
|
|
|
|
|
|
|
|
| 422 |
elif mtype == "mark_read":
|
|
|
|
| 423 |
up_to = data.get("up_to_message_id")
|
| 424 |
-
if
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 443 |
except WebSocketDisconnect:
|
| 444 |
pass
|
|
|
|
|
|
|
| 445 |
finally:
|
| 446 |
manager.disconnect(ws, username)
|
| 447 |
if username not in manager.active:
|
|
|
|
| 1 |
+
# app.py
|
| 2 |
import os
|
| 3 |
import json
|
| 4 |
import time
|
|
|
|
| 9 |
import aiosqlite
|
| 10 |
import tempfile
|
| 11 |
import io
|
| 12 |
+
import logging
|
| 13 |
+
from contextlib import asynccontextmanager
|
| 14 |
|
| 15 |
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, Header, Query, Request, UploadFile, File
|
| 16 |
from fastapi.staticfiles import StaticFiles
|
| 17 |
from fastapi.responses import Response, JSONResponse, FileResponse
|
|
|
|
| 18 |
|
| 19 |
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
| 20 |
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
|
|
| 36 |
logging.basicConfig(level=logging.INFO)
|
| 37 |
logger = logging.getLogger("InfinityChat")
|
| 38 |
|
| 39 |
+
# --------------------------- Database initialization ---------------------------
|
| 40 |
+
async def init_database():
|
| 41 |
+
"""Initialize the SQLite database with proper async context."""
|
| 42 |
+
async with aiosqlite.connect(DATABASE_URL) as db:
|
| 43 |
+
db.row_factory = aiosqlite.Row
|
| 44 |
+
await db.execute("PRAGMA journal_mode=WAL;")
|
| 45 |
+
await db.execute("PRAGMA foreign_keys=ON;")
|
| 46 |
+
|
| 47 |
+
await db.execute("""
|
| 48 |
+
CREATE TABLE IF NOT EXISTS users (
|
| 49 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 50 |
+
username TEXT UNIQUE NOT NULL,
|
| 51 |
+
display_name TEXT NOT NULL DEFAULT '',
|
| 52 |
+
password_hash TEXT NOT NULL,
|
| 53 |
+
salt TEXT NOT NULL,
|
| 54 |
+
avatar_path TEXT,
|
| 55 |
+
token TEXT UNIQUE,
|
| 56 |
+
created_at INTEGER DEFAULT (strftime('%s','now'))
|
| 57 |
+
)
|
| 58 |
+
""")
|
| 59 |
+
await db.execute("""
|
| 60 |
+
CREATE TABLE IF NOT EXISTS messages (
|
| 61 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 62 |
+
sender_id INTEGER NOT NULL,
|
| 63 |
+
encrypted_content TEXT NOT NULL,
|
| 64 |
+
timestamp_ms INTEGER NOT NULL,
|
| 65 |
+
reply_to_id INTEGER,
|
| 66 |
+
is_edited INTEGER DEFAULT 0,
|
| 67 |
+
is_deleted INTEGER DEFAULT 0,
|
| 68 |
+
file_path TEXT,
|
| 69 |
+
file_type TEXT,
|
| 70 |
+
file_name TEXT,
|
| 71 |
+
FOREIGN KEY(sender_id) REFERENCES users(id)
|
| 72 |
+
)
|
| 73 |
+
""")
|
| 74 |
+
await db.execute("""
|
| 75 |
+
CREATE TABLE IF NOT EXISTS read_receipts (
|
| 76 |
+
user_id INTEGER NOT NULL,
|
| 77 |
+
message_id INTEGER NOT NULL,
|
| 78 |
+
PRIMARY KEY (user_id, message_id)
|
| 79 |
+
)
|
| 80 |
+
""")
|
| 81 |
+
await db.execute("CREATE INDEX IF NOT EXISTS idx_msg_time ON messages(timestamp_ms)")
|
| 82 |
+
await db.commit()
|
| 83 |
+
|
| 84 |
+
logger.info("✅ Database initialized successfully")
|
| 85 |
+
|
| 86 |
+
# --------------------------- Lifespan handler (proper async startup) ---------------------------
|
| 87 |
+
@asynccontextmanager
|
| 88 |
+
async def lifespan(app: FastAPI):
|
| 89 |
+
"""Handle startup and shutdown events properly."""
|
| 90 |
+
# Startup
|
| 91 |
+
await init_database()
|
| 92 |
+
yield
|
| 93 |
+
# Shutdown
|
| 94 |
+
# Cleanup if needed
|
| 95 |
+
|
| 96 |
+
# --------------------------- FastAPI app with lifespan ---------------------------
|
| 97 |
+
app = FastAPI(title="InfinityChat", lifespan=lifespan)
|
| 98 |
+
app.mount("/static", StaticFiles(directory="static"), name="static")
|
| 99 |
|
| 100 |
+
# --------------------------- Database helpers ---------------------------
|
| 101 |
async def get_db():
|
| 102 |
+
"""Get a database connection with proper async context."""
|
| 103 |
db = await aiosqlite.connect(DATABASE_URL)
|
| 104 |
db.row_factory = aiosqlite.Row
|
| 105 |
await db.execute("PRAGMA journal_mode=WAL")
|
| 106 |
+
await db.execute("PRAGMA foreign_keys=ON")
|
| 107 |
return db
|
| 108 |
|
| 109 |
+
# --------------------------- Encryption helpers ---------------------------
|
| 110 |
def encrypt_message(plain: str) -> str:
|
| 111 |
+
"""Encrypt message content with AES-256-GCM."""
|
| 112 |
aesgcm = AESGCM(MESSAGE_KEY)
|
| 113 |
nonce = os.urandom(12)
|
| 114 |
ct = aesgcm.encrypt(nonce, plain.encode(), None)
|
| 115 |
return base64.urlsafe_b64encode(nonce + ct).decode()
|
| 116 |
|
| 117 |
def decrypt_message(encrypted_b64: str) -> str:
|
| 118 |
+
"""Decrypt message content."""
|
| 119 |
raw = base64.urlsafe_b64decode(encrypted_b64)
|
| 120 |
nonce, ct = raw[:12], raw[12:]
|
| 121 |
aesgcm = AESGCM(MESSAGE_KEY)
|
| 122 |
return aesgcm.decrypt(nonce, ct, None).decode()
|
| 123 |
|
| 124 |
+
# --------------------------- Password handling ---------------------------
|
| 125 |
def hash_password(password: str, salt: Optional[str] = None) -> tuple[str, str]:
|
| 126 |
+
"""Hash password with PBKDF2."""
|
| 127 |
if salt is None:
|
| 128 |
salt = os.urandom(16).hex()
|
| 129 |
+
kdf = PBKDF2HMAC(
|
| 130 |
+
algorithm=hashes.SHA256(),
|
| 131 |
+
length=32,
|
| 132 |
+
salt=salt.encode(),
|
| 133 |
+
iterations=600000,
|
| 134 |
+
backend=default_backend()
|
| 135 |
+
)
|
| 136 |
return base64.urlsafe_b64encode(kdf.derive(password.encode())).decode(), salt
|
| 137 |
|
| 138 |
def verify_password(password: str, salt: str, stored_hash: str) -> bool:
|
| 139 |
+
"""Verify password against stored hash."""
|
| 140 |
key, _ = hash_password(password, salt)
|
| 141 |
return key == stored_hash
|
| 142 |
|
| 143 |
def generate_token() -> str:
|
| 144 |
+
"""Generate a secure random token."""
|
| 145 |
return base64.urlsafe_b64encode(os.urandom(32)).decode()
|
| 146 |
|
| 147 |
+
# --------------------------- User lookup ---------------------------
|
| 148 |
async def get_user_by_token(token: str):
|
| 149 |
+
"""Get user by token with proper async context."""
|
| 150 |
db = await get_db()
|
| 151 |
try:
|
| 152 |
async with db.execute("SELECT * FROM users WHERE token = ?", (token,)) as cur:
|
|
|
|
| 154 |
finally:
|
| 155 |
await db.close()
|
| 156 |
|
| 157 |
+
async def get_user_by_username(username: str):
|
| 158 |
+
"""Get user by username."""
|
| 159 |
+
db = await get_db()
|
| 160 |
+
try:
|
| 161 |
+
async with db.execute("SELECT * FROM users WHERE username = ?", (username,)) as cur:
|
| 162 |
+
return await cur.fetchone()
|
| 163 |
+
finally:
|
| 164 |
+
await db.close()
|
| 165 |
|
| 166 |
# --------------------------- HTTP endpoints ---------------------------
|
| 167 |
@app.post("/signup")
|
| 168 |
async def signup(username: str, password: str, display_name: str = ""):
|
| 169 |
+
"""Create a new user account."""
|
| 170 |
db = await get_db()
|
| 171 |
+
try:
|
| 172 |
+
existing = await get_user_by_username(username)
|
| 173 |
+
if existing:
|
| 174 |
+
raise HTTPException(400, "Username already taken")
|
| 175 |
+
|
| 176 |
+
pwd_hash, salt = hash_password(password)
|
| 177 |
+
token = generate_token()
|
| 178 |
+
|
| 179 |
+
await db.execute(
|
| 180 |
+
"INSERT INTO users (username, display_name, password_hash, salt, token) VALUES (?, ?, ?, ?, ?)",
|
| 181 |
+
(username, display_name, pwd_hash, salt, token)
|
| 182 |
+
)
|
| 183 |
+
await db.commit()
|
| 184 |
+
|
| 185 |
+
return {"token": token, "username": username, "display_name": display_name}
|
| 186 |
+
finally:
|
| 187 |
await db.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
|
| 189 |
@app.post("/login")
|
| 190 |
async def login(username: str, password: str):
|
| 191 |
+
"""Authenticate user and return token."""
|
| 192 |
db = await get_db()
|
| 193 |
+
try:
|
| 194 |
+
user = await get_user_by_username(username)
|
| 195 |
+
if not user or not verify_password(password, user["salt"], user["password_hash"]):
|
| 196 |
+
raise HTTPException(400, "Invalid credentials")
|
| 197 |
+
|
| 198 |
+
token = generate_token()
|
| 199 |
+
await db.execute("UPDATE users SET token = ? WHERE id = ?", (token, user["id"]))
|
| 200 |
+
await db.commit()
|
| 201 |
+
|
| 202 |
+
return {
|
| 203 |
+
"token": token,
|
| 204 |
+
"username": user["username"],
|
| 205 |
+
"display_name": user["display_name"],
|
| 206 |
+
"avatar_path": user["avatar_path"]
|
| 207 |
+
}
|
| 208 |
+
finally:
|
| 209 |
await db.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
|
| 211 |
@app.get("/me")
|
| 212 |
async def me(token: str = Header(...)):
|
| 213 |
+
"""Get current user info."""
|
| 214 |
+
user = await get_user_by_token(token)
|
| 215 |
+
if not user:
|
| 216 |
+
raise HTTPException(401, "Invalid token")
|
| 217 |
+
return {
|
| 218 |
+
"id": user["id"],
|
| 219 |
+
"username": user["username"],
|
| 220 |
+
"display_name": user["display_name"],
|
| 221 |
+
"avatar_path": user["avatar_path"]
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
@app.post("/update_profile")
|
| 225 |
+
async def update_profile(display_name: str = None, token: str = Header(...)):
|
| 226 |
+
"""Update user profile."""
|
| 227 |
user = await get_user_by_token(token)
|
| 228 |
if not user:
|
| 229 |
raise HTTPException(401)
|
| 230 |
+
|
| 231 |
+
if display_name is not None:
|
| 232 |
+
db = await get_db()
|
| 233 |
+
try:
|
| 234 |
+
await db.execute("UPDATE users SET display_name = ? WHERE id = ?", (display_name, user["id"]))
|
| 235 |
+
await db.commit()
|
| 236 |
+
finally:
|
| 237 |
+
await db.close()
|
| 238 |
+
|
| 239 |
+
return {"status": "ok"}
|
| 240 |
|
| 241 |
@app.post("/upload_avatar")
|
| 242 |
async def upload_avatar(file: UploadFile = File(...), token: str = Header(...)):
|
| 243 |
+
"""Upload user avatar."""
|
| 244 |
user = await get_user_by_token(token)
|
| 245 |
if not user:
|
| 246 |
raise HTTPException(401)
|
| 247 |
+
|
| 248 |
data = await file.read()
|
| 249 |
ext = os.path.splitext(file.filename)[1] if file.filename else ".jpg"
|
| 250 |
+
remote_path = f"avatars/{user['username']}_{uuid.uuid4().hex}{ext}"
|
| 251 |
+
|
| 252 |
+
store_file(remote_path, data)
|
| 253 |
+
|
| 254 |
db = await get_db()
|
| 255 |
+
try:
|
| 256 |
+
await db.execute("UPDATE users SET avatar_path = ? WHERE id = ?", (remote_path, user["id"]))
|
| 257 |
+
await db.commit()
|
| 258 |
+
finally:
|
| 259 |
+
await db.close()
|
| 260 |
+
|
| 261 |
+
return {"avatar_path": remote_path}
|
| 262 |
|
| 263 |
+
# --------------------------- Chunked upload ---------------------------
|
| 264 |
+
upload_sessions: Dict[str, dict] = {}
|
| 265 |
|
| 266 |
@app.post("/upload_chunk")
|
| 267 |
async def upload_chunk(
|
|
|
|
| 273 |
upload_id: str = Query(...),
|
| 274 |
token: str = Header(...)
|
| 275 |
):
|
| 276 |
+
"""Handle chunked file upload."""
|
| 277 |
user = await get_user_by_token(token)
|
| 278 |
if not user:
|
| 279 |
raise HTTPException(401)
|
| 280 |
+
|
| 281 |
if upload_id not in upload_sessions:
|
| 282 |
upload_sessions[upload_id] = {
|
| 283 |
"chunks": {},
|
|
|
|
| 287 |
"user_id": user["id"],
|
| 288 |
"received": 0
|
| 289 |
}
|
| 290 |
+
|
| 291 |
ses = upload_sessions[upload_id]
|
| 292 |
+
|
| 293 |
if chunk_index in ses["chunks"]:
|
| 294 |
return {"status": "duplicate"}
|
| 295 |
+
|
| 296 |
ses["chunks"][chunk_index] = await file.read()
|
| 297 |
ses["received"] += 1
|
| 298 |
+
|
| 299 |
+
if ses["received"] == ses["total_chunks"]:
|
| 300 |
+
# All chunks received, reassemble
|
| 301 |
full_data = b"".join(ses["chunks"][i] for i in sorted(ses["chunks"]))
|
| 302 |
+
remote_path = f"uploads/{user['username']}_{uuid.uuid4().hex}/{file_name}"
|
| 303 |
+
|
| 304 |
try:
|
| 305 |
+
store_file(remote_path, full_data)
|
|
|
|
| 306 |
except Exception as e:
|
| 307 |
logger.error(f"Failed to store file: {e}")
|
| 308 |
del upload_sessions[upload_id]
|
| 309 |
raise HTTPException(500, "Storage error")
|
| 310 |
+
|
| 311 |
del upload_sessions[upload_id]
|
| 312 |
+
|
| 313 |
return {
|
| 314 |
"status": "complete",
|
| 315 |
+
"file_path": remote_path,
|
| 316 |
"file_name": file_name,
|
| 317 |
"file_type": file_type
|
| 318 |
}
|
| 319 |
+
|
| 320 |
+
return {"status": "chunk_ok", "received": ses["received"], "total": ses["total_chunks"]}
|
| 321 |
|
| 322 |
+
# --------------------------- File download ---------------------------
|
| 323 |
@app.get("/download/{file_path:path}")
|
| 324 |
async def download(file_path: str, token: str = Header(...)):
|
| 325 |
+
"""Download a file (authenticated proxy)."""
|
| 326 |
user = await get_user_by_token(token)
|
| 327 |
if not user:
|
| 328 |
raise HTTPException(401)
|
| 329 |
+
|
| 330 |
try:
|
| 331 |
data = retrieve_file(file_path)
|
| 332 |
except Exception as e:
|
| 333 |
raise HTTPException(404, "File not found or decryption failed")
|
| 334 |
+
|
| 335 |
import mimetypes
|
| 336 |
+
media_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
|
| 337 |
+
return Response(content=data, media_type=media_type)
|
| 338 |
|
| 339 |
@app.get("/")
|
| 340 |
async def root():
|
| 341 |
+
"""Serve the main page."""
|
| 342 |
return FileResponse("static/index.html")
|
| 343 |
|
| 344 |
+
# --------------------------- WebSocket Manager ---------------------------
|
| 345 |
class ConnectionManager:
|
| 346 |
def __init__(self):
|
| 347 |
+
self.active: Dict[str, Set[WebSocket]] = {}
|
| 348 |
+
|
|
|
|
| 349 |
async def connect(self, ws: WebSocket, username: str):
|
| 350 |
await ws.accept()
|
| 351 |
self.active.setdefault(username, set()).add(ws)
|
| 352 |
+
|
| 353 |
def disconnect(self, ws: WebSocket, username: str):
|
| 354 |
if username in self.active:
|
| 355 |
self.active[username].discard(ws)
|
| 356 |
if not self.active[username]:
|
| 357 |
del self.active[username]
|
| 358 |
+
|
| 359 |
async def broadcast(self, msg: dict, exclude: WebSocket = None):
|
| 360 |
for uname, ws_set in self.active.items():
|
| 361 |
for w in list(ws_set):
|
|
|
|
| 365 |
await w.send_json(msg)
|
| 366 |
except Exception:
|
| 367 |
self.disconnect(w, uname)
|
| 368 |
+
|
| 369 |
def online_users(self) -> list:
|
| 370 |
return list(self.active.keys())
|
| 371 |
|
| 372 |
manager = ConnectionManager()
|
| 373 |
|
| 374 |
+
# --------------------------- WebSocket endpoint ---------------------------
|
| 375 |
@app.websocket("/ws")
|
| 376 |
async def ws_endpoint(ws: WebSocket, token: str = Query(...)):
|
| 377 |
+
"""WebSocket endpoint for real-time communication."""
|
| 378 |
if not token:
|
| 379 |
await ws.close(code=4001)
|
| 380 |
return
|
| 381 |
+
|
| 382 |
user = await get_user_by_token(token)
|
| 383 |
if not user:
|
| 384 |
await ws.close(code=4001, reason="Invalid token")
|
| 385 |
return
|
| 386 |
+
|
| 387 |
username = user["username"]
|
| 388 |
uid = user["id"]
|
| 389 |
+
|
| 390 |
await manager.connect(ws, username)
|
| 391 |
+
|
| 392 |
+
# Send online users
|
| 393 |
online = manager.online_users()
|
| 394 |
await ws.send_json({"type": "online_users", "users": online})
|
| 395 |
await manager.broadcast({"type": "user_online", "username": username}, exclude=ws)
|
| 396 |
+
|
| 397 |
try:
|
| 398 |
while True:
|
| 399 |
data = await ws.receive_json()
|
| 400 |
mtype = data.get("type")
|
| 401 |
+
|
|
|
|
| 402 |
if mtype == "chat_message":
|
| 403 |
+
# Handle new message
|
| 404 |
content = data.get("content", "")
|
| 405 |
reply_to = data.get("reply_to_id")
|
| 406 |
client_id = data.get("client_id")
|
|
|
|
| 408 |
file_type = data.get("file_type")
|
| 409 |
file_name = data.get("file_name")
|
| 410 |
ts = int(time.time() * 1000)
|
| 411 |
+
|
| 412 |
encrypted = encrypt_message(content)
|
| 413 |
+
|
| 414 |
db = await get_db()
|
| 415 |
+
try:
|
| 416 |
+
cur = await db.execute(
|
| 417 |
+
"""INSERT INTO messages (sender_id, encrypted_content, timestamp_ms, reply_to_id, file_path, file_type, file_name)
|
| 418 |
+
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
| 419 |
+
(uid, encrypted, ts, reply_to, file_path, file_type, file_name)
|
| 420 |
+
)
|
| 421 |
+
mid = cur.lastrowid
|
| 422 |
+
await db.commit()
|
| 423 |
+
finally:
|
| 424 |
+
await db.close()
|
| 425 |
+
|
| 426 |
msg_out = {
|
| 427 |
"type": "new_message",
|
| 428 |
"id": mid,
|
|
|
|
| 441 |
"client_id": client_id,
|
| 442 |
"status": "sent"
|
| 443 |
}
|
| 444 |
+
|
| 445 |
+
# Send confirmation to sender
|
| 446 |
await ws.send_json({**msg_out, "status": "delivered"})
|
| 447 |
# Broadcast to others
|
| 448 |
await manager.broadcast(msg_out, exclude=ws)
|
| 449 |
+
|
|
|
|
| 450 |
elif mtype == "load_messages":
|
| 451 |
+
# Handle message loading with cursor-based pagination
|
| 452 |
before_id = data.get("before_id")
|
| 453 |
limit = min(data.get("limit", 50), 100)
|
| 454 |
+
|
| 455 |
db = await get_db()
|
| 456 |
+
try:
|
| 457 |
+
q = "SELECT * FROM messages WHERE is_deleted = 0"
|
| 458 |
+
params = []
|
| 459 |
+
if before_id is not None:
|
| 460 |
+
q += " AND id < ?"
|
| 461 |
+
params.append(before_id)
|
| 462 |
+
q += " ORDER BY id DESC LIMIT ?"
|
| 463 |
+
params.append(limit)
|
| 464 |
+
|
| 465 |
+
rows = await (await db.execute(q, tuple(params))).fetchall()
|
| 466 |
+
rows = list(reversed(rows))
|
| 467 |
+
|
| 468 |
+
msgs = []
|
| 469 |
+
for row in rows:
|
| 470 |
+
try:
|
| 471 |
+
plain = decrypt_message(row["encrypted_content"])
|
| 472 |
+
except Exception:
|
| 473 |
+
plain = "[decryption error]"
|
| 474 |
+
|
| 475 |
+
s = await db.execute(
|
| 476 |
+
"SELECT id, username, display_name, avatar_path FROM users WHERE id = ?",
|
| 477 |
+
(row["sender_id"],)
|
| 478 |
+
)
|
| 479 |
+
s = await s.fetchone()
|
| 480 |
+
|
| 481 |
+
msgs.append({
|
| 482 |
+
"id": row["id"],
|
| 483 |
+
"sender_id": row["sender_id"],
|
| 484 |
+
"username": s["username"] if s else "unknown",
|
| 485 |
+
"display_name": s["display_name"] if s else "",
|
| 486 |
+
"avatar_path": s["avatar_path"] if s else "",
|
| 487 |
+
"content": plain,
|
| 488 |
+
"timestamp_ms": row["timestamp_ms"],
|
| 489 |
+
"reply_to_id": row["reply_to_id"],
|
| 490 |
+
"file_path": row["file_path"],
|
| 491 |
+
"file_type": row["file_type"],
|
| 492 |
+
"file_name": row["file_name"],
|
| 493 |
+
"is_edited": bool(row["is_edited"]),
|
| 494 |
+
"is_deleted": bool(row["is_deleted"])
|
| 495 |
+
})
|
| 496 |
+
|
| 497 |
+
await ws.send_json({"type": "messages_batch", "messages": msgs, "before_id": before_id})
|
| 498 |
+
finally:
|
| 499 |
+
await db.close()
|
| 500 |
+
|
| 501 |
elif mtype == "edit_message":
|
| 502 |
+
# Handle message edit
|
| 503 |
mid, new_text = data.get("message_id"), data.get("content")
|
| 504 |
db = await get_db()
|
| 505 |
+
try:
|
| 506 |
+
row = await db.execute(
|
| 507 |
+
"SELECT * FROM messages WHERE id = ? AND sender_id = ? AND is_deleted = 0",
|
| 508 |
+
(mid, uid)
|
| 509 |
+
)
|
| 510 |
+
if await row.fetchone():
|
| 511 |
+
new_enc = encrypt_message(new_text)
|
| 512 |
+
await db.execute(
|
| 513 |
+
"UPDATE messages SET encrypted_content = ?, is_edited = 1 WHERE id = ?",
|
| 514 |
+
(new_enc, mid)
|
| 515 |
+
)
|
| 516 |
+
await db.commit()
|
| 517 |
+
await manager.broadcast({"type": "edit_message", "message_id": mid, "content": new_text})
|
| 518 |
+
finally:
|
| 519 |
await db.close()
|
| 520 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 521 |
elif mtype == "delete_message":
|
| 522 |
+
# Handle permanent message deletion
|
| 523 |
mid = data.get("message_id")
|
| 524 |
db = await get_db()
|
| 525 |
+
try:
|
| 526 |
+
row = await db.execute("SELECT * FROM messages WHERE id = ? AND sender_id = ?", (mid, uid))
|
| 527 |
+
msg = await row.fetchone()
|
| 528 |
+
if msg:
|
| 529 |
+
if msg["file_path"]:
|
| 530 |
+
try:
|
| 531 |
+
delete_file(msg["file_path"])
|
| 532 |
+
except Exception:
|
| 533 |
+
pass
|
| 534 |
+
|
| 535 |
+
await db.execute("DELETE FROM messages WHERE id = ?", (mid,))
|
| 536 |
+
await db.execute("DELETE FROM read_receipts WHERE message_id = ?", (mid,))
|
| 537 |
+
await db.commit()
|
| 538 |
+
await manager.broadcast({"type": "delete_message", "message_id": mid})
|
| 539 |
+
finally:
|
| 540 |
await db.close()
|
| 541 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 542 |
elif mtype == "typing":
|
| 543 |
+
# Handle typing indicator
|
| 544 |
+
await manager.broadcast(
|
| 545 |
+
{"type": "typing_update", "username": username, "is_typing": data.get("is_typing", False)},
|
| 546 |
+
exclude=ws
|
| 547 |
+
)
|
| 548 |
+
|
| 549 |
elif mtype == "mark_read":
|
| 550 |
+
# Handle read receipts
|
| 551 |
up_to = data.get("up_to_message_id")
|
| 552 |
+
if up_to:
|
| 553 |
+
db = await get_db()
|
| 554 |
+
try:
|
| 555 |
+
rows = await db.execute(
|
| 556 |
+
"""SELECT id, sender_id FROM messages
|
| 557 |
+
WHERE id <= ? AND sender_id != ? AND is_deleted = 0
|
| 558 |
+
EXCEPT SELECT message_id FROM read_receipts WHERE user_id = ?""",
|
| 559 |
+
(up_to, uid, uid)
|
| 560 |
+
)
|
| 561 |
+
|
| 562 |
+
receipts = []
|
| 563 |
+
async for r in rows:
|
| 564 |
+
await db.execute(
|
| 565 |
+
"INSERT OR IGNORE INTO read_receipts (user_id, message_id) VALUES (?, ?)",
|
| 566 |
+
(uid, r["id"])
|
| 567 |
+
)
|
| 568 |
+
receipts.append(r["id"])
|
| 569 |
+
|
| 570 |
+
await db.commit()
|
| 571 |
+
|
| 572 |
+
for mid in receipts:
|
| 573 |
+
await manager.broadcast({
|
| 574 |
+
"type": "read_receipt",
|
| 575 |
+
"message_id": mid,
|
| 576 |
+
"reader_username": username
|
| 577 |
+
})
|
| 578 |
+
finally:
|
| 579 |
+
await db.close()
|
| 580 |
+
|
| 581 |
except WebSocketDisconnect:
|
| 582 |
pass
|
| 583 |
+
except Exception as e:
|
| 584 |
+
logger.error(f"WebSocket error for {username}: {e}")
|
| 585 |
finally:
|
| 586 |
manager.disconnect(ws, username)
|
| 587 |
if username not in manager.active:
|