smodusermc commited on
Commit
2e5dd6d
·
verified ·
1 Parent(s): 93a1022

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +827 -320
app.py CHANGED
@@ -1,49 +1,57 @@
1
- # app.py
2
  import os
3
  import json
4
  import time
5
  import uuid
6
  import base64
7
- from typing import Optional, Dict, Set
8
  import asyncio
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
21
  from cryptography.hazmat.primitives import hashes
22
  from cryptography.hazmat.backends import default_backend
23
 
24
- from storage_handler import store_file, retrieve_file, delete_file, store_file_stream
25
 
26
- # --------------------------- Configuration ---------------------------
 
 
27
  DATABASE_URL = os.environ.get("DATABASE_URL", "/data/infinitychat.db")
28
  MESSAGE_KEY_B64 = os.environ.get("SECRET_KEY", None)
29
  if MESSAGE_KEY_B64 is None:
30
- # Auto-generate (set permanently in production!)
31
  MESSAGE_KEY_B64 = base64.urlsafe_b64encode(os.urandom(32)).decode()
32
- print("WARNING: SECRET_KEY not set – generated random key.")
33
  MESSAGE_KEY = base64.urlsafe_b64decode(MESSAGE_KEY_B64)
34
- assert len(MESSAGE_KEY) == 32
35
 
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,
@@ -53,9 +61,13 @@ async def init_database():
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,
@@ -68,72 +80,140 @@ async def init_database():
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."""
@@ -142,447 +222,874 @@ def verify_password(password: str, salt: str, stored_hash: str) -> bool:
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:
153
- return await cur.fetchone()
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(
268
  file: UploadFile = File(...),
269
- chunk_index: int = Query(...),
270
- total_chunks: int = Query(...),
271
- file_name: str = Query(...),
272
- file_type: str = Query(...),
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": {},
284
  "filename": file_name,
285
  "file_type": file_type,
 
286
  "total_chunks": total_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):
362
- if w == exclude:
363
- continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
364
  try:
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")
 
407
  file_path = data.get("file_path")
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,
429
- "sender_id": uid,
430
- "username": username,
431
- "display_name": user["display_name"],
432
- "avatar_path": user["avatar_path"],
433
- "content": content,
434
- "timestamp_ms": ts,
435
- "reply_to_id": reply_to,
436
- "file_path": file_path,
437
- "file_type": file_type,
438
- "file_name": file_name,
439
- "is_edited": False,
440
- "is_deleted": False,
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:
588
- await manager.broadcast({"type": "user_offline", "username": username})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import os
2
  import json
3
  import time
4
  import uuid
5
  import base64
6
+ from typing import Optional, Dict, Set, List, Any
7
  import asyncio
8
  import aiosqlite
 
 
9
  import logging
10
  from contextlib import asynccontextmanager
11
+ from datetime import datetime
12
+ from urllib.parse import unquote
13
 
14
  from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, Header, Query, Request, UploadFile, File
15
  from fastapi.staticfiles import StaticFiles
16
  from fastapi.responses import Response, JSONResponse, FileResponse
17
+ from fastapi.middleware.cors import CORSMiddleware
18
+ from fastapi.middleware.trustedhost import TrustedHostMiddleware
19
 
20
  from cryptography.hazmat.primitives.ciphers.aead import AESGCM
21
  from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
22
  from cryptography.hazmat.primitives import hashes
23
  from cryptography.hazmat.backends import default_backend
24
 
25
+ from storage_handler import store_file, retrieve_file, delete_file, get_bucket_info, file_exists
26
 
27
+ # ------------------------------------------------------------------------
28
+ # Configuration
29
+ # ------------------------------------------------------------------------
30
  DATABASE_URL = os.environ.get("DATABASE_URL", "/data/infinitychat.db")
31
  MESSAGE_KEY_B64 = os.environ.get("SECRET_KEY", None)
32
  if MESSAGE_KEY_B64 is None:
 
33
  MESSAGE_KEY_B64 = base64.urlsafe_b64encode(os.urandom(32)).decode()
34
+ print("WARNING: SECRET_KEY not set – generated random key. Set it permanently in production!")
35
  MESSAGE_KEY = base64.urlsafe_b64decode(MESSAGE_KEY_B64)
36
+ assert len(MESSAGE_KEY) == 32, "SECRET_KEY must decode to exactly 32 bytes"
37
 
38
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
39
  logger = logging.getLogger("InfinityChat")
40
 
41
+ # ------------------------------------------------------------------------
42
+ # Database initialization
43
+ # ------------------------------------------------------------------------
44
  async def init_database():
45
+ """Initialize SQLite database with all required tables and indexes."""
46
  async with aiosqlite.connect(DATABASE_URL) as db:
47
  db.row_factory = aiosqlite.Row
48
  await db.execute("PRAGMA journal_mode=WAL;")
49
  await db.execute("PRAGMA foreign_keys=ON;")
50
+ await db.execute("PRAGMA synchronous=NORMAL;")
51
+ await db.execute("PRAGMA cache_size=-20000;") # 20MB cache
52
+ await db.execute("PRAGMA busy_timeout=5000;")
53
 
54
+ # Users table
55
  await db.execute("""
