smodusermc commited on
Commit
66fb4d9
·
verified ·
1 Parent(s): acb50d4

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +230 -774
app.py CHANGED
@@ -3,26 +3,23 @@ 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
@@ -31,27 +28,23 @@ 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,
@@ -66,8 +59,6 @@ async def init_database():
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,
@@ -84,8 +75,6 @@ async def init_database():
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,
@@ -96,620 +85,309 @@ async def init_database():
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."""
220
  key, _ = hash_password(password, salt)
221
  return key == 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")
@@ -717,379 +395,157 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(...)):
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")
 
3
  import time
4
  import uuid
5
  import base64
 
 
 
6
  import logging
7
+ from typing import Optional, Dict, Set, List, Any
8
  from contextlib import asynccontextmanager
 
 
9
 
10
+ import aiosqlite
11
+
12
+ from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, Header, Query, UploadFile, File
13
  from fastapi.staticfiles import StaticFiles
14
  from fastapi.responses import Response, JSONResponse, FileResponse
15
  from fastapi.middleware.cors import CORSMiddleware
 
16
 
17
  from cryptography.hazmat.primitives.ciphers.aead import AESGCM
18
  from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
19
  from cryptography.hazmat.primitives import hashes
20
  from cryptography.hazmat.backends import default_backend
21
 
22
+ from storage_handler import store_file, retrieve_file, delete_file, file_exists
23
 
24
  # ------------------------------------------------------------------------
25
  # Configuration
 
28
  MESSAGE_KEY_B64 = os.environ.get("SECRET_KEY", None)
29
  if MESSAGE_KEY_B64 is None:
30
  MESSAGE_KEY_B64 = base64.urlsafe_b64encode(os.urandom(32)).decode()
31
+ print("WARNING: SECRET_KEY not set – generated random key. Set it permanently!")
32
  MESSAGE_KEY = base64.urlsafe_b64decode(MESSAGE_KEY_B64)
33
+ assert len(MESSAGE_KEY) == 32
34
 
35
+ logging.basicConfig(level=logging.INFO)
36
  logger = logging.getLogger("InfinityChat")
37
 
38
  # ------------------------------------------------------------------------
39
+ # Database initialization (once at startup)
40
  # ------------------------------------------------------------------------
41
  async def init_database():
 
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
  await db.execute("PRAGMA synchronous=NORMAL;")
47
+ await db.execute("PRAGMA cache_size=-20000;")
 
 
 
48
  await db.execute("""
49
  CREATE TABLE IF NOT EXISTS users (
50
  id INTEGER PRIMARY KEY AUTOINCREMENT,
 
59
  status TEXT DEFAULT 'offline'
60
  )
61
  """)
 
 
62
  await db.execute("""
63
  CREATE TABLE IF NOT EXISTS messages (
64
  id INTEGER PRIMARY KEY AUTOINCREMENT,
 
75
  FOREIGN KEY(sender_id) REFERENCES users(id)
76
  )
77
  """)
 
 
78
  await db.execute("""
79
  CREATE TABLE IF NOT EXISTS read_receipts (
80
  user_id INTEGER NOT NULL,
 
85
  FOREIGN KEY(message_id) REFERENCES messages(id)
86
  )