56
  CREATE TABLE IF NOT EXISTS users (
57
  id INTEGER PRIMARY KEY AUTOINCREMENT,
 
61
  salt TEXT NOT NULL,
62
  avatar_path TEXT,
63
  token TEXT UNIQUE,
64
+ created_at INTEGER DEFAULT (strftime('%s','now')),
65
+ last_seen INTEGER DEFAULT (strftime('%s','now')),
66
+ status TEXT DEFAULT 'offline'
67
  )
68
  """)
69
+
70
+ # Messages table with optimized indexes
71
  await db.execute("""
72
  CREATE TABLE IF NOT EXISTS messages (
73
  id INTEGER PRIMARY KEY AUTOINCREMENT,
 
80
  file_path TEXT,
81
  file_type TEXT,
82
  file_name TEXT,
83
+ file_size INTEGER DEFAULT 0,
84
  FOREIGN KEY(sender_id) REFERENCES users(id)
85
  )
86
  """)
87
+
88
+ # Read receipts
89
  await db.execute("""
90
  CREATE TABLE IF NOT EXISTS read_receipts (
91
  user_id INTEGER NOT NULL,
92
  message_id INTEGER NOT NULL,
93
+ read_at INTEGER DEFAULT (strftime('%s','now')),
94
+ PRIMARY KEY (user_id, message_id),
95
+ FOREIGN KEY(user_id) REFERENCES users(id),
96
+ FOREIGN KEY(message_id) REFERENCES messages(id)
97
  )
98
  """)
99
+
100
+ # Conversation index for faster queries
101
+ await db.execute("""
102
+ CREATE INDEX IF NOT EXISTS idx_messages_sender_time
103
+ ON messages(sender_id, timestamp_ms)
104
+ """)
105
+ await db.execute("""
106
+ CREATE INDEX IF NOT EXISTS idx_messages_time_id
107
+ ON messages(timestamp_ms, id)
108
+ """)
109
+ await db.execute("""
110
+ CREATE INDEX IF NOT EXISTS idx_messages_not_deleted
111
+ ON messages(is_deleted, id)
112
+ """)
113
+ await db.execute("""
114
+ CREATE INDEX IF NOT EXISTS idx_receipts_user_msg
115
+ ON read_receipts(user_id, message_id)
116
+ """)
117
+
118
  await db.commit()
119
 
120
  logger.info("✅ Database initialized successfully")
121
 
122
+ # ------------------------------------------------------------------------
123
+ # FastAPI app with lifespan
124
+ # ------------------------------------------------------------------------
125
  @asynccontextmanager
126
  async def lifespan(app: FastAPI):
127
+ """Handle startup and shutdown events."""
 
128
  await init_database()
129
  yield
 
 
130
 
131
+ app = FastAPI(
132
+ title="InfinityChat",
133
+ version="1.0.0",
134
+ lifespan=lifespan,
135
+ docs_url=None,
136
+ redoc_url=None
137
+ )
138
+
139
+ # Middleware
140
+ app.add_middleware(
141
+ CORSMiddleware,
142
+ allow_origins=["*"],
143
+ allow_credentials=True,
144
+ allow_methods=["*"],
145
+ allow_headers=["*"],
146
+ )
147
+
148
+ # Static files
149
  app.mount("/static", StaticFiles(directory="static"), name="static")
150
 
151
+ # ------------------------------------------------------------------------
152
+ # Database connection pool
153
+ # ------------------------------------------------------------------------
154
+ class DatabasePool:
155
+ """Simple database connection management."""
156
+
157
+ def __init__(self):
158
+ self._conn = None
159
+
160
+ async def get_connection(self) -> aiosqlite.Connection:
161
+ if self._conn is None:
162
+ self._conn = await aiosqlite.connect(DATABASE_URL)
163
+ self._conn.row_factory = aiosqlite.Row
164
+ await self._conn.execute("PRAGMA journal_mode=WAL")
165
+ await self._conn.execute("PRAGMA foreign_keys=ON")
166
+ await self._conn.execute("PRAGMA synchronous=NORMAL")
167
+ return self._conn
168
+
169
+ async def close(self):
170
+ if self._conn:
171
+ await self._conn.close()
172
+ self._conn = None
173
+
174
+ db_pool = DatabasePool()
175
+
176
+ async def get_db() -> aiosqlite.Connection:
177
+ """Get or create a database connection."""
178
+ return await db_pool.get_connection()
179
+
180
+ # ------------------------------------------------------------------------
181
+ # Encryption (AES-256-GCM)
182
+ # ------------------------------------------------------------------------
183
+ def encrypt_message(plaintext: str) -> str:
184
  """Encrypt message content with AES-256-GCM."""
185
  aesgcm = AESGCM(MESSAGE_KEY)
186
  nonce = os.urandom(12)
187
+ ciphertext = aesgcm.encrypt(nonce, plaintext.encode('utf-8'), None)
188
+ return base64.urlsafe_b64encode(nonce + ciphertext).decode('utf-8')
189
 
190
  def decrypt_message(encrypted_b64: str) -> str:
191
  """Decrypt message content."""
192
+ try:
193
+ raw = base64.urlsafe_b64decode(encrypted_b64)
194
+ nonce, ciphertext = raw[:12], raw[12:]
195
+ aesgcm = AESGCM(MESSAGE_KEY)
196
+ return aesgcm.decrypt(nonce, ciphertext, None).decode('utf-8')
197
+ except Exception as e:
198
+ logger.error(f"Decryption failed: {e}")
199
+ return "[Decryption Error]"
200
 
201
+ # ------------------------------------------------------------------------
202
+ # Password hashing (PBKDF2)
203
+ # ------------------------------------------------------------------------
204
  def hash_password(password: str, salt: Optional[str] = None) -> tuple[str, str]:
205
+ """Hash password with PBKDF2-SHA256."""
206
  if salt is None:
207
+ salt = os.urandom(32).hex()
208
  kdf = PBKDF2HMAC(
209
  algorithm=hashes.SHA256(),
210
  length=32,
211
+ salt=salt.encode('utf-8'),
212
  iterations=600000,
213
  backend=default_backend()
214
  )
215
+ key = base64.urlsafe_b64encode(kdf.derive(password.encode('utf-8'))).decode('utf-8')
216
+ return key, salt
217
 
218
  def verify_password(password: str, salt: str, stored_hash: str) -> bool:
219
  """Verify password against stored hash."""
 
222
 
223
  def generate_token() -> str:
224
  """Generate a secure random token."""
225
+ return base64.urlsafe_b64encode(os.urandom(32)).decode('utf-8')
226
 
227
+ # ------------------------------------------------------------------------
228
+ # Authentication helpers
229
+ # ------------------------------------------------------------------------
230
+ async def authenticate_user(token: str) -> Optional[dict]:
231
+ """Validate token and return user data."""
232
+ if not token or token in ('null', 'undefined'):
233
+ return None
234
+
235
  db = await get_db()
236
  try:
237
+ async with db.execute(
238
+ "SELECT id, username, display_name, avatar_path, status FROM users WHERE token = ?",
239
+ (token,)
240
+ ) as cursor:
241
+ user = await cursor.fetchone()
242
+ if user:
243
+ # Update last_seen
244
+ await db.execute(
245
+ "UPDATE users SET last_seen = ? WHERE id = ?",
246
+ (int(time.time()), user['id'])
247
+ )
248
+ await db.commit()
249
+ return dict(user)
250
+ return None
251
+ except Exception as e:
252
+ logger.error(f"Authentication error: {e}")
253
+ return None
254
 
255
+ # ------------------------------------------------------------------------
256
+ # HTTP Endpoints
257
+ # ------------------------------------------------------------------------
258
+ @app.get("/api/health")
259
+ async def health_check():
260
+ """Health check endpoint."""
261
+ return {
262
+ "status": "healthy",
263
+ "timestamp": int(time.time()),
264
+ "version": "1.0.0"
265
+ }
266
 
267
+ @app.post("/api/auth/signup")
268
+ async def signup(
269
+ username: str = Query(..., min_length=3, max_length=30),
270
+ password: str = Query(..., min_length=6),
271
+ display_name: Optional[str] = Query(None, max_length=50)
272
+ ):
273
  """Create a new user account."""
274
+ # Validate username format
275
+ if not username.isalnum() and '_' not in username:
276
+ raise HTTPException(400, "Username can only contain letters, numbers, and underscores")
277
+
278
  db = await get_db()
279
  try:
280
+ # Check if username exists
281
+ async with db.execute(
282
+ "SELECT id FROM users WHERE username = ?", (username.lower(),)
283
+ ) as cursor:
284
+ if await cursor.fetchone():
285
+ raise HTTPException(409, "Username is already taken")
286
 
287
+ # Create user
288
  pwd_hash, salt = hash_password(password)
289
  token = generate_token()
290
+ display = display_name or username
291
 
292
+ cursor = await db.execute(
293
+ """INSERT INTO users (username, display_name, password_hash, salt, token, status)
294
+ VALUES (?, ?, ?, ?, ?, 'online')""",
295
+ (username.lower(), display, pwd_hash, salt, token)
296
  )
297
  await db.commit()
298
+ user_id = cursor.lastrowid
299
+
300
+ logger.info(f"New user registered: {username} (ID: {user_id})")
301
 
302
+ return {
303
+ "token": token,
304
+ "user": {
305
+ "id": user_id,
306
+ "username": username,
307
+ "display_name": display,
308
+ "avatar_path": None,
309
+ "status": "online"
310
+ }
311
+ }
312
+ except HTTPException:
313
+ raise
314
+ except Exception as e:
315
+ logger.error(f"Signup error: {e}")
316
+ raise HTTPException(500, "Internal server error")
317
  finally:
318
  await db.close()
319
 
320
+ @app.post("/api/auth/login")
321
+ async def login(
322
+ username: str = Query(...),
323
+ password: str = Query(...)
324
+ ):
325
  """Authenticate user and return token."""
326
  db = await get_db()
327
  try:
328
+ async with db.execute(
329
+ "SELECT * FROM users WHERE username = ?", (username.lower(),)
330
+ ) as cursor:
331
+ user = await cursor.fetchone()
332
+
333
+ if not user:
334
+ # Use constant-time comparison to prevent timing attacks
335
+ verify_password("dummy", os.urandom(32).hex(),
336
+ base64.urlsafe_b64encode(os.urandom(32)).decode())
337
+ raise HTTPException(401, "Invalid username or password")
338
 
339
+ if not verify_password(password, user['salt'], user['password_hash']):
340
+ raise HTTPException(401, "Invalid username or password")
341
+
342
+ # Generate new token
343
  token = generate_token()
344
+ await db.execute(
345
+ "UPDATE users SET token = ?, last_seen = ?, status = 'online' WHERE id = ?",
346
+ (token, int(time.time()), user['id'])
347
+ )
348
  await db.commit()
349
 
350
+ logger.info(f"User logged in: {user['username']}")
351
+
352
  return {
353
  "token": token,
354
+ "user": {
355
+ "id": user['id'],
356
+ "username": user['username'],
357
+ "display_name": user['display_name'],
358
+ "avatar_path": user['avatar_path'],
359
+ "status": "online"
360
+ }
361
  }
362
+ except HTTPException:
363
+ raise
364
+ except Exception as e:
365
+ logger.error(f"Login error: {e}")
366
+ raise HTTPException(500, "Internal server error")
367
  finally:
368
  await db.close()
369
 
370
+ @app.get("/api/auth/verify")
371
+ async def verify_token(token: str = Header(..., alias="X-Auth-Token")):
372
+ """Verify authentication token."""
373
+ user = await authenticate_user(token)
374
  if not user:
375
+ raise HTTPException(401, "Invalid or expired token")
376
+ return {"user": user}
377
+
378
+ @app.post("/api/auth/logout")
379
+ async def logout(token: str = Header(..., alias="X-Auth-Token")):
380
+ """Logout user by invalidating token."""
381
+ db = await get_db()
382
+ try:
383
+ await db.execute(
384
+ "UPDATE users SET token = NULL, status = 'offline', last_seen = ? WHERE token = ?",
385
+ (int(time.time()), token)
386
+ )
387
+ await db.commit()
388
+ return {"status": "logged_out"}
389
+ finally:
390
+ await db.close()
391
 
392
+ # ------------------------------------------------------------------------
393
+ # Profile Endpoints
394
+ # ------------------------------------------------------------------------
395
+ @app.get("/api/profile")
396
+ async def get_profile(token: str = Header(..., alias="X-Auth-Token")):
397
+ """Get current user profile."""
398
+ user = await authenticate_user(token)
399
+ if not user:
400
+ raise HTTPException(401)
401
+ return {"user": user}
402
+
403
+ @app.patch("/api/profile")
404
+ async def update_profile(
405
+ display_name: Optional[str] = Query(None, max_length=50),
406
+ token: str = Header(..., alias="X-Auth-Token")
407
+ ):
408
  """Update user profile."""
409
+ user = await authenticate_user(token)
410
  if not user:
411
  raise HTTPException(401)
412
 
413
+ db = await get_db()
414
+ try:
415
+ if display_name:
416
+ await db.execute(
417
+ "UPDATE users SET display_name = ? WHERE id = ?",
418
+ (display_name, user['id'])
419
+ )
420
  await db.commit()
421
+ user['display_name'] = display_name
422
+
423
+ return {"user": user}
424
+ finally:
425
+ await db.close()
426
 
427
+ @app.post("/api/profile/avatar")
428
+ async def upload_avatar(
429
+ file: UploadFile = File(...),
430
+ token: str = Header(..., alias="X-Auth-Token")
431
+ ):
432
  """Upload user avatar."""
433
+ user = await authenticate_user(token)
434
  if not user:
435
  raise HTTPException(401)
436
 
437
+ # Validate file type
438
+ allowed_types = {'image/jpeg', 'image/png', 'image/gif', 'image/webp'}
439
+ if file.content_type not in allowed_types:
440
+ raise HTTPException(400, "Only JPEG, PNG, GIF, and WebP images are allowed")
441
+
442
+ # Validate file size (max 5MB)
443
  data = await file.read()
444
+ if len(data) > 5 * 1024 * 1024:
445
+ raise HTTPException(400, "Image must be less than 5MB")
446
+
447
+ # Generate unique path
448
+ ext = os.path.splitext(file.filename)[1] if file.filename else '.jpg'
449
  remote_path = f"avatars/{user['username']}_{uuid.uuid4().hex}{ext}"
450
 
451
+ try:
452
+ store_file(remote_path, data)
453
+ except Exception as e:
454
+ logger.error(f"Avatar upload failed: {e}")
455
+ raise HTTPException(500, "Failed to upload avatar")
456
 
457
  db = await get_db()
458
  try:
459
+ await db.execute(
460
+ "UPDATE users SET avatar_path = ? WHERE id = ?",
461
+ (remote_path, user['id'])
462
+ )
463
  await db.commit()
464
+ user['avatar_path'] = remote_path
465
  finally:
466
  await db.close()
467
 
468
  return {"avatar_path": remote_path}
469
 
470
+ # ------------------------------------------------------------------------
471
+ # File Upload (Chunked)
472
+ # ------------------------------------------------------------------------
473
+ upload_sessions: Dict[str, Dict] = {}
474
 
475
+ @app.post("/api/upload/chunk")
476
  async def upload_chunk(
477
  file: UploadFile = File(...),
478
+ chunk_index: int = Query(..., ge=0),
479
+ total_chunks: int = Query(..., ge=1),
480
+ file_name: str = Query(..., max_length=255),
481
+ file_type: str = Query(..., max_length=100),
482
+ file_size: int = Query(..., ge=0),
483
+ upload_id: str = Query(..., min_length=8),
484
+ token: str = Header(..., alias="X-Auth-Token")
485
  ):
486
  """Handle chunked file upload."""
487
+ user = await authenticate_user(token)
488
  if not user:
489
  raise HTTPException(401)
490
 
491
+ # Validate file size (max 100MB total)
492
+ if file_size > 100 * 1024 * 1024:
493
+ raise HTTPException(400, "File must be less than 100MB")
494
+
495
+ # Initialize upload session if new
496
  if upload_id not in upload_sessions:
497
  upload_sessions[upload_id] = {
498
  "chunks": {},
499
  "filename": file_name,
500
  "file_type": file_type,
501
+ "file_size": file_size,
502
  "total_chunks": total_chunks,
503
+ "received": 0,
504
+ "user_id": user['id'],
505
+ "created_at": time.time()
506
  }
507
 
508
+ session = upload_sessions[upload_id]
509
 
510
+ # Verify ownership
511
+ if session['user_id'] != user['id']:
512
+ raise HTTPException(403, "Upload session belongs to another user")
513
 
514
+ # Store chunk
515
+ chunk_data = await file.read()
516
+ if chunk_index in session['chunks']:
517
+ return {"status": "duplicate", "received": session['received'], "total": total_chunks}
518
 
519
+ session['chunks'][chunk_index] = chunk_data
520
+ session['received'] += 1
521
+
522
+ # Check if upload complete
523
+ if session['received'] == total_chunks:
524
+ # Reassemble file
525
+ full_data = b"".join(session['chunks'][i] for i in sorted(session['chunks'].keys()))
526
+
527
+ # Generate storage path
528
+ remote_path = f"uploads/{user['username']}/{uuid.uuid4().hex}/{file_name}"
529
 
530
  try:
531
  store_file(remote_path, full_data)
532
  except Exception as e:
533
+ logger.error(f"Failed to store uploaded file: {e}")
534
  del upload_sessions[upload_id]
535
+ raise HTTPException(500, "Failed to store file")
536
 
537
+ # Cleanup
538
  del upload_sessions[upload_id]
539
 
540
+ # Cleanup old sessions (older than 30 minutes)
541
+ current_time = time.time()
542
+ expired = [uid for uid, s in upload_sessions.items()
543
+ if current_time - s['created_at'] > 1800]
544
+ for uid in expired:
545
+ del upload_sessions[uid]
546
+
547
  return {
548
  "status": "complete",
549
  "file_path": remote_path,
550
  "file_name": file_name,
551
+ "file_type": file_type,
552
+ "file_size": file_size
553
  }
554
 
555
+ return {
556
+ "status": "in_progress",
557
+ "received": session['received'],
558
+ "total": total_chunks
559
+ }
560
 
561
+ # ------------------------------------------------------------------------
562
+ # File Download
563
+ # ------------------------------------------------------------------------
564
+ @app.get("/api/download/{file_path:path}")
565
+ async def download_file(
566
+ file_path: str,
567
+ token: str = Header(..., alias="X-Auth-Token")
568
+ ):
569
+ """Download a file with authentication."""
570
+ user = await authenticate_user(token)
571
  if not user:
572
  raise HTTPException(401)
573
 
574
+ # Security: prevent path traversal
575
+ if '..' in file_path or file_path.startswith('/'):
576
+ raise HTTPException(400, "Invalid file path")
577
+
578
  try:
579
  data = retrieve_file(file_path)
580
+ except FileNotFoundError:
581
+ raise HTTPException(404, "File not found")
582
  except Exception as e:
583
+ logger.error(f"File download failed: {file_path} - {e}")
584
+ raise HTTPException(500, "Failed to retrieve file")
585
 
586
  import mimetypes
587
  media_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
588
+ filename = os.path.basename(file_path)
589
+
590
+ return Response(
591
+ content=data,
592
+ media_type=media_type,
593
+ headers={
594
+ "Content-Disposition": f'inline; filename="{filename}"',
595
+ "Cache-Control": "private, max-age=3600"
596
+ }
597
+ )
598
 
599
+ # ------------------------------------------------------------------------
600
+ # WebSocket Connection Manager
601
+ # ------------------------------------------------------------------------
602
  class ConnectionManager:
603
+ """Manage WebSocket connections with per-user tracking."""
604
+
605
  def __init__(self):
606
+ self.active_connections: Dict[int, Dict[str, Any]] = {} # user_id -> {username, connections: set}
607
+
608
+ async def connect(self, websocket: WebSocket, user_id: int, username: str):
609
+ """Add a new connection."""
610
+ await websocket.accept()
611
+
612
+ if user_id not in self.active_connections:
613
+ self.active_connections[user_id] = {
614
+ "username": username,
615
+ "connections": set()
616
+ }
617
+
618
+ self.active_connections[user_id]["connections"].add(websocket)
619
+
620
+ def disconnect(self, websocket: WebSocket, user_id: int):
621
+ """Remove a connection."""
622
+ if user_id in self.active_connections:
623
+ self.active_connections[user_id]["connections"].discard(websocket)
624
+ if not self.active_connections[user_id]["connections"]:
625
+ del self.active_connections[user_id]
626
+
627
+ async def broadcast(self, message: dict, exclude_user_id: Optional[int] = None):
628
+ """Broadcast message to all connected users."""
629
+ for uid, info in self.active_connections.items():
630
+ if uid == exclude_user_id:
631
+ continue
632
+ for ws in list(info["connections"]):
633
+ try:
634
+ await ws.send_json(message)
635
+ except Exception:
636
+ self.disconnect(ws, uid)
637
+
638
+ async def send_to_user(self, user_id: int, message: dict):
639
+ """Send message to a specific user."""
640
+ if user_id in self.active_connections:
641
+ for ws in list(self.active_connections[user_id]["connections"]):
642
  try:
643
+ await ws.send_json(message)
644
  except Exception:
645
+ self.disconnect(ws, user_id)
646
+
647
+ def get_online_users(self) -> List[Dict]:
648
+ """Get list of online users with their info."""
649
+ users = []
650
+ for uid, info in self.active_connections.items():
651
+ users.append({
652
+ "id": uid,
653
+ "username": info["username"]
654
+ })
655
+ return users
656
 
657
+ def is_online(self, user_id: int) -> bool:
658
+ """Check if a user is online."""
659
+ return user_id in self.active_connections
660
 
661
  manager = ConnectionManager()
662
 
663
+ # ------------------------------------------------------------------------
664
+ # WebSocket Endpoint
665
+ # ------------------------------------------------------------------------
666
  @app.websocket("/ws")
667
+ async def websocket_endpoint(websocket: WebSocket, token: str = Query(...)):
668
+ """WebSocket endpoint for real-time messaging."""
669
+ # Authenticate
670
+ user = await authenticate_user(token)
 
 
 
671
  if not user:
672
+ await websocket.close(code=4001, reason="Authentication failed")
673
  return
674
 
675
+ user_id = user['id']
676
+ username = user['username']
677
+ display_name = user['display_name']
678
+ avatar_path = user['avatar_path']
679
+
680
+ # Connect
681
+ await manager.connect(websocket, user_id, username)
682
 
683
+ # Send initial data
684
+ online_users = manager.get_online_users()
685
+ await websocket.send_json({
686
+ "type": "connection_established",
687
+ "user_id": user_id,
688
+ "username": username,
689
+ "display_name": display_name,
690
+ "avatar_path": avatar_path,
691
+ "online_users": online_users
692
+ })
693
 
694
+ # Broadcast user online status
695
+ await manager.broadcast({
696
+ "type": "user_status",
697
+ "user_id": user_id,
698
+ "username": username,
699
+ "display_name": display_name,
700
+ "avatar_path": avatar_path,
701
+ "status": "online"
702
+ }, exclude_user_id=user_id)
703
 
704
  try:
705
  while True:
706
+ data = await websocket.receive_json()
707
+ message_type = data.get("type")
708
 
709
+ # --------------------------------------------------------------------
710
+ # Send Message
711
+ # --------------------------------------------------------------------
712
+ if message_type == "send_message":
713
+ content = data.get("content", "").strip()
714
+ reply_to_id = data.get("reply_to_id")
715
  file_path = data.get("file_path")
716
  file_type = data.get("file_type")
717
  file_name = data.get("file_name")
718
+ file_size = data.get("file_size", 0)
719
+ client_id = data.get("client_id")
720
+
721
+ # Validate content
722
+ if not content and not file_path:
723
+ await websocket.send_json({
724
+ "type": "error",
725
+ "code": "INVALID_MESSAGE",
726
+ "message": "Message must contain text or a file"
727
+ })
728
+ continue
729
 
730
+ # Validate content length
731
+ if len(content) > 10000:
732
+ await websocket.send_json({
733
+ "type": "error",
734
+ "code": "MESSAGE_TOO_LONG",
735
+ "message": "Message must be less than 10,000 characters"
736
+ })
737
+ continue
738
+
739
+ # Validate reply exists
740
+ if reply_to_id:
741
+ db = await get_db()
742
+ try:
743
+ async with db.execute(
744
+ "SELECT id FROM messages WHERE id = ? AND is_deleted = 0",
745
+ (reply_to_id,)
746
+ ) as cursor:
747
+ if not await cursor.fetchone():
748
+ reply_to_id = None
749
+ finally:
750
+ await db.close()
751
+
752
+ timestamp_ms = int(time.time() * 1000)
753
+
754
+ # Encrypt content
755
  encrypted = encrypt_message(content)
756
 
757
+ # Store in database
758
  db = await get_db()
759
  try:
760
+ cursor = await db.execute(
761
+ """INSERT INTO messages
762
+ (sender_id, encrypted_content, timestamp_ms, reply_to_id,
763
+ file_path, file_type, file_name, file_size)
764
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
765
+ (user_id, encrypted, timestamp_ms, reply_to_id,
766
+ file_path, file_type, file_name, file_size)
767
  )