87
  """)
88
+ await db.execute("CREATE INDEX IF NOT EXISTS idx_messages_sender_time ON messages(sender_id, timestamp_ms)")
89
+ await db.execute("CREATE INDEX IF NOT EXISTS idx_messages_time_id ON messages(timestamp_ms, id)")
90
+ await db.execute("CREATE INDEX IF NOT EXISTS idx_messages_not_deleted ON messages(is_deleted, id)")
91
+ await db.execute("CREATE INDEX IF NOT EXISTS idx_receipts_user_msg ON read_receipts(user_id, message_id)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  await db.commit()
 
93
  logger.info("✅ Database initialized successfully")
94
 
95
  # ------------------------------------------------------------------------
96
+ # FastAPI lifespan
97
  # ------------------------------------------------------------------------
98
  @asynccontextmanager
99
  async def lifespan(app: FastAPI):
 
100
  await init_database()
101
  yield
102
 
103
+ app = FastAPI(title="InfinityChat", version="1.0.0", lifespan=lifespan)
104
+ app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  app.mount("/static", StaticFiles(directory="static"), name="static")
106
 
107
  # ------------------------------------------------------------------------
108
+ # Database helper (new connection each time)
109
  # ------------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  async def get_db() -> aiosqlite.Connection:
111
+ conn = await aiosqlite.connect(DATABASE_URL)
112
+ conn.row_factory = aiosqlite.Row
113
+ await conn.execute("PRAGMA journal_mode=WAL")
114
+ await conn.execute("PRAGMA foreign_keys=ON")
115
+ return conn
116
 
117
  # ------------------------------------------------------------------------
118
+ # Encryption helpers
119
  # ------------------------------------------------------------------------
120
  def encrypt_message(plaintext: str) -> str:
 
121
  aesgcm = AESGCM(MESSAGE_KEY)
122
  nonce = os.urandom(12)
123
  ciphertext = aesgcm.encrypt(nonce, plaintext.encode('utf-8'), None)
124
  return base64.urlsafe_b64encode(nonce + ciphertext).decode('utf-8')
125
 
126
  def decrypt_message(encrypted_b64: str) -> str:
 
127
  try:
128
  raw = base64.urlsafe_b64decode(encrypted_b64)
129
  nonce, ciphertext = raw[:12], raw[12:]
130
  aesgcm = AESGCM(MESSAGE_KEY)
131
  return aesgcm.decrypt(nonce, ciphertext, None).decode('utf-8')
132
+ except Exception:
 
133
  return "[Decryption Error]"
134
 
135
  # ------------------------------------------------------------------------
136
+ # Password hashing
137
  # ------------------------------------------------------------------------
138
  def hash_password(password: str, salt: Optional[str] = None) -> tuple[str, str]:
 
139
  if salt is None:
140
  salt = os.urandom(32).hex()
141
+ kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt.encode(), iterations=600000, backend=default_backend())
142
+ key = base64.urlsafe_b64encode(kdf.derive(password.encode())).decode()
 
 
 
 
 
 
143
  return key, salt
144
 
145
  def verify_password(password: str, salt: str, stored_hash: str) -> bool:
 
146
  key, _ = hash_password(password, salt)
147
  return key == stored_hash
148
 
149
  def generate_token() -> str:
150
+ return base64.urlsafe_b64encode(os.urandom(32)).decode()
 
151
 
152
  # ------------------------------------------------------------------------
153
+ # Authentication
154
  # ------------------------------------------------------------------------
155
  async def authenticate_user(token: str) -> Optional[dict]:
 
156
  if not token or token in ('null', 'undefined'):
157
  return None
 
158
  db = await get_db()
159
  try:
160
+ cursor = await db.execute("SELECT id, username, display_name, avatar_path, status FROM users WHERE token = ?", (token,))
161
+ user = await cursor.fetchone()
162
+ if user:
163
+ await db.execute("UPDATE users SET last_seen = ? WHERE id = ?", (int(time.time()), user['id']))
164
+ await db.commit()
165
+ return dict(user)
 
 
 
 
 
 
 
 
 
 
166
  return None
167
+ finally:
168
+ await db.close()
169
 
170
  # ------------------------------------------------------------------------
171
  # HTTP Endpoints
172
  # ------------------------------------------------------------------------
173
  @app.get("/api/health")
174
  async def health_check():
175
+ return {"status": "healthy", "timestamp": int(time.time()), "version": "1.0.0"}
 
 
 
 
 
176
 
177
  @app.post("/api/auth/signup")
178
+ async def signup(username: str = Query(...), password: str = Query(...), display_name: str = Query(None)):
 
 
 
 
 
 
179
  if not username.isalnum() and '_' not in username:
180
+ raise HTTPException(400, "Invalid username")
 
181
  db = await get_db()
182
  try:
183
+ async with db.execute("SELECT id FROM users WHERE username = ?", (username.lower(),)) as cursor:
 
 
 
184
  if await cursor.fetchone():
185
+ raise HTTPException(409, "Username taken")
 
 
186
  pwd_hash, salt = hash_password(password)
187
  token = generate_token()
188
  display = display_name or username
189
+ await db.execute(
190
+ "INSERT INTO users (username, display_name, password_hash, salt, token, status) VALUES (?,?,?,?,?, 'online')",
 
 
191
  (username.lower(), display, pwd_hash, salt, token)
192
  )
193
  await db.commit()
194
+ return {"token": token, "user": {"username": username, "display_name": display, "avatar_path": None, "status": "online"}}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  finally:
196
  await db.close()
197
 
198
  @app.post("/api/auth/login")
199
+ async def login(username: str = Query(...), password: str = Query(...)):
 
 
 
 
200
  db = await get_db()
201
  try:
202
+ async with db.execute("SELECT * FROM users WHERE username = ?", (username.lower(),)) as cursor:
 
 
203
  user = await cursor.fetchone()
204
+ if not user or not verify_password(password, user['salt'], user['password_hash']):
205
+ raise HTTPException(401, "Invalid credentials")
 
 
 
 
 
 
 
 
 
206
  token = generate_token()
207
+ await db.execute("UPDATE users SET token = ?, last_seen = ?, status = 'online' WHERE id = ?", (token, int(time.time()), user['id']))
 
 
 
208
  await db.commit()
209
+ return {"token": token, "user": {"id": user['id'], "username": user['username'], "display_name": user['display_name'], "avatar_path": user['avatar_path'], "status": "online"}}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  finally:
211
  await db.close()
212
 
213
  @app.get("/api/auth/verify")
214
  async def verify_token(token: str = Header(..., alias="X-Auth-Token")):
 
215
  user = await authenticate_user(token)
216
+ if not user: raise HTTPException(401)
 
217
  return {"user": user}
218
 
219
  @app.post("/api/auth/logout")
220
  async def logout(token: str = Header(..., alias="X-Auth-Token")):
 
221
  db = await get_db()
222
  try:
223
+ await db.execute("UPDATE users SET token = NULL, status = 'offline', last_seen = ? WHERE token = ?", (int(time.time()), token))
 
 
 
224
  await db.commit()
225
  return {"status": "logged_out"}
226
  finally:
227
  await db.close()
228
 
229
  # ------------------------------------------------------------------------
230
+ # Profile
231
  # ------------------------------------------------------------------------
232
  @app.get("/api/profile")
233
  async def get_profile(token: str = Header(..., alias="X-Auth-Token")):
 
234
  user = await authenticate_user(token)
235
+ if not user: raise HTTPException(401)
 
236
  return {"user": user}
237
 
238
  @app.patch("/api/profile")
239
+ async def update_profile(display_name: str = Query(...), token: str = Header(..., alias="X-Auth-Token")):
 
 
 
 
240
  user = await authenticate_user(token)
241
+ if not user: raise HTTPException(401)
 
 
242
  db = await get_db()
243
  try:
244
+ await db.execute("UPDATE users SET display_name = ? WHERE id = ?", (display_name, user['id']))
245
+ await db.commit()
246
+ user['display_name'] = display_name
 
 
 
 
 
247
  return {"user": user}
248
  finally:
249
  await db.close()
250
 
251
  @app.post("/api/profile/avatar")
252
+ async def upload_avatar(file: UploadFile = File(...), token: str = Header(..., alias="X-Auth-Token")):
 
 
 
 
253
  user = await authenticate_user(token)
254
+ if not user: raise HTTPException(401)
 
 
 
 
 
 
 
 
255
  data = await file.read()
256
+ if len(data) > 5*1024*1024:
257
+ raise HTTPException(400, "Image too large")
 
 
258
  ext = os.path.splitext(file.filename)[1] if file.filename else '.jpg'
259
  remote_path = f"avatars/{user['username']}_{uuid.uuid4().hex}{ext}"
260
+ store_file(remote_path, data)
 
 
 
 
 
 
261
  db = await get_db()
262
  try:
263
+ await db.execute("UPDATE users SET avatar_path = ? WHERE id = ?", (remote_path, user['id']))
 
 
 
264
  await db.commit()
 
265
  finally:
266
  await db.close()
 
267
  return {"avatar_path": remote_path}
268
 
269
  # ------------------------------------------------------------------------
270
+ # Chunked Upload
271
  # ------------------------------------------------------------------------
272
+ upload_sessions: Dict[str, dict] = {}
273
 
274
  @app.post("/api/upload/chunk")
275
+ async def upload_chunk(file: UploadFile = File(...), chunk_index: int = Query(...), total_chunks: int = Query(...),
276
+ file_name: str = Query(...), file_type: str = Query(...), file_size: int = Query(...),
277
+ upload_id: str = Query(...), token: str = Header(..., alias="X-Auth-Token")):
 
 
 
 
 
 
 
 
278
  user = await authenticate_user(token)
279
+ if not user: raise HTTPException(401)
 
 
 
 
 
 
 
280
  if upload_id not in upload_sessions:
281
+ upload_sessions[upload_id] = {"chunks": {}, "filename": file_name, "file_type": file_type,
282
+ "file_size": file_size, "total_chunks": total_chunks, "received": 0,
283
+ "user_id": user['id'], "created_at": time.time()}
 
 
 
 
 
 
 
 
284
  session = upload_sessions[upload_id]
285
+ if session['user_id'] != user['id']: raise HTTPException(403)
 
 
 
 
 
286
  chunk_data = await file.read()
 
 
 
287
  session['chunks'][chunk_index] = chunk_data
288
  session['received'] += 1
 
 
289
  if session['received'] == total_chunks:
290
+ full_data = b"".join(session['chunks'][i] for i in sorted(session['chunks']))
 
 
 
291
  remote_path = f"uploads/{user['username']}/{uuid.uuid4().hex}/{file_name}"
292
+ store_file(remote_path, full_data)
 
 
 
 
 
 
 
 
293
  del upload_sessions[upload_id]
294
+ return {"status": "complete", "file_path": remote_path, "file_name": file_name, "file_type": file_type, "file_size": file_size}
295
+ return {"status": "in_progress", "received": session['received'], "total": total_chunks}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
 
297
  # ------------------------------------------------------------------------
298
  # File Download
299
  # ------------------------------------------------------------------------
300
  @app.get("/api/download/{file_path:path}")
301
+ async def download_file(file_path: str, token: str = Header(..., alias="X-Auth-Token")):
 
 
 
 
302
  user = await authenticate_user(token)
303
+ if not user: raise HTTPException(401)
304
+ if '..' in file_path: raise HTTPException(400)
 
 
 
 
 
305
  try:
306
  data = retrieve_file(file_path)
307
  except FileNotFoundError:
308
+ raise HTTPException(404)
 
 
 
 
309
  import mimetypes
310
+ mt = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
311
+ return Response(content=data, media_type=mt)
 
 
 
 
 
 
 
 
 
312
 
313
  # ------------------------------------------------------------------------
314
+ # WebSocket Manager
315
  # ------------------------------------------------------------------------
316
  class ConnectionManager:
 
 
317
  def __init__(self):
318
+ self.active: Dict[int, Dict[str, Any]] = {}
319
+
320
+ async def connect(self, ws: WebSocket, user_id: int, username: str):
321
+ await ws.accept()
322
+ if user_id not in self.active:
323
+ self.active[user_id] = {"username": username, "connections": set()}
324
+ self.active[user_id]["connections"].add(ws)
325
+
326
+ def disconnect(self, ws: WebSocket, user_id: int):
327
+ if user_id in self.active:
328
+ self.active[user_id]["connections"].discard(ws)
329
+ if not self.active[user_id]["connections"]:
330
+ del self.active[user_id]
331
+
332
+ async def broadcast(self, msg: dict, exclude_user_id: Optional[int] = None):
333
+ for uid, info in list(self.active.items()):
334
+ if uid == exclude_user_id: continue
 
 
 
 
 
 
 
 
 
335
  for ws in list(info["connections"]):
336
  try:
337
+ await ws.send_json(msg)
338
  except Exception:
339
  self.disconnect(ws, uid)
340
+
341
+ async def send_to_user(self, user_id: int, msg: dict):
342
+ if user_id in self.active:
343
+ for ws in list(self.active[user_id]["connections"]):
 
344
  try:
345
+ await ws.send_json(msg)
346
  except Exception:
347
  self.disconnect(ws, user_id)
348
+
349
  def get_online_users(self) -> List[Dict]:
350
+ return [{"id": uid, "username": info["username"]} for uid, info in self.active.items()]
351
+
 
 
 
 
 
 
 
352
  def is_online(self, user_id: int) -> bool:
353
+ return user_id in self.active
 
354
 
355
  manager = ConnectionManager()
356
 
357
  # ------------------------------------------------------------------------
358
+ # WebSocket endpoint
359
  # ------------------------------------------------------------------------
360
  @app.websocket("/ws")
361
+ async def ws_endpoint(ws: WebSocket, token: str = Query(...)):
 
 
362
  user = await authenticate_user(token)
363
  if not user:
364
+ await ws.close(code=4001, reason="Invalid token")
365
  return
366
+
367
+ uid = user['id']
368
  username = user['username']
369
  display_name = user['display_name']
370
  avatar_path = user['avatar_path']
371
+
372
+ await manager.connect(ws, uid, username)
373
+
 
374
  # Send initial data
375
+ await ws.send_json({
 
376
  "type": "connection_established",
377
+ "user_id": uid, "username": username, "display_name": display_name,
378
+ "avatar_path": avatar_path, "online_users": manager.get_online_users()
 
 
 
379
  })
 
 
380
  await manager.broadcast({
381
+ "type": "user_status", "user_id": uid, "username": username,
382
+ "display_name": display_name, "avatar_path": avatar_path, "status": "online"
383
+ }, exclude_user_id=uid)
384
+
 
 
 
 
385
  try:
386
  while True:
387
+ data = await ws.receive_json()
388
+ mtype = data.get("type")
389
+
390
+ if mtype == "send_message":
 
 
 
391
  content = data.get("content", "").strip()
392
  reply_to_id = data.get("reply_to_id")
393
  file_path = data.get("file_path")
 
395
  file_name = data.get("file_name")
396
  file_size = data.get("file_size", 0)
397
  client_id = data.get("client_id")
398
+
 
399
  if not content and not file_path:
400
+ await ws.send_json({"type": "error", "code": "EMPTY", "message": "Message cannot be empty"})
 
 
 
 
401
  continue
 
 
402
  if len(content) > 10000:
403
+ await ws.send_json({"type": "error", "code": "TOO_LONG", "message": "Message too long"})
 
 
 
 
404
  continue
405
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
406
  encrypted = encrypt_message(content)
407
+ ts = int(time.time() * 1000)
408
+
409
  db = await get_db()
410
  try:
411
  cursor = await db.execute(
412
+ "INSERT INTO messages (sender_id, encrypted_content, timestamp_ms, reply_to_id, file_path, file_type, file_name, file_size) VALUES (?,?,?,?,?,?,?,?)",
413
+ (uid, encrypted, ts, reply_to_id, file_path, file_type, file_name, file_size)
 
 
 
 
414
  )
415
+ mid = cursor.lastrowid
416
  await db.commit()
417
  finally:
418
  await db.close()
419
+
420
+ msg_obj = {
421
+ "id": mid, "sender_id": uid, "sender_username": username, "sender_display_name": display_name,
422
+ "sender_avatar_path": avatar_path, "content": content, "timestamp_ms": ts,
423
+ "reply_to_id": reply_to_id, "file_path": file_path, "file_type": file_type,
424
+ "file_name": file_name, "file_size": file_size, "is_edited": False, "is_deleted": False,
425
+ "status": "sent"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
426
  }
427
+
428
+ await ws.send_json({"type": "new_message", "message": {**msg_obj, "status": "delivered"}, "client_id": client_id})
429
+ await manager.broadcast({"type": "new_message", "message": msg_obj}, exclude_user_id=uid)
430
+
431
+ elif mtype == "load_messages":
432
+ cursor_id = data.get("cursor")
433
+ limit = min(data.get("limit", 50), 100)
 
 
 
 
 
 
 
 
 
 
 
434
  db = await get_db()
435
  try:
436
  if cursor_id:
437
+ rows = await db.execute(
438
+ """SELECT m.*, u.username, u.display_name, u.avatar_path
439
+ FROM messages m JOIN users u ON m.sender_id = u.id
440
+ WHERE m.is_deleted = 0 AND m.id < ? ORDER BY m.id DESC LIMIT ?""",
441
+ (cursor_id, limit)
442
+ )
 
 
 
443
  else:
444
+ rows = await db.execute(
445
+ """SELECT m.*, u.username, u.display_name, u.avatar_path
446
+ FROM messages m JOIN users u ON m.sender_id = u.id
447
+ WHERE m.is_deleted = 0 ORDER BY m.id DESC LIMIT ?""",
448
+ (limit,)
449
+ )
450
+ msgs = []
451
+ for row in reversed(await rows.fetchall()):
 
 
 
 
 
 
452
  try:
453
  decrypted = decrypt_message(row['encrypted_content'])
454
  except Exception:
455
  decrypted = "[Decryption Error]"
456
+ read_cursor = await db.execute("SELECT COUNT(*) as cnt FROM read_receipts WHERE message_id = ?", (row['id'],))
457
+ read_count = (await read_cursor.fetchone())['cnt']
458
+ msgs.append({
459
+ "id": row['id'], "sender_id": row['sender_id'], "sender_username": row['username'],
460
+ "sender_display_name": row['display_name'], "sender_avatar_path": row['avatar_path'],
461
+ "content": decrypted, "timestamp_ms": row['timestamp_ms'], "reply_to_id": row['reply_to_id'],
462
+ "file_path": row['file_path'], "file_type": row['file_type'], "file_name": row['file_name'],
463
+ "file_size": row['file_size'], "is_edited": bool(row['is_edited']), "is_deleted": False,
464
+ "status": "read" if read_count > 0 else "sent"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
465
  })
466
+ next_cursor = msgs[0]['id'] if msgs else None
467
+ await ws.send_json({"type": "messages_loaded", "messages": msgs, "next_cursor": next_cursor, "has_more": len(msgs) == limit})
 
 
 
 
 
 
 
 
 
 
468
  finally:
469
  await db.close()
470
+
471
+ elif mtype == "edit_message":
472
+ mid = data.get("message_id")
 
 
 
473
  new_content = data.get("content", "").strip()
474
+ if not mid or not new_content: continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
475
  db = await get_db()
476
  try:
477
+ async with db.execute("SELECT * FROM messages WHERE id = ? AND sender_id = ? AND is_deleted = 0", (mid, uid)) as c:
478
+ if not await c.fetchone():
479
+ await ws.send_json({"type": "error", "code": "NOT_FOUND", "message": "Message not found"})
 
 
 
 
 
 
 
 
480
  continue
481
+ new_enc = encrypt_message(new_content)
482
+ await db.execute("UPDATE messages SET encrypted_content = ?, is_edited = 1 WHERE id = ?", (new_enc, mid))
 
 
 
 
483
  await db.commit()
484
+ await manager.broadcast({"type": "message_edited", "message_id": mid, "content": new_content, "editor_id": uid})
 
 
 
 
 
 
 
485
  finally:
486
  await db.close()
487
+
488
+ elif mtype == "delete_message":
489
+ mid = data.get("message_id")
490
+ if not mid: continue
 
 
 
 
 
 
491
  db = await get_db()
492
  try:
493
+ async with db.execute("SELECT * FROM messages WHERE id = ? AND sender_id = ?", (mid, uid)) as c:
494
+ msg = await c.fetchone()
495
+ if not msg:
496
+ await ws.send_json({"type": "error", "code": "NOT_FOUND", "message": "Not found"})
 
 
 
 
 
 
 
 
 
497
  continue
498
+ if msg['file_path']:
499
+ try: delete_file(msg['file_path'])
500
+ except: pass
501
+ await db.execute("DELETE FROM messages WHERE id = ?", (mid,))
502
+ await db.execute("DELETE FROM read_receipts WHERE message_id = ?", (mid,))
 
 
 
 
 
 
503
  await db.commit()
504
+ await manager.broadcast({"type": "message_deleted", "message_id": mid})
 
 
 
 
 
505
  finally:
506
  await db.close()
507
+
508
+ elif mtype == "typing":
509
+ await manager.broadcast({"type": "typing_indicator", "user_id": uid, "username": username, "display_name": display_name, "is_typing": bool(data.get("is_typing", False))}, exclude_user_id=uid)
510
+
511
+ elif mtype == "mark_read":
512
+ up_to = data.get("up_to_message_id")
513
+ if not up_to: continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
514
  db = await get_db()
515
  try:
 
516
  rows = await db.execute(
517
+ """SELECT m.id, m.sender_id FROM messages m
518
+ WHERE m.id <= ? AND m.sender_id != ? AND m.is_deleted = 0
519
+ AND m.id NOT IN (SELECT message_id FROM read_receipts WHERE user_id = ?)""",
520
+ (up_to, uid, uid)
 
 
 
 
 
 
 
521
  )
 
522
  new_reads = []
523
+ async for r in rows:
524
+ await db.execute("INSERT OR IGNORE INTO read_receipts (user_id, message_id) VALUES (?,?)", (uid, r['id']))
525
+ new_reads.append(r)
 
 
 
 
 
 
 
526
  await db.commit()
527
+ for r in new_reads:
528
+ await manager.send_to_user(r['sender_id'], {"type": "message_read", "message_id": r['id'], "reader_username": username})
 
 
 
 
 
 
 
529
  finally:
530
  await db.close()
531
+
532
+ elif mtype == "get_online_users":
533
+ await ws.send_json({"type": "online_users", "users": manager.get_online_users()})
534
+
 
 
 
 
 
 
535
  except WebSocketDisconnect:
536
  pass
537
  except Exception as e:
538
+ logger.error(f"WS error for {username}: {e}")
539
  finally:
540
+ manager.disconnect(ws, uid)
 
 
 
541
  db = await get_db()
542
  try:
543
+ await db.execute("UPDATE users SET status = 'offline', last_seen = ? WHERE id = ?", (int(time.time()), uid))
 
 
 
544
  await db.commit()
545
  finally:
546
  await db.close()
547
+ await manager.broadcast({"type": "user_status", "user_id": uid, "username": username, "display_name": display_name, "status": "offline"})
 
 
 
 
 
 
 
 
 
548
 
 
 
 
549
  @app.get("/")
550
  async def root():
 
551
  return FileResponse("static/index.html")