768
+ message_id = cursor.lastrowid
769
  await db.commit()
770
  finally:
771
  await db.close()
772
 
773
+ # Build message object
774
+ message_obj = {
775
  "type": "new_message",
776
+ "message": {
777
+ "id": message_id,
778
+ "sender_id": user_id,
779
+ "sender_username": username,
780
+ "sender_display_name": display_name,
781
+ "sender_avatar_path": avatar_path,
782
+ "content": content,
783
+ "timestamp_ms": timestamp_ms,
784
+ "reply_to_id": reply_to_id,
785
+ "file_path": file_path,
786
+ "file_type": file_type,
787
+ "file_name": file_name,
788
+ "file_size": file_size,
789
+ "is_edited": False,
790
+ "is_deleted": False,
791
+ "status": "sent"
792
+ }
793
  }
794
 
795
+ # Send to sender with delivered status
796
+ await websocket.send_json({
797
+ **message_obj,
798
+ "message": {**message_obj["message"], "status": "delivered"},
799
+ "client_id": client_id
800
+ })
801
+
802
+ # Broadcast to other users
803
+ await manager.broadcast(message_obj, exclude_user_id=user_id)
804
 
805
+ # --------------------------------------------------------------------
806
+ # Load Messages (Cursor-based Pagination)
807
+ # --------------------------------------------------------------------
808
+ elif message_type == "load_messages":
809
+ cursor_id = data.get("cursor") # Message ID to load before
810
+ limit = min(int(data.get("limit", 50)), 100)
811
 
812
  db = await get_db()
813
  try:
814
+ if cursor_id:
815
+ query = """
816
+ SELECT m.*, u.username, u.display_name, u.avatar_path
817
+ FROM messages m
818
+ JOIN users u ON m.sender_id = u.id
819
+ WHERE m.is_deleted = 0 AND m.id < ?
820
+ ORDER BY m.id DESC
821
+ LIMIT ?
822
+ """
823
+ params = (cursor_id, limit)
824
+ else:
825
+ query = """
826
+ SELECT m.*, u.username, u.display_name, u.avatar_path
827
+ FROM messages m
828
+ JOIN users u ON m.sender_id = u.id
829
+ WHERE m.is_deleted = 0
830
+ ORDER BY m.id DESC
831
+ LIMIT ?
832
+ """
833
+ params = (limit,)
834
 
835
+ rows = await (await db.execute(query, params)).fetchall()
 
836
 
837
+ messages = []
838
+ for row in reversed(rows): # Reverse to get chronological order
839
  try:
840
+ decrypted = decrypt_message(row['encrypted_content'])
841
  except Exception:
842
+ decrypted = "[Decryption Error]"
843
 
844
+ # Check read status
845
+ read_status = "sent"
846
+ async with db.execute(
847
+ "SELECT COUNT(*) as count FROM read_receipts WHERE message_id = ?",
848
+ (row['id'],)
849
+ ) as read_cursor:
850
+ read_result = await read_cursor.fetchone()
851
+ if read_result and read_result['count'] > 0:
852
+ read_status = "read"
853
 
854
+ messages.append({
855
+ "id": row['id'],
856
+ "sender_id": row['sender_id'],
857
+ "sender_username": row['username'],
858
+ "sender_display_name": row['display_name'],
859
+ "sender_avatar_path": row['avatar_path'],
860
+ "content": decrypted,
861
+ "timestamp_ms": row['timestamp_ms'],
862
+ "reply_to_id": row['reply_to_id'],
863
+ "file_path": row['file_path'],
864
+ "file_type": row['file_type'],
865
+ "file_name": row['file_name'],
866
+ "file_size": row['file_size'],
867
+ "is_edited": bool(row['is_edited']),
868
+ "is_deleted": False,
869
+ "status": read_status
870
  })
871
 
872
+ # Get the oldest message ID for next page
873
+ next_cursor = None
874
+ if len(messages) > 0:
875
+ next_cursor = messages[0]['id']
876
+
877
+ await websocket.send_json({
878
+ "type": "messages_loaded",
879
+ "messages": messages,
880
+ "next_cursor": next_cursor,
881
+ "has_more": len(messages) == limit
882
+ })
883
  finally:
884
  await db.close()
885
 
886
+ # --------------------------------------------------------------------
887
+ # Edit Message
888
+ # --------------------------------------------------------------------
889
+ elif message_type == "edit_message":
890
+ message_id = data.get("message_id")
891
+ new_content = data.get("content", "").strip()
892
+
893
+ if not message_id or not new_content:
894
+ await websocket.send_json({
895
+ "type": "error",
896
+ "code": "INVALID_EDIT",
897
+ "message": "Message ID and new content are required"
898
+ })
899
+ continue
900
+
901
+ if len(new_content) > 10000:
902
+ await websocket.send_json({
903
+ "type": "error",
904
+ "code": "MESSAGE_TOO_LONG",
905
+ "message": "Message must be less than 10,000 characters"
906
+ })
907
+ continue
908
+
909
  db = await get_db()
910
  try:
911
+ # Verify ownership
912
+ async with db.execute(
913
  "SELECT * FROM messages WHERE id = ? AND sender_id = ? AND is_deleted = 0",
914
+ (message_id, user_id)
915
+ ) as cursor:
916
+ if not await cursor.fetchone():
917
+ await websocket.send_json({
918
+ "type": "error",
919
+ "code": "NOT_FOUND",
920
+ "message": "Message not found or you don't have permission to edit it"
921
+ })
922
+ continue
923
+
924
+ new_encrypted = encrypt_message(new_content)
925
+ await db.execute(
926
+ "UPDATE messages SET encrypted_content = ?, is_edited = 1 WHERE id = ?",
927
+ (new_encrypted, message_id)
928
  )
929
+ await db.commit()
930
+
931
+ # Broadcast edit
932
+ await manager.broadcast({
933
+ "type": "message_edited",
934
+ "message_id": message_id,
935
+ "content": new_content,
936
+ "editor_id": user_id
937
+ })
938
  finally:
939
  await db.close()
940
 
941
+ # --------------------------------------------------------------------
942
+ # Delete Message
943
+ # --------------------------------------------------------------------
944
+ elif message_type == "delete_message":
945
+ message_id = data.get("message_id")
946
+
947
+ if not message_id:
948
+ continue
949
+
950
  db = await get_db()
951
  try:
952
+ # Verify ownership
953
+ async with db.execute(
954
+ "SELECT * FROM messages WHERE id = ? AND sender_id = ?",
955
+ (message_id, user_id)
956
+ ) as cursor:
957
+ message = await cursor.fetchone()
958
+
959
+ if not message:
960
+ await websocket.send_json({
961
+ "type": "error",
962
+ "code": "NOT_FOUND",
963
+ "message": "Message not found or you don't have permission to delete it"
964
+ })
965
+ continue
966
+
967
+ # Delete associated file if exists
968
+ if message['file_path']:
969
+ try:
970
+ delete_file(message['file_path'])
971
+ except Exception:
972
+ pass
973
+
974
+ # Remove message and read receipts
975
+ await db.execute("DELETE FROM messages WHERE id = ?", (message_id,))
976
+ await db.execute("DELETE FROM read_receipts WHERE message_id = ?", (message_id,))
977
+ await db.commit()
978
+
979
+ # Broadcast deletion
980
+ await manager.broadcast({
981
+ "type": "message_deleted",
982
+ "message_id": message_id
983
+ })
984
  finally:
985
  await db.close()
986
 
987
+ # --------------------------------------------------------------------
988
+ # Typing Indicator
989
+ # --------------------------------------------------------------------
990
+ elif message_type == "typing":
991
+ is_typing = bool(data.get("is_typing", False))
992
+
993
+ await manager.broadcast({
994
+ "type": "typing_indicator",
995
+ "user_id": user_id,
996
+ "username": username,
997
+ "display_name": display_name,
998
+ "is_typing": is_typing
999
+ }, exclude_user_id=user_id)
1000
 
1001
+ # --------------------------------------------------------------------
1002
+ # Mark as Read
1003
+ # --------------------------------------------------------------------
1004
+ elif message_type == "mark_read":
1005
+ up_to_message_id = data.get("up_to_message_id")
1006
+
1007
+ if not up_to_message_id:
1008
+ continue
1009
+
1010
+ db = await get_db()
1011
+ try:
1012
+ # Get messages that haven't been read by this user
1013
+ rows = await db.execute(
1014
+ """SELECT m.id, m.sender_id
1015
+ FROM messages m
1016
+ WHERE m.id <= ?
1017
+ AND m.sender_id != ?
1018
+ AND m.is_deleted = 0
1019
+ AND m.id > (
1020
+ SELECT COALESCE(MAX(message_id), 0)
1021
+ FROM read_receipts
1022
+ WHERE user_id = ?
1023
+ )""",
1024
+ (up_to_message_id, user_id, user_id)
1025
+ )
1026
+
1027
+ new_reads = []
1028
+ async for row in rows:
1029
+ await db.execute(
1030
+ "INSERT OR IGNORE INTO read_receipts (user_id, message_id, read_at) VALUES (?, ?, ?)",
1031
+ (user_id, row['id'], int(time.time()))
1032
  )
1033
+ new_reads.append({
1034
+ "message_id": row['id'],
1035
+ "sender_id": row['sender_id']
1036
+ })
1037
+
1038
+ await db.commit()
1039
+
1040
+ # Notify message senders
1041
+ for read_info in new_reads:
1042
+ await manager.send_to_user(read_info['sender_id'], {
1043
+ "type": "message_read",
1044
+ "message_id": read_info['message_id'],
1045
+ "reader_id": user_id,
1046
+ "reader_username": username
1047
+ })
1048
+ finally:
1049
+ await db.close()
1050
+
1051
+ # --------------------------------------------------------------------
1052
+ # Get Online Users
1053
+ # --------------------------------------------------------------------
1054
+ elif message_type == "get_online_users":
1055
+ await websocket.send_json({
1056
+ "type": "online_users",
1057
+ "users": manager.get_online_users()
1058
+ })
1059
 
1060
  except WebSocketDisconnect:
1061
  pass
1062
  except Exception as e:
1063
  logger.error(f"WebSocket error for {username}: {e}")
1064
  finally:
1065
+ # Disconnect
1066
+ manager.disconnect(websocket, user_id)
1067
+
1068
+ # Update status in database
1069
+ db = await get_db()
1070
+ try:
1071
+ await db.execute(
1072
+ "UPDATE users SET status = 'offline', last_seen = ? WHERE id = ?",
1073
+ (int(time.time()), user_id)
1074
+ )
1075
+ await db.commit()
1076
+ finally:
1077
+ await db.close()
1078
+
1079
+ # Broadcast offline status
1080
+ await manager.broadcast({
1081
+ "type": "user_status",
1082
+ "user_id": user_id,
1083
+ "username": username,
1084
+ "display_name": display_name,
1085
+ "avatar_path": avatar_path,
1086
+ "status": "offline"
1087
+ })
1088
+
1089
+ # ------------------------------------------------------------------------
1090
+ # Root endpoint
1091
+ # ------------------------------------------------------------------------
1092
+ @app.get("/")
1093
+ async def root():
1094
+ """Serve the main application."""
1095
+ return FileResponse("static/index.html")