smodusermc commited on
Commit
d221625
·
verified ·
1 Parent(s): d820d9d

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +1206 -200
app.py CHANGED
@@ -1,4 +1,5 @@
1
  import os
 
2
  import json
3
  import time
4
  import uuid
@@ -6,14 +7,15 @@ import base64
6
  import logging
7
  import tempfile
8
  import shutil
9
- from typing import Optional, Dict, List, Any
 
10
  from contextlib import asynccontextmanager
11
 
12
  import aiosqlite
13
 
14
  from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, Header, Query, UploadFile, File
15
  from fastapi.staticfiles import StaticFiles
16
- from fastapi.responses import Response, JSONResponse, FileResponse, StreamingResponse
17
  from fastapi.middleware.cors import CORSMiddleware
18
 
19
  from cryptography.hazmat.primitives.ciphers.aead import AESGCM
@@ -22,38 +24,63 @@ from cryptography.hazmat.primitives import hashes
22
  from cryptography.hazmat.backends import default_backend
23
 
24
  from storage_handler import (
25
- store_file, retrieve_file, delete_file, file_exists,
26
  download_database, upload_database, start_db_sync
27
  )
28
 
29
  # ------------------------------------------------------------------------
30
  # Configuration
31
  # ------------------------------------------------------------------------
 
32
  DATABASE_URL = os.environ.get("DATABASE_URL", "/data/infinitychat.db")
33
  MESSAGE_KEY_B64 = os.environ.get("SECRET_KEY", None)
34
-
35
- # GIVE ME THE KEYS NOW!!!!
36
- print(MESSAGE_KEY_B64)
37
  FILE_ENCRYPTION_KEY_B64 = os.environ.get("FILE_ENCRYPTION_KEY", None)
38
- print(FILE_ENCRYPTION_KEY_B64)
39
- # end
 
40
 
41
  if MESSAGE_KEY_B64 is None:
42
  MESSAGE_KEY_B64 = base64.urlsafe_b64encode(os.urandom(32)).decode()
43
- print("WARNING: SECRET_KEY not set – generated random key. Set it permanently!")
44
- MESSAGE_KEY = base64.urlsafe_b64decode(MESSAGE_KEY_B64)
45
- assert len(MESSAGE_KEY) == 32
 
 
 
 
 
 
46
 
47
  # Temp directory for chunk assembly - uses disk not RAM
48
  TEMP_DIR = os.environ.get("TEMP_DIR", "/tmp/infinitychat_uploads")
49
  os.makedirs(TEMP_DIR, exist_ok=True)
50
 
51
- logging.basicConfig(level=logging.INFO)
52
- logger = logging.getLogger("InfinityChat")
 
 
 
 
 
 
53
 
54
  # ------------------------------------------------------------------------
55
- # Database initialization
56
  # ------------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  async def init_database():
58
  os.makedirs(os.path.dirname(DATABASE_URL), exist_ok=True)
59
  db_existed = download_database(DATABASE_URL)
@@ -68,6 +95,9 @@ async def init_database():
68
  await db.execute("PRAGMA foreign_keys=ON;")
69
  await db.execute("PRAGMA synchronous=NORMAL;")
70
  await db.execute("PRAGMA cache_size=-20000;")
 
 
 
71
  await db.execute("""
72
  CREATE TABLE IF NOT EXISTS users (
73
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -95,6 +125,8 @@ async def init_database():
95
  file_type TEXT,
96
  file_name TEXT,
97
  file_size INTEGER DEFAULT 0,
 
 
98
  FOREIGN KEY(sender_id) REFERENCES users(id)
99
  )
100
  """)
@@ -108,19 +140,99 @@ async def init_database():
108
  FOREIGN KEY(message_id) REFERENCES messages(id)
109
  )
110
  """)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  await db.execute("CREATE INDEX IF NOT EXISTS idx_messages_sender_time ON messages(sender_id, timestamp_ms)")
112
  await db.execute("CREATE INDEX IF NOT EXISTS idx_messages_time_id ON messages(timestamp_ms, id)")
113
  await db.execute("CREATE INDEX IF NOT EXISTS idx_messages_not_deleted ON messages(is_deleted, id)")
 
 
 
114
  await db.execute("CREATE INDEX IF NOT EXISTS idx_receipts_user_msg ON read_receipts(user_id, message_id)")
 
 
 
 
115
  await db.commit()
116
 
117
  upload_database(DATABASE_URL)
118
  start_db_sync(DATABASE_URL)
119
- logger.info("✅ Database initialized successfully")
120
 
121
  # Clean up any leftover temp upload dirs from previous runs
122
  _cleanup_temp_dir()
123
 
 
124
  # ------------------------------------------------------------------------
125
  # FastAPI lifespan
126
  # ------------------------------------------------------------------------
@@ -132,16 +244,18 @@ async def lifespan(app: FastAPI):
132
  upload_database(DATABASE_URL)
133
  _cleanup_temp_dir()
134
 
135
- app = FastAPI(title="InfinityChat", version="1.0.0", lifespan=lifespan)
 
136
  app.add_middleware(
137
  CORSMiddleware,
138
  allow_origins=["*"],
139
- allow_credentials=True,
140
  allow_methods=["*"],
141
  allow_headers=["*"]
142
  )
143
  app.mount("/static", StaticFiles(directory="static"), name="static")
144
 
 
145
  # ------------------------------------------------------------------------
146
  # Temp directory cleanup
147
  # ------------------------------------------------------------------------
@@ -158,6 +272,7 @@ def _cleanup_temp_dir():
158
  except Exception as e:
159
  logger.warning(f"Temp cleanup error: {e}")
160
 
 
161
  # ------------------------------------------------------------------------
162
  # Database helper
163
  # ------------------------------------------------------------------------
@@ -166,12 +281,15 @@ async def get_db() -> aiosqlite.Connection:
166
  conn.row_factory = aiosqlite.Row
167
  await conn.execute("PRAGMA journal_mode=WAL")
168
  await conn.execute("PRAGMA foreign_keys=ON")
 
169
  return conn
170
 
 
171
  def schedule_db_sync():
172
  import threading
173
  threading.Thread(target=upload_database, args=(DATABASE_URL,), daemon=True).start()
174
 
 
175
  # ------------------------------------------------------------------------
176
  # Encryption helpers (AES-256-GCM)
177
  # ------------------------------------------------------------------------
@@ -181,6 +299,7 @@ def encrypt_message(plaintext: str) -> str:
181
  ciphertext = aesgcm.encrypt(nonce, plaintext.encode('utf-8'), None)
182
  return base64.urlsafe_b64encode(nonce + ciphertext).decode('utf-8')
183
 
 
184
  def decrypt_message(encrypted_b64: str) -> str:
185
  try:
186
  raw = base64.urlsafe_b64decode(encrypted_b64)
@@ -190,10 +309,11 @@ def decrypt_message(encrypted_b64: str) -> str:
190
  except Exception:
191
  return "[Decryption Error]"
192
 
 
193
  # ------------------------------------------------------------------------
194
  # Password hashing (PBKDF2)
195
  # ------------------------------------------------------------------------
196
- def hash_password(password: str, salt: Optional[str] = None) -> tuple[str, str]:
197
  if salt is None:
198
  salt = os.urandom(32).hex()
199
  kdf = PBKDF2HMAC(
@@ -206,13 +326,16 @@ def hash_password(password: str, salt: Optional[str] = None) -> tuple[str, str]:
206
  key = base64.urlsafe_b64encode(kdf.derive(password.encode())).decode()
207
  return key, salt
208
 
 
209
  def verify_password(password: str, salt: str, stored_hash: str) -> bool:
210
  key, _ = hash_password(password, salt)
211
  return key == stored_hash
212
 
 
213
  def generate_token() -> str:
214
  return base64.urlsafe_b64encode(os.urandom(32)).decode()
215
 
 
216
  # ------------------------------------------------------------------------
217
  # Authentication
218
  # ------------------------------------------------------------------------
@@ -237,12 +360,290 @@ async def authenticate_user(token: str) -> Optional[dict]:
237
  finally:
238
  await db.close()
239
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  # ------------------------------------------------------------------------
241
  # HTTP Endpoints
242
  # ------------------------------------------------------------------------
243
  @app.get("/api/health")
244
  async def health_check():
245
- return {"status": "healthy", "timestamp": int(time.time()), "version": "1.0.0"}
 
246
 
247
  @app.post("/api/auth/signup")
248
  async def signup(
@@ -260,16 +661,19 @@ async def signup(
260
  pwd_hash, salt = hash_password(password)
261
  token = generate_token()
262
  display = display_name or username
263
- await db.execute(
264
- "INSERT INTO users (username, display_name, password_hash, salt, token, status) VALUES (?,?,?,?,?,'online')",
 
265
  (username.lower(), display, pwd_hash, salt, token)
266
  )
 
 
267
  await db.commit()
268
  schedule_db_sync()
269
  return {
270
  "token": token,
271
  "user": {
272
- "id": None,
273
  "username": username.lower(),
274
  "display_name": display,
275
  "avatar_path": None,
@@ -279,6 +683,7 @@ async def signup(
279
  finally:
280
  await db.close()
281
 
 
282
  @app.post("/api/auth/login")
283
  async def login(username: str = Query(...), password: str = Query(...)):
284
  db = await get_db()
@@ -292,6 +697,7 @@ async def login(username: str = Query(...), password: str = Query(...)):
292
  "UPDATE users SET token = ?, last_seen = ?, status = 'online' WHERE id = ?",
293
  (token, int(time.time()), user['id'])
294
  )
 
295
  await db.commit()
296
  schedule_db_sync()
297
  return {
@@ -307,6 +713,7 @@ async def login(username: str = Query(...), password: str = Query(...)):
307
  finally:
308
  await db.close()
309
 
 
310
  @app.get("/api/auth/verify")
311
  async def verify_token(token: str = Header(..., alias="X-Auth-Token")):
312
  user = await authenticate_user(token)
@@ -314,20 +721,27 @@ async def verify_token(token: str = Header(..., alias="X-Auth-Token")):
314
  raise HTTPException(401)
315
  return {"user": user}
316
 
 
317
  @app.post("/api/auth/logout")
318
  async def logout(token: str = Header(..., alias="X-Auth-Token")):
319
  db = await get_db()
320
  try:
 
 
321
  await db.execute(
322
  "UPDATE users SET token = NULL, status = 'offline', last_seen = ? WHERE token = ?",
323
  (int(time.time()), token)
324
  )
325
  await db.commit()
326
  schedule_db_sync()
 
 
 
327
  return {"status": "logged_out"}
328
  finally:
329
  await db.close()
330
 
 
331
  # ------------------------------------------------------------------------
332
  # Profile
333
  # ------------------------------------------------------------------------
@@ -338,9 +752,10 @@ async def get_profile(token: str = Header(..., alias="X-Auth-Token")):
338
  raise HTTPException(401)
339
  return {"user": user}
340
 
 
341
  @app.patch("/api/profile")
342
  async def update_profile(
343
- display_name: str = Query(...),
344
  token: str = Header(..., alias="X-Auth-Token")
345
  ):
346
  user = await authenticate_user(token)
@@ -350,15 +765,66 @@ async def update_profile(
350
  try:
351
  await db.execute(
352
  "UPDATE users SET display_name = ? WHERE id = ?",
353
- (display_name, user['id'])
354
  )
355
  await db.commit()
356
  schedule_db_sync()
357
- user['display_name'] = display_name
358
  return {"user": user}
359
  finally:
360
  await db.close()
361
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
362
  @app.post("/api/profile/avatar")
363
  async def upload_avatar(
364
  file: UploadFile = File(...),
@@ -369,12 +835,14 @@ async def upload_avatar(
369
  raise HTTPException(401)
370
 
371
  # Write to temp file on disk first, not RAM
372
- suffix = os.path.splitext(file.filename)[1] if file.filename else '.jpg'
373
- with tempfile.NamedTemporaryFile(delete=False, suffix=suffix, dir=TEMP_DIR) as tmp:
374
  tmp_path = tmp.name
375
  size = 0
 
376
  chunk = await file.read(65536)
377
  while chunk:
 
 
378
  size += len(chunk)
379
  if size > 5 * 1024 * 1024:
380
  os.unlink(tmp_path)
@@ -382,11 +850,27 @@ async def upload_avatar(
382
  tmp.write(chunk)
383
  chunk = await file.read(65536)
384
 
 
 
 
 
 
 
385
  try:
386
  with open(tmp_path, 'rb') as f:
387
  data = f.read()
388
- remote_path = f"avatars/{user['username']}_{uuid.uuid4().hex}{suffix}"
389
- store_file(remote_path, data)
 
 
 
 
 
 
 
 
 
 
390
  finally:
391
  os.unlink(tmp_path)
392
 
@@ -402,12 +886,276 @@ async def upload_avatar(
402
  await db.close()
403
  return {"avatar_path": remote_path}
404
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
405
  # ------------------------------------------------------------------------
406
  # Chunked Upload - assembles chunks on DISK not RAM
407
  # ------------------------------------------------------------------------
408
- # Tracks active uploads: upload_id -> metadata dict (no chunk data in RAM)
409
  upload_sessions: Dict[str, dict] = {}
410
 
 
411
  @app.post("/api/upload/chunk")
412
  async def upload_chunk(
413
  file: UploadFile = File(...),
@@ -423,10 +1171,19 @@ async def upload_chunk(
423
  if not user:
424
  raise HTTPException(401)
425
 
426
- # Validate file size limit (500MB)
427
- MAX_FILE_SIZE = 500 * 1024 * 1024
428
  if file_size > MAX_FILE_SIZE:
429
  raise HTTPException(400, "File too large (max 500MB)")
 
 
 
 
 
 
 
 
 
 
 
430
 
431
  # Create session with temp dir on disk
432
  if upload_id not in upload_sessions:
@@ -446,11 +1203,14 @@ async def upload_chunk(
446
  session = upload_sessions[upload_id]
447
  if session['user_id'] != user['id']:
448
  raise HTTPException(403)
 
 
 
 
449
 
450
  # Write chunk directly to disk
451
  chunk_path = os.path.join(session['session_dir'], f"chunk_{chunk_index:06d}")
452
  with open(chunk_path, 'wb') as f:
453
- # Stream chunk to disk in 64KB pieces to avoid RAM spike
454
  data = await file.read(65536)
455
  while data:
456
  f.write(data)
@@ -459,35 +1219,28 @@ async def upload_chunk(
459
  session['received_chunks'].add(chunk_index)
460
 
461
  if len(session['received_chunks']) == total_chunks:
462
- # All chunks received - assemble on disk then stream to bucket
463
  remote_path = f"uploads/{user['username']}/{uuid.uuid4().hex}/{file_name}"
464
  session_dir = session['session_dir']
465
 
466
  try:
467
- # Assemble chunks into single temp file on disk
468
  assembled_path = os.path.join(session_dir, "assembled")
469
  with open(assembled_path, 'wb') as out_f:
470
  for i in range(total_chunks):
471
  chunk_file = os.path.join(session_dir, f"chunk_{i:06d}")
472
  with open(chunk_file, 'rb') as in_f:
473
- # Copy in 1MB pieces
474
  buf = in_f.read(1024 * 1024)
475
  while buf:
476
  out_f.write(buf)
477
  buf = in_f.read(1024 * 1024)
478
- os.unlink(chunk_file) # Delete chunk immediately after use
479
 
480
- # Read assembled file and store to bucket
481
- # Note: this does load into RAM once for encryption
482
- # For very large files this is unavoidable with AES-GCM
483
- # as it needs to process the whole file
484
  with open(assembled_path, 'rb') as f:
485
  full_data = f.read()
486
- store_file(remote_path, full_data)
487
- del full_data # Explicitly free RAM immediately
 
488
 
489
  finally:
490
- # Always clean up temp dir
491
  shutil.rmtree(session_dir, ignore_errors=True)
492
  del upload_sessions[upload_id]
493
 
@@ -505,12 +1258,10 @@ async def upload_chunk(
505
  "total": total_chunks
506
  }
507
 
 
508
  # ------------------------------------------------------------------------
509
  # File Download - streams from bucket to client in chunks
510
- # avoids loading entire file into RAM
511
  # ------------------------------------------------------------------------
512
- DOWNLOAD_CHUNK_SIZE = 1024 * 1024 # 1MB streaming chunks
513
-
514
  @app.get("/api/download/{file_path:path}")
515
  async def download_file(
516
  file_path: str,
@@ -521,16 +1272,13 @@ async def download_file(
521
  user = await authenticate_user(auth_token)
522
  if not user:
523
  raise HTTPException(401)
524
- if '..' in file_path:
525
  raise HTTPException(400, "Invalid path")
526
 
527
  try:
528
- # retrieve_file decrypts and returns bytes
529
- # For true streaming we'd need a streaming decrypt but AES-GCM
530
- # requires the full ciphertext to verify the auth tag before
531
- # decrypting - so one full read is required for security.
532
- # We do however stream the response TO the client in chunks.
533
- data = retrieve_file(file_path)
534
  except FileNotFoundError:
535
  raise HTTPException(404, "File not found")
536
  except Exception as e:
@@ -541,8 +1289,9 @@ async def download_file(
541
  mt = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
542
  file_size = len(data)
543
 
544
- # Stream response to client in chunks to avoid keeping
545
- # large response in RAM on the server side
 
546
  def iter_data():
547
  offset = 0
548
  while offset < len(data):
@@ -555,10 +1304,37 @@ async def download_file(
555
  headers={
556
  "Content-Length": str(file_size),
557
  "Cache-Control": "private, max-age=3600",
558
- "Content-Disposition": f'inline; filename="{os.path.basename(file_path)}"'
559
  }
560
  )
561
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
562
  # ------------------------------------------------------------------------
563
  # WebSocket Manager
564
  # ------------------------------------------------------------------------
@@ -566,10 +1342,19 @@ class ConnectionManager:
566
  def __init__(self):
567
  self.active: Dict[int, Dict[str, Any]] = {}
568
 
569
- async def connect(self, ws: WebSocket, user_id: int, username: str):
 
570
  await ws.accept()
571
  if user_id not in self.active:
572
- self.active[user_id] = {"username": username, "connections": set()}
 
 
 
 
 
 
 
 
573
  self.active[user_id]["connections"].add(ws)
574
 
575
  def disconnect(self, ws: WebSocket, user_id: int):
@@ -578,6 +1363,20 @@ class ConnectionManager:
578
  if not self.active[user_id]["connections"]:
579
  del self.active[user_id]
580
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
581
  async def broadcast(self, msg: dict, exclude_user_id: Optional[int] = None):
582
  for uid, info in list(self.active.items()):
583
  if uid == exclude_user_id:
@@ -588,25 +1387,61 @@ class ConnectionManager:
588
  except Exception:
589
  self.disconnect(ws, uid)
590
 
591
- async def send_to_user(self, user_id: int, msg: dict):
592
- if user_id in self.active:
593
- for ws in list(self.active[user_id]["connections"]):
594
- try:
595
- await ws.send_json(msg)
596
- except Exception:
597
- self.disconnect(ws, user_id)
598
-
599
  def get_online_users(self) -> List[Dict]:
600
  return [
601
- {"id": uid, "username": info["username"]}
 
 
 
 
 
602
  for uid, info in self.active.items()
603
  ]
604
 
605
  def is_online(self, user_id: int) -> bool:
606
  return user_id in self.active
607
 
 
 
 
 
 
 
 
 
 
 
 
608
  manager = ConnectionManager()
609
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
610
  # ------------------------------------------------------------------------
611
  # WebSocket endpoint
612
  # ------------------------------------------------------------------------
@@ -622,7 +1457,22 @@ async def ws_endpoint(ws: WebSocket, token: str = Query(...)):
622
  display_name = user['display_name']
623
  avatar_path = user['avatar_path']
624
 
625
- await manager.connect(ws, uid, username)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
626
 
627
  await ws.send_json({
628
  "type": "connection_established",
@@ -630,7 +1480,9 @@ async def ws_endpoint(ws: WebSocket, token: str = Query(...)):
630
  "username": username,
631
  "display_name": display_name,
632
  "avatar_path": avatar_path,
633
- "online_users": manager.get_online_users()
 
 
634
  })
635
 
636
  await manager.broadcast({
@@ -647,135 +1499,215 @@ async def ws_endpoint(ws: WebSocket, token: str = Query(...)):
647
  data = await ws.receive_json()
648
  mtype = data.get("type")
649
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
650
  if mtype == "send_message":
651
- content = data.get("content", "").strip()
 
 
 
 
 
 
652
  reply_to_id = data.get("reply_to_id")
 
653
  file_path = data.get("file_path")
654
  file_type = data.get("file_type")
655
  file_name = data.get("file_name")
656
  file_size = data.get("file_size", 0)
657
- client_id = data.get("client_id")
658
 
659
  if not content and not file_path:
660
- await ws.send_json({"type": "error", "code": "EMPTY", "message": "Message cannot be empty"})
 
661
  continue
662
- if len(content) > 10000:
663
- await ws.send_json({"type": "error", "code": "TOO_LONG", "message": "Message too long"})
 
664
  continue
665
 
666
- encrypted = encrypt_message(content)
667
- ts = int(time.time() * 1000)
668
-
669
  db = await get_db()
670
  try:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
671
  cursor = await db.execute(
672
  """INSERT INTO messages
673
  (sender_id, encrypted_content, timestamp_ms, reply_to_id,
674
- file_path, file_type, file_name, file_size)
675
- VALUES (?,?,?,?,?,?,?,?)""",
676
- (uid, encrypted, ts, reply_to_id, file_path, file_type, file_name, file_size)
 
 
677
  )
678
  mid = cursor.lastrowid
679
  await db.commit()
680
  schedule_db_sync()
681
- finally:
682
- await db.close()
683
-
684
- msg_obj = {
685
- "id": mid,
686
- "sender_id": uid,
687
- "sender_username": username,
688
- "sender_display_name": display_name,
689
- "sender_avatar_path": avatar_path,
690
- "content": content,
691
- "timestamp_ms": ts,
692
- "reply_to_id": reply_to_id,
693
- "file_path": file_path,
694
- "file_type": file_type,
695
- "file_name": file_name,
696
- "file_size": file_size,
697
- "is_edited": False,
698
- "is_deleted": False,
699
- "status": "sent"
700
- }
701
 
702
- await ws.send_json({
703
- "type": "new_message",
704
- "message": {**msg_obj, "status": "delivered"},
705
- "client_id": client_id
706
- })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
707
 
708
- await manager.broadcast({
709
- "type": "new_message",
710
- "message": msg_obj
711
- }, exclude_user_id=uid)
 
 
712
 
713
- elif mtype == "load_messages":
 
 
714
  cursor_id = data.get("cursor")
715
- limit = min(data.get("limit", 50), 100)
716
  db = await get_db()
717
  try:
 
 
 
 
718
  if cursor_id:
719
  rows = await db.execute(
720
  """SELECT m.*, u.username, u.display_name, u.avatar_path
721
  FROM messages m JOIN users u ON m.sender_id = u.id
722
- WHERE m.is_deleted = 0 AND m.id < ?
723
  ORDER BY m.id DESC LIMIT ?""",
724
- (cursor_id, limit)
725
  )
726
  else:
727
  rows = await db.execute(
728
  """SELECT m.*, u.username, u.display_name, u.avatar_path
729
  FROM messages m JOIN users u ON m.sender_id = u.id
730
- WHERE m.is_deleted = 0
731
  ORDER BY m.id DESC LIMIT ?""",
732
- (limit,)
733
  )
734
 
735
  msgs = []
736
  for row in reversed(await rows.fetchall()):
737
- try:
738
- decrypted = decrypt_message(row['encrypted_content'])
739
- except Exception:
740
- decrypted = "[Decryption Error]"
741
-
742
- read_cursor = await db.execute(
743
- "SELECT COUNT(*) as cnt FROM read_receipts WHERE message_id = ?",
744
- (row['id'],)
745
- )
746
- read_count = (await read_cursor.fetchone())['cnt']
747
-
748
- msgs.append({
749
- "id": row['id'],
750
- "sender_id": row['sender_id'],
751
- "sender_username": row['username'],
752
- "sender_display_name": row['display_name'],
753
- "sender_avatar_path": row['avatar_path'],
754
- "content": decrypted,
755
- "timestamp_ms": row['timestamp_ms'],
756
- "reply_to_id": row['reply_to_id'],
757
- "file_path": row['file_path'],
758
- "file_type": row['file_type'],
759
- "file_name": row['file_name'],
760
- "file_size": row['file_size'],
761
- "is_edited": bool(row['is_edited']),
762
- "is_deleted": False,
763
- "status": "read" if read_count > 0 else "sent"
764
- })
765
 
 
766
  next_cursor = msgs[0]['id'] if msgs else None
767
  await ws.send_json({
768
  "type": "messages_loaded",
 
769
  "messages": msgs,
770
  "next_cursor": next_cursor,
771
  "has_more": len(msgs) == limit
772
  })
773
  finally:
774
  await db.close()
 
775
 
776
- elif mtype == "edit_message":
 
777
  mid = data.get("message_id")
778
- new_content = data.get("content", "").strip()
779
  if not mid or not new_content:
780
  continue
781
  db = await get_db()
@@ -784,9 +1716,11 @@ async def ws_endpoint(ws: WebSocket, token: str = Query(...)):
784
  "SELECT * FROM messages WHERE id = ? AND sender_id = ? AND is_deleted = 0",
785
  (mid, uid)
786
  ) as cursor:
787
- if not await cursor.fetchone():
788
- await ws.send_json({"type": "error", "code": "NOT_FOUND", "message": "Message not found"})
789
- continue
 
 
790
  new_enc = encrypt_message(new_content)
791
  await db.execute(
792
  "UPDATE messages SET encrypted_content = ?, is_edited = 1 WHERE id = ?",
@@ -794,16 +1728,22 @@ async def ws_endpoint(ws: WebSocket, token: str = Query(...)):
794
  )
795
  await db.commit()
796
  schedule_db_sync()
797
- await manager.broadcast({
798
  "type": "message_edited",
799
  "message_id": mid,
 
800
  "content": new_content,
801
- "editor_id": uid
802
- })
 
 
 
803
  finally:
804
  await db.close()
 
805
 
806
- elif mtype == "delete_message":
 
807
  mid = data.get("message_id")
808
  if not mid:
809
  continue
@@ -813,95 +1753,161 @@ async def ws_endpoint(ws: WebSocket, token: str = Query(...)):
813
  "SELECT * FROM messages WHERE id = ? AND sender_id = ?",
814
  (mid, uid)
815
  ) as cursor:
816
- msg = await cursor.fetchone()
817
- if not msg:
818
- await ws.send_json({"type": "error", "code": "NOT_FOUND", "message": "Message not found"})
 
819
  continue
820
- if msg['file_path']:
821
  try:
822
- delete_file(msg['file_path'])
823
  except Exception:
824
  pass
825
  await db.execute("DELETE FROM messages WHERE id = ?", (mid,))
826
  await db.execute("DELETE FROM read_receipts WHERE message_id = ?", (mid,))
827
  await db.commit()
828
  schedule_db_sync()
829
- await manager.broadcast({"type": "message_deleted", "message_id": mid})
 
 
 
 
 
 
 
 
830
  finally:
831
  await db.close()
 
832
 
833
- elif mtype == "typing":
834
- await manager.broadcast({
835
- "type": "typing_indicator",
836
- "user_id": uid,
837
- "username": username,
838
- "display_name": display_name,
839
- "is_typing": bool(data.get("is_typing", False))
840
- }, exclude_user_id=uid)
 
 
 
 
 
 
 
 
 
 
841
 
842
- elif mtype == "mark_read":
 
843
  up_to = data.get("up_to_message_id")
 
844
  if not up_to:
845
  continue
846
  db = await get_db()
847
  try:
848
- rows = await db.execute(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
849
  """SELECT m.id, m.sender_id FROM messages m
850
- WHERE m.id <= ? AND m.sender_id != ? AND m.is_deleted = 0
851
- AND m.id NOT IN (
852
- SELECT message_id FROM read_receipts WHERE user_id = ?
853
- )""",
854
- (up_to, uid, uid)
855
  )
856
- new_reads = []
857
- async for r in rows:
 
 
858
  await db.execute(
859
- "INSERT OR IGNORE INTO read_receipts (user_id, message_id) VALUES (?,?)",
860
- (uid, r['id'])
 
861
  )
862
- new_reads.append(r)
 
 
 
 
 
 
 
 
863
  await db.commit()
864
  if new_reads:
865
  schedule_db_sync()
 
 
866
  for r in new_reads:
867
  await manager.send_to_user(r['sender_id'], {
868
  "type": "message_read",
869
  "message_id": r['id'],
870
- "reader_username": username
 
 
 
 
 
 
 
871
  })
 
872
  finally:
873
  await db.close()
 
874
 
875
- elif mtype == "get_online_users":
 
876
  await ws.send_json({
877
  "type": "online_users",
878
  "users": manager.get_online_users()
879
  })
 
880
 
881
  except WebSocketDisconnect:
882
  pass
883
  except Exception as e:
884
- logger.error(f"WS error for {username}: {e}")
885
  finally:
 
 
 
 
886
  manager.disconnect(ws, uid)
887
- db = await get_db()
888
- try:
889
- await db.execute(
890
- "UPDATE users SET status = 'offline', last_seen = ? WHERE id = ?",
891
- (int(time.time()), uid)
892
- )
893
- await db.commit()
894
- schedule_db_sync()
895
- finally:
896
- await db.close()
897
- await manager.broadcast({
898
- "type": "user_status",
899
- "user_id": uid,
900
- "username": username,
901
- "display_name": display_name,
902
- "avatar_path": avatar_path,
903
- "status": "offline"
904
- })
 
 
905
 
906
  @app.get("/")
907
  async def root():
 
1
  import os
2
+ import re
3
  import json
4
  import time
5
  import uuid
 
7
  import logging
8
  import tempfile
9
  import shutil
10
+ import asyncio
11
+ from typing import Optional, Dict, List, Any, Tuple
12
  from contextlib import asynccontextmanager
13
 
14
  import aiosqlite
15
 
16
  from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, Header, Query, UploadFile, File
17
  from fastapi.staticfiles import StaticFiles
18
+ from fastapi.responses import FileResponse, StreamingResponse
19
  from fastapi.middleware.cors import CORSMiddleware
20
 
21
  from cryptography.hazmat.primitives.ciphers.aead import AESGCM
 
24
  from cryptography.hazmat.backends import default_backend
25
 
26
  from storage_handler import (
27
+ store_file, retrieve_file, delete_file,
28
  download_database, upload_database, start_db_sync
29
  )
30
 
31
  # ------------------------------------------------------------------------
32
  # Configuration
33
  # ------------------------------------------------------------------------
34
+ APP_VERSION = "2.0.0"
35
  DATABASE_URL = os.environ.get("DATABASE_URL", "/data/infinitychat.db")
36
  MESSAGE_KEY_B64 = os.environ.get("SECRET_KEY", None)
 
 
 
37
  FILE_ENCRYPTION_KEY_B64 = os.environ.get("FILE_ENCRYPTION_KEY", None)
38
+
39
+ logging.basicConfig(level=logging.INFO)
40
+ logger = logging.getLogger("InfinityChat")
41
 
42
  if MESSAGE_KEY_B64 is None:
43
  MESSAGE_KEY_B64 = base64.urlsafe_b64encode(os.urandom(32)).decode()
44
+ logger.warning(
45
+ "SECRET_KEY not set - generated a random key for this run. "
46
+ "Existing messages will be unreadable next restart unless you persist it!"
47
+ )
48
+ try:
49
+ MESSAGE_KEY = base64.urlsafe_b64decode(MESSAGE_KEY_B64)
50
+ except Exception:
51
+ raise ValueError("SECRET_KEY is not valid base64")
52
+ assert len(MESSAGE_KEY) == 32, "SECRET_KEY must decode to exactly 32 bytes"
53
 
54
  # Temp directory for chunk assembly - uses disk not RAM
55
  TEMP_DIR = os.environ.get("TEMP_DIR", "/tmp/infinitychat_uploads")
56
  os.makedirs(TEMP_DIR, exist_ok=True)
57
 
58
+ # ------------------------------------------------------------------------
59
+ # App constants
60
+ # ------------------------------------------------------------------------
61
+ GLOBAL_CONVERSATION_ID = 1 # id of the public lobby room
62
+ MAX_PRIVATE_CHATS_PER_PAIR = 3 # up to 3 private chats between two people
63
+ MAX_MESSAGE_LENGTH = 10000
64
+ MAX_FILE_SIZE = 500 * 1024 * 1024 # 500MB upload cap
65
+ DOWNLOAD_CHUNK_SIZE = 1024 * 1024 # 1MB streaming chunks
66
 
67
  # ------------------------------------------------------------------------
68
+ # Database initialization + additive migrations (v1 -> v2)
69
  # ------------------------------------------------------------------------
70
+ async def _table_columns(db: aiosqlite.Connection, table: str) -> set:
71
+ cursor = await db.execute(f"PRAGMA table_info({table})")
72
+ rows = await cursor.fetchall()
73
+ return {r["name"] for r in rows}
74
+
75
+
76
+ async def _ensure_column(db: aiosqlite.Connection, table: str, column: str, ddl: str):
77
+ """Add a column if it doesn't exist yet (safe, non-destructive upgrade)."""
78
+ cols = await _table_columns(db, table)
79
+ if column not in cols:
80
+ await db.execute(f"ALTER TABLE {table} ADD COLUMN {column} {ddl}")
81
+ logger.info(f"🛠 Migration: added column {table}.{column}")
82
+
83
+
84
  async def init_database():
85
  os.makedirs(os.path.dirname(DATABASE_URL), exist_ok=True)
86
  db_existed = download_database(DATABASE_URL)
 
95
  await db.execute("PRAGMA foreign_keys=ON;")
96
  await db.execute("PRAGMA synchronous=NORMAL;")
97
  await db.execute("PRAGMA cache_size=-20000;")
98
+ await db.execute("PRAGMA busy_timeout=10000;")
99
+
100
+ # --- Base tables (v1 schema, unchanged shapes so old DBs keep working) ---
101
  await db.execute("""
102
  CREATE TABLE IF NOT EXISTS users (
103
  id INTEGER PRIMARY KEY AUTOINCREMENT,
 
125
  file_type TEXT,
126
  file_name TEXT,
127
  file_size INTEGER DEFAULT 0,
128
+ conversation_id INTEGER NOT NULL DEFAULT 1,
129
+ client_id TEXT,
130
  FOREIGN KEY(sender_id) REFERENCES users(id)
131
  )
132
  """)
 
140
  FOREIGN KEY(message_id) REFERENCES messages(id)
141
  )
142
  """)
143
+
144
+ # --- v2 tables ---
145
+ await db.execute("""
146
+ CREATE TABLE IF NOT EXISTS conversations (
147
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
148
+ type TEXT NOT NULL DEFAULT 'global' CHECK(type IN ('global','dm')),
149
+ title TEXT,
150
+ created_by INTEGER,
151
+ user_low_id INTEGER,
152
+ user_high_id INTEGER,
153
+ created_at INTEGER DEFAULT (strftime('%s','now'))
154
+ )
155
+ """)
156
+ await db.execute("""
157
+ CREATE TABLE IF NOT EXISTS conversation_members (
158
+ conversation_id INTEGER NOT NULL,
159
+ user_id INTEGER NOT NULL,
160
+ joined_at INTEGER DEFAULT (strftime('%s','now')),
161
+ last_read_message_id INTEGER NOT NULL DEFAULT 0,
162
+ PRIMARY KEY (conversation_id, user_id),
163
+ FOREIGN KEY(conversation_id) REFERENCES conversations(id) ON DELETE CASCADE,
164
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
165
+ )
166
+ """)
167
+
168
+ # --- Additive migrations for databases created before v2 ---
169
+ msg_cols = await _table_columns(db, "messages")
170
+ if "conversation_id" not in msg_cols:
171
+ await _ensure_column(db, "messages", "conversation_id",
172
+ "INTEGER NOT NULL DEFAULT 1")
173
+ if "client_id" not in msg_cols:
174
+ await _ensure_column(db, "messages", "client_id", "TEXT")
175
+ if "is_edited" not in msg_cols:
176
+ await _ensure_column(db, "messages", "is_edited", "INTEGER DEFAULT 0")
177
+ if "is_deleted" not in msg_cols:
178
+ await _ensure_column(db, "messages", "is_deleted", "INTEGER DEFAULT 0")
179
+
180
+ usr_cols = await _table_columns(db, "users")
181
+ for col, ddl in {
182
+ "display_name": "TEXT NOT NULL DEFAULT ''",
183
+ "avatar_path": "TEXT",
184
+ "last_seen": "INTEGER DEFAULT (strftime('%s','now'))",
185
+ "status": "TEXT DEFAULT 'offline'",
186
+ }.items():
187
+ if col not in usr_cols:
188
+ await _ensure_column(db, "users", col, ddl)
189
+
190
+ rc_cols = await _table_columns(db, "read_receipts")
191
+ if "read_at" not in rc_cols:
192
+ await _ensure_column(db, "read_receipts", "read_at",
193
+ "INTEGER DEFAULT (strftime('%s','now'))")
194
+ # Old receipts stored seconds - upgrade them to milliseconds
195
+ await db.execute(
196
+ "UPDATE read_receipts SET read_at = read_at * 1000 WHERE read_at < 100000000000"
197
+ )
198
+
199
+ # Seed the global lobby conversation (id 1 stays stable across versions)
200
+ await db.execute(
201
+ "INSERT OR IGNORE INTO conversations (id, type, title, created_by) "
202
+ "VALUES (?, 'global', 'Global Chat', NULL)",
203
+ (GLOBAL_CONVERSATION_ID,)
204
+ )
205
+
206
+ # Backfill: any message that somehow predates conversations gets the lobby
207
+ await db.execute(
208
+ "UPDATE messages SET conversation_id = ? "
209
+ "WHERE conversation_id IS NULL OR conversation_id NOT IN "
210
+ "(SELECT id FROM conversations WHERE id = ?)",
211
+ (GLOBAL_CONVERSATION_ID, GLOBAL_CONVERSATION_ID)
212
+ )
213
+
214
+ # --- Indexes ---
215
  await db.execute("CREATE INDEX IF NOT EXISTS idx_messages_sender_time ON messages(sender_id, timestamp_ms)")
216
  await db.execute("CREATE INDEX IF NOT EXISTS idx_messages_time_id ON messages(timestamp_ms, id)")
217
  await db.execute("CREATE INDEX IF NOT EXISTS idx_messages_not_deleted ON messages(is_deleted, id)")
218
+ await db.execute("CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id, id)")
219
+ await db.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_client_dedupe "
220
+ "ON messages(sender_id, client_id) WHERE client_id IS NOT NULL")
221
  await db.execute("CREATE INDEX IF NOT EXISTS idx_receipts_user_msg ON read_receipts(user_id, message_id)")
222
+ await db.execute("CREATE INDEX IF NOT EXISTS idx_receipts_message ON read_receipts(message_id)")
223
+ await db.execute("CREATE INDEX IF NOT EXISTS idx_members_user ON conversation_members(user_id)")
224
+ await db.execute("CREATE INDEX IF NOT EXISTS idx_conv_dm_pair "
225
+ "ON conversations(type, user_low_id, user_high_id) WHERE type = 'dm'")
226
  await db.commit()
227
 
228
  upload_database(DATABASE_URL)
229
  start_db_sync(DATABASE_URL)
230
+ logger.info("✅ Database initialized successfully (schema v2)")
231
 
232
  # Clean up any leftover temp upload dirs from previous runs
233
  _cleanup_temp_dir()
234
 
235
+
236
  # ------------------------------------------------------------------------
237
  # FastAPI lifespan
238
  # ------------------------------------------------------------------------
 
244
  upload_database(DATABASE_URL)
245
  _cleanup_temp_dir()
246
 
247
+
248
+ app = FastAPI(title="InfinityChat", version=APP_VERSION, lifespan=lifespan)
249
  app.add_middleware(
250
  CORSMiddleware,
251
  allow_origins=["*"],
252
+ allow_credentials=False, # auth travels via header/query, never cookies
253
  allow_methods=["*"],
254
  allow_headers=["*"]
255
  )
256
  app.mount("/static", StaticFiles(directory="static"), name="static")
257
 
258
+
259
  # ------------------------------------------------------------------------
260
  # Temp directory cleanup
261
  # ------------------------------------------------------------------------
 
272
  except Exception as e:
273
  logger.warning(f"Temp cleanup error: {e}")
274
 
275
+
276
  # ------------------------------------------------------------------------
277
  # Database helper
278
  # ------------------------------------------------------------------------
 
281
  conn.row_factory = aiosqlite.Row
282
  await conn.execute("PRAGMA journal_mode=WAL")
283
  await conn.execute("PRAGMA foreign_keys=ON")
284
+ await conn.execute("PRAGMA busy_timeout=10000")
285
  return conn
286
 
287
+
288
  def schedule_db_sync():
289
  import threading
290
  threading.Thread(target=upload_database, args=(DATABASE_URL,), daemon=True).start()
291
 
292
+
293
  # ------------------------------------------------------------------------
294
  # Encryption helpers (AES-256-GCM)
295
  # ------------------------------------------------------------------------
 
299
  ciphertext = aesgcm.encrypt(nonce, plaintext.encode('utf-8'), None)
300
  return base64.urlsafe_b64encode(nonce + ciphertext).decode('utf-8')
301
 
302
+
303
  def decrypt_message(encrypted_b64: str) -> str:
304
  try:
305
  raw = base64.urlsafe_b64decode(encrypted_b64)
 
309
  except Exception:
310
  return "[Decryption Error]"
311
 
312
+
313
  # ------------------------------------------------------------------------
314
  # Password hashing (PBKDF2)
315
  # ------------------------------------------------------------------------
316
+ def hash_password(password: str, salt: Optional[str] = None) -> Tuple[str, str]:
317
  if salt is None:
318
  salt = os.urandom(32).hex()
319
  kdf = PBKDF2HMAC(
 
326
  key = base64.urlsafe_b64encode(kdf.derive(password.encode())).decode()
327
  return key, salt
328
 
329
+
330
  def verify_password(password: str, salt: str, stored_hash: str) -> bool:
331
  key, _ = hash_password(password, salt)
332
  return key == stored_hash
333
 
334
+
335
  def generate_token() -> str:
336
  return base64.urlsafe_b64encode(os.urandom(32)).decode()
337
 
338
+
339
  # ------------------------------------------------------------------------
340
  # Authentication
341
  # ------------------------------------------------------------------------
 
360
  finally:
361
  await db.close()
362
 
363
+
364
+ # ------------------------------------------------------------------------
365
+ # Conversation helpers
366
+ # ------------------------------------------------------------------------
367
+ async def user_in_conversation(db: aiosqlite.Connection, uid: int, cid: int) -> bool:
368
+ """Everyone is in the global lobby; private chats require a membership row."""
369
+ if cid == GLOBAL_CONVERSATION_ID:
370
+ return True
371
+ cursor = await db.execute(
372
+ "SELECT 1 FROM conversation_members WHERE conversation_id = ? AND user_id = ?",
373
+ (cid, uid)
374
+ )
375
+ return await cursor.fetchone() is not None
376
+
377
+
378
+ async def conversation_member_ids(db: aiosqlite.Connection, cid: int) -> List[int]:
379
+ """All member ids of a conversation (for scoped broadcasts)."""
380
+ if cid == GLOBAL_CONVERSATION_ID:
381
+ # Global lobby membership is implicit for every account.
382
+ cursor = await db.execute("SELECT id FROM users")
383
+ return [r["id"] for r in await cursor.fetchall()]
384
+ cursor = await db.execute(
385
+ "SELECT user_id FROM conversation_members WHERE conversation_id = ?", (cid,)
386
+ )
387
+ return [r["user_id"] for r in await cursor.fetchall()]
388
+
389
+
390
+ async def _ensure_global_member(db: aiosqlite.Connection, uid: int):
391
+ """First sighting of a user: mark the whole existing lobby as already-read."""
392
+ cursor = await db.execute(
393
+ "SELECT MAX(id) AS max_id FROM messages WHERE conversation_id = ?",
394
+ (GLOBAL_CONVERSATION_ID,)
395
+ )
396
+ row = await cursor.fetchone()
397
+ max_id = row["max_id"] or 0
398
+ await db.execute(
399
+ "INSERT OR IGNORE INTO conversation_members "
400
+ "(conversation_id, user_id, last_read_message_id) VALUES (?, ?, ?)",
401
+ (GLOBAL_CONVERSATION_ID, uid, max_id)
402
+ )
403
+
404
+
405
+ def _row_to_public_user(row) -> dict:
406
+ return {
407
+ "id": row["id"],
408
+ "username": row["username"],
409
+ "display_name": row["display_name"],
410
+ "avatar_path": row["avatar_path"],
411
+ }
412
+
413
+
414
+ def _preview_for(content: str, file_type: str, file_name: str) -> str:
415
+ content = (content or "").strip()
416
+ if content:
417
+ # mirror client-side markdown so previews read like the real message
418
+ text = re.sub(r'<img\b[^>]*>', ' ', content)
419
+ text = re.sub(r'```', ' ', text)
420
+ text = re.sub(r'\*\*([^*]+)\*\*', r'\1', text)
421
+ text = re.sub(r'`([^`]+)`', r'\1', text)
422
+ text = re.sub(r'([\s(>]|^)\*([^*\n]+)\*(?=[\s.,!?;:)\n]|$)', r'\1\2', text)
423
+ text = re.sub(r'\s+', ' ', text).strip()
424
+ return text[:90]
425
+ if file_type and file_type.startswith("image/"):
426
+ return "📷 Photo"
427
+ if file_type and file_type.startswith("video/"):
428
+ return "🎬 Video"
429
+ if file_type and file_type.startswith("audio/"):
430
+ return "🎵 Audio"
431
+ return f"📎 {file_name or 'File'}"
432
+
433
+
434
+ async def _peer_for(db: aiosqlite.Connection, cid: int, uid: int) -> Optional[dict]:
435
+ """The other person inside a DM conversation."""
436
+ cursor = await db.execute(
437
+ """SELECT u.id, u.username, u.display_name, u.avatar_path, u.status, u.last_seen
438
+ FROM conversation_members cm JOIN users u ON u.id = cm.user_id
439
+ WHERE cm.conversation_id = ? AND cm.user_id != ? LIMIT 1""",
440
+ (cid, uid)
441
+ )
442
+ row = await cursor.fetchone()
443
+ return dict(row) if row else None
444
+
445
+
446
+ async def _dm_number(db: aiosqlite.Connection, cid: int) -> int:
447
+ cursor = await db.execute(
448
+ """SELECT id FROM conversations
449
+ WHERE type = 'dm' AND user_low_id = (
450
+ SELECT user_low_id FROM conversations WHERE id = ?
451
+ ) AND user_high_id = (
452
+ SELECT user_high_id FROM conversations WHERE id = ?
453
+ ) ORDER BY id""",
454
+ (cid, cid)
455
+ )
456
+ ids = [r["id"] for r in await cursor.fetchall()]
457
+ try:
458
+ return ids.index(cid) + 1
459
+ except ValueError:
460
+ return 1
461
+
462
+
463
+ async def _build_conversation_summary(
464
+ db: aiosqlite.Connection, cid: int, uid: int, online_ids: set = None
465
+ ) -> dict:
466
+ """One conversation as sent to the client (sidebar row)."""
467
+ cursor = await db.execute(
468
+ "SELECT * FROM conversations WHERE id = ?", (cid,)
469
+ )
470
+ conv = await cursor.fetchone()
471
+ if not conv:
472
+ raise HTTPException(404, "Conversation not found")
473
+
474
+ online_ids = online_ids if online_ids is not None else set()
475
+ summary = {
476
+ "id": conv["id"],
477
+ "type": conv["type"],
478
+ "title": conv["title"] or "Chat",
479
+ "created_at": conv["created_at"],
480
+ "peer": None,
481
+ "peer_online": False,
482
+ "dm_number": 1,
483
+ "last_message_id": None,
484
+ "last_message_preview": "",
485
+ "last_message_ts": None,
486
+ "last_sender_name": "",
487
+ "last_sender_id": None,
488
+ "last_message_type": None,
489
+ "unread_count": 0,
490
+ }
491
+
492
+ if conv["type"] == "dm":
493
+ peer = await _peer_for(db, cid, uid)
494
+ if peer:
495
+ summary["peer"] = {
496
+ "id": peer["id"],
497
+ "username": peer["username"],
498
+ "display_name": peer["display_name"],
499
+ "avatar_path": peer["avatar_path"],
500
+ "online": peer["id"] in online_ids,
501
+ "last_seen": peer["last_seen"],
502
+ }
503
+ summary["peer_online"] = peer["id"] in online_ids
504
+ summary["dm_number"] = await _dm_number(db, cid)
505
+
506
+ # Newest visible message (encrypted in DB - decrypt only what we preview)
507
+ cursor = await db.execute(
508
+ """SELECT m.id, m.sender_id, m.encrypted_content, m.timestamp_ms,
509
+ m.file_path, m.file_type, m.file_name, u.username, u.display_name
510
+ FROM messages m JOIN users u ON u.id = m.sender_id
511
+ WHERE m.conversation_id = ? AND m.is_deleted = 0
512
+ ORDER BY m.id DESC LIMIT 1""",
513
+ (cid,)
514
+ )
515
+ last = await cursor.fetchone()
516
+ if last:
517
+ plain = decrypt_message(last["encrypted_content"])
518
+ summary["last_message_id"] = last["id"]
519
+ summary["last_message_preview"] = _preview_for(plain, last["file_type"], last["file_name"])
520
+ summary["last_message_ts"] = last["timestamp_ms"]
521
+ summary["last_sender_id"] = last["sender_id"]
522
+ summary["last_sender_name"] = last["display_name"] or last["username"]
523
+ summary["last_message_type"] = last["file_type"] or "text"
524
+
525
+ # Unread = messages from other people past the point the user last read to
526
+ cursor = await db.execute(
527
+ """SELECT COUNT(*) AS cnt FROM messages m
528
+ WHERE m.conversation_id = ? AND m.sender_id != ? AND m.is_deleted = 0
529
+ AND m.id > (SELECT COALESCE(last_read_message_id, 0)
530
+ FROM conversation_members
531
+ WHERE conversation_id = ? AND user_id = ?)""",
532
+ (cid, uid, cid, uid)
533
+ )
534
+ unread = await cursor.fetchone()
535
+ summary["unread_count"] = unread["cnt"] or 0
536
+ return summary
537
+
538
+
539
+ async def _conversations_for_user(db: aiosqlite.Connection, uid: int, online_ids=None) -> List[dict]:
540
+ """Global lobby first, then the user's private chats newest-activity first."""
541
+ cursor = await db.execute(
542
+ """SELECT c.id FROM conversations c
543
+ WHERE c.id = ?
544
+ OR (c.type = 'dm' AND EXISTS (
545
+ SELECT 1 FROM conversation_members cm
546
+ WHERE cm.conversation_id = c.id AND cm.user_id = ?))
547
+ ORDER BY c.id""",
548
+ (GLOBAL_CONVERSATION_ID, uid)
549
+ )
550
+ ids = [r["id"] for r in await cursor.fetchall()]
551
+ summaries = []
552
+ for cid in ids:
553
+ try:
554
+ summaries.append(await _build_conversation_summary(db, cid, uid, online_ids))
555
+ except HTTPException:
556
+ continue
557
+
558
+ def sort_key(s):
559
+ if s["id"] == GLOBAL_CONVERSATION_ID:
560
+ return (0, 0)
561
+ return (1, -(s["last_message_ts"] or 0))
562
+
563
+ return sorted(summaries, key=sort_key)
564
+
565
+
566
+ # ------------------------------------------------------------------------
567
+ # Message serialization
568
+ # ------------------------------------------------------------------------
569
+ async def _attach_receipt_info(db: aiosqlite.Connection, msgs: List[dict], uid: int):
570
+ """
571
+ For the current user's own messages, attach who has read them
572
+ (readers + reader_count). Used by the clickable read-receipt tick.
573
+ """
574
+ own = [m["id"] for m in msgs if m["sender_id"] == uid]
575
+ if not own:
576
+ return msgs
577
+ placeholders = ",".join("?" * len(own))
578
+ reader_map: Dict[int, List[dict]] = {mid: [] for mid in own}
579
+ count_map: Dict[int, int] = {mid: 0 for mid in own}
580
+
581
+ cursor = await db.execute(
582
+ f"""SELECT r.message_id, r.read_at, u.id, u.username, u.display_name, u.avatar_path
583
+ FROM read_receipts r JOIN users u ON u.id = r.user_id
584
+ WHERE r.message_id IN ({placeholders})
585
+ ORDER BY r.read_at ASC""",
586
+ tuple(own)
587
+ )
588
+ for row in await cursor.fetchall():
589
+ reader_map.setdefault(row["message_id"], []).append({
590
+ "user_id": row["id"],
591
+ "username": row["username"],
592
+ "display_name": row["display_name"],
593
+ "avatar_path": row["avatar_path"],
594
+ "read_at": row["read_at"],
595
+ })
596
+ count_map[row["message_id"]] = count_map.get(row["message_id"], 0) + 1
597
+
598
+ cursor = await db.execute(
599
+ f"""SELECT message_id, COUNT(*) AS cnt FROM read_receipts
600
+ WHERE message_id IN ({placeholders}) GROUP BY message_id""",
601
+ tuple(own)
602
+ )
603
+ for row in await cursor.fetchall():
604
+ count_map[row["message_id"]] = row["cnt"]
605
+
606
+ for m in msgs:
607
+ if m["sender_id"] == uid:
608
+ m["readers"] = reader_map.get(m["id"], [])[:12]
609
+ m["reader_count"] = count_map.get(m["id"], 0)
610
+ m["status"] = "read" if m["reader_count"] > 0 else "sent"
611
+ else:
612
+ m["readers"] = []
613
+ m["reader_count"] = 0
614
+ return msgs
615
+
616
+
617
+ def _row_to_message(row, content: str) -> dict:
618
+ return {
619
+ "id": row["id"],
620
+ "conversation_id": row["conversation_id"],
621
+ "sender_id": row["sender_id"],
622
+ "sender_username": row["username"],
623
+ "sender_display_name": row["display_name"],
624
+ "sender_avatar_path": row["avatar_path"],
625
+ "content": content,
626
+ "timestamp_ms": row["timestamp_ms"],
627
+ "reply_to_id": row["reply_to_id"],
628
+ "is_edited": bool(row["is_edited"]),
629
+ "is_deleted": False,
630
+ "file_path": row["file_path"],
631
+ "file_type": row["file_type"],
632
+ "file_name": row["file_name"],
633
+ "file_size": row["file_size"],
634
+ "status": "sent",
635
+ "readers": [],
636
+ "reader_count": 0,
637
+ }
638
+
639
+
640
  # ------------------------------------------------------------------------
641
  # HTTP Endpoints
642
  # ------------------------------------------------------------------------
643
  @app.get("/api/health")
644
  async def health_check():
645
+ return {"status": "healthy", "timestamp": int(time.time()), "version": APP_VERSION}
646
+
647
 
648
  @app.post("/api/auth/signup")
649
  async def signup(
 
661
  pwd_hash, salt = hash_password(password)
662
  token = generate_token()
663
  display = display_name or username
664
+ cursor = await db.execute(
665
+ "INSERT INTO users (username, display_name, password_hash, salt, token, status) "
666
+ "VALUES (?,?,?,?,?,'online')",
667
  (username.lower(), display, pwd_hash, salt, token)
668
  )
669
+ new_uid = cursor.lastrowid
670
+ await _ensure_global_member(db, new_uid)
671
  await db.commit()
672
  schedule_db_sync()
673
  return {
674
  "token": token,
675
  "user": {
676
+ "id": new_uid,
677
  "username": username.lower(),
678
  "display_name": display,
679
  "avatar_path": None,
 
683
  finally:
684
  await db.close()
685
 
686
+
687
  @app.post("/api/auth/login")
688
  async def login(username: str = Query(...), password: str = Query(...)):
689
  db = await get_db()
 
697
  "UPDATE users SET token = ?, last_seen = ?, status = 'online' WHERE id = ?",
698
  (token, int(time.time()), user['id'])
699
  )
700
+ await _ensure_global_member(db, user['id'])
701
  await db.commit()
702
  schedule_db_sync()
703
  return {
 
713
  finally:
714
  await db.close()
715
 
716
+
717
  @app.get("/api/auth/verify")
718
  async def verify_token(token: str = Header(..., alias="X-Auth-Token")):
719
  user = await authenticate_user(token)
 
721
  raise HTTPException(401)
722
  return {"user": user}
723
 
724
+
725
  @app.post("/api/auth/logout")
726
  async def logout(token: str = Header(..., alias="X-Auth-Token")):
727
  db = await get_db()
728
  try:
729
+ cursor = await db.execute("SELECT id FROM users WHERE token = ?", (token,))
730
+ row = await cursor.fetchone()
731
  await db.execute(
732
  "UPDATE users SET token = NULL, status = 'offline', last_seen = ? WHERE token = ?",
733
  (int(time.time()), token)
734
  )
735
  await db.commit()
736
  schedule_db_sync()
737
+ if row:
738
+ # a logged-out device/tab must not keep receiving messages
739
+ await manager.force_close_user(row["id"])
740
  return {"status": "logged_out"}
741
  finally:
742
  await db.close()
743
 
744
+
745
  # ------------------------------------------------------------------------
746
  # Profile
747
  # ------------------------------------------------------------------------
 
752
  raise HTTPException(401)
753
  return {"user": user}
754
 
755
+
756
  @app.patch("/api/profile")
757
  async def update_profile(
758
+ display_name: str = Query(..., max_length=50),
759
  token: str = Header(..., alias="X-Auth-Token")
760
  ):
761
  user = await authenticate_user(token)
 
765
  try:
766
  await db.execute(
767
  "UPDATE users SET display_name = ? WHERE id = ?",
768
+ (display_name.strip() or user['username'], user['id'])
769
  )
770
  await db.commit()
771
  schedule_db_sync()
772
+ user['display_name'] = display_name.strip() or user['username']
773
  return {"user": user}
774
  finally:
775
  await db.close()
776
 
777
+
778
+ @app.post("/api/profile/password")
779
+ async def change_password(
780
+ current_password: str = Query(...),
781
+ new_password: str = Query(..., min_length=6, max_length=200),
782
+ token: str = Header(..., alias="X-Auth-Token")
783
+ ):
784
+ user = await authenticate_user(token)
785
+ if not user:
786
+ raise HTTPException(401)
787
+ db = await get_db()
788
+ try:
789
+ async with db.execute(
790
+ "SELECT password_hash, salt FROM users WHERE id = ?", (user['id'],)
791
+ ) as cursor:
792
+ row = await cursor.fetchone()
793
+ if not row or not verify_password(current_password, row['salt'], row['password_hash']):
794
+ raise HTTPException(400, "Current password is incorrect")
795
+ pwd_hash, salt = hash_password(new_password)
796
+ await db.execute(
797
+ "UPDATE users SET password_hash = ?, salt = ? WHERE id = ?",
798
+ (pwd_hash, salt, user['id'])
799
+ )
800
+ await db.commit()
801
+ schedule_db_sync()
802
+ return {"status": "ok"}
803
+ finally:
804
+ await db.close()
805
+
806
+
807
+ _MAGIC_TO_TYPE = [
808
+ (b"\xff\xd8\xff", "image/jpeg"),
809
+ (b"\x89PNG\r\n\x1a\n", "image/png"),
810
+ (b"GIF87a", "image/gif"),
811
+ (b"GIF89a", "image/gif"),
812
+ (b"RIFF", "image/webp"),
813
+ (b"\x00\x00\x01\x00", "image/x-icon"),
814
+ (b"BM", "image/bmp"),
815
+ ]
816
+
817
+
818
+ def sniff_image_type(data: bytes) -> Optional[str]:
819
+ for magic, mime in _MAGIC_TO_TYPE:
820
+ if data.startswith(magic):
821
+ if mime == "image/webp" and len(data) > 12:
822
+ if data[8:12] != b"WEBP":
823
+ continue
824
+ return mime
825
+ return None
826
+
827
+
828
  @app.post("/api/profile/avatar")
829
  async def upload_avatar(
830
  file: UploadFile = File(...),
 
835
  raise HTTPException(401)
836
 
837
  # Write to temp file on disk first, not RAM
838
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".img", dir=TEMP_DIR) as tmp:
 
839
  tmp_path = tmp.name
840
  size = 0
841
+ head = b""
842
  chunk = await file.read(65536)
843
  while chunk:
844
+ if size < 32:
845
+ head += chunk[:32]
846
  size += len(chunk)
847
  if size > 5 * 1024 * 1024:
848
  os.unlink(tmp_path)
 
850
  tmp.write(chunk)
851
  chunk = await file.read(65536)
852
 
853
+ # Only actual image bytes are accepted (magic-number sniffing)
854
+ detected = sniff_image_type(head) if head else None
855
+ if not detected:
856
+ os.unlink(tmp_path)
857
+ raise HTTPException(400, "Invalid image file - must be PNG, JPEG, GIF, WebP or BMP")
858
+
859
  try:
860
  with open(tmp_path, 'rb') as f:
861
  data = f.read()
862
+ ext = {".jpg": "", ".png": "", ".gif": "", ".webp": "", ".ico": "", ".bmp": ""}.get(
863
+ os.path.splitext(file.filename or "")[1].lower(), "")
864
+ if detected == "image/jpeg":
865
+ ext = ".jpg"
866
+ elif detected == "image/png":
867
+ ext = ".png"
868
+ elif detected == "image/gif":
869
+ ext = ".gif"
870
+ elif detected == "image/webp":
871
+ ext = ".webp"
872
+ remote_path = f"avatars/{user['username']}_{uuid.uuid4().hex}{ext}"
873
+ await asyncio.to_thread(store_file, remote_path, data)
874
  finally:
875
  os.unlink(tmp_path)
876
 
 
886
  await db.close()
887
  return {"avatar_path": remote_path}
888
 
889
+
890
+ # ------------------------------------------------------------------------
891
+ # Conversations (REST fallbacks - primary channel is WebSocket)
892
+ # ------------------------------------------------------------------------
893
+ @app.get("/api/conversations")
894
+ async def list_conversations(token: str = Header(..., alias="X-Auth-Token")):
895
+ user = await authenticate_user(token)
896
+ if not user:
897
+ raise HTTPException(401)
898
+ db = await get_db()
899
+ try:
900
+ # REST fallback must seed the global watermark too (it normally happens
901
+ # on first WS connect / signup), otherwise unread counts would be wrong.
902
+ await _ensure_global_member(db, user['id'])
903
+ await db.commit()
904
+ online = {u["id"] for u in manager.get_online_users()}
905
+ return {"conversations": await _conversations_for_user(db, user['id'], online)}
906
+ finally:
907
+ await db.close()
908
+
909
+
910
+ _dm_create_lock = asyncio.Lock()
911
+
912
+
913
+ async def _create_dm(db: aiosqlite.Connection, uid: int, target_id: int,
914
+ manager_ref=None) -> Optional[dict]:
915
+ """Create a private chat. Returns summary or None if invalid/at limit.
916
+
917
+ Up to MAX_PRIVATE_CHATS_PER_PAIR private chats are allowed per pair of users.
918
+ """
919
+ if target_id == uid:
920
+ raise HTTPException(400, "You can't start a private chat with yourself")
921
+
922
+ async with _dm_create_lock:
923
+ cursor = await db.execute(
924
+ "SELECT id, username FROM users WHERE id = ?", (target_id,)
925
+ )
926
+ target = await cursor.fetchone()
927
+ if not target:
928
+ raise HTTPException(404, "User not found")
929
+
930
+ low, high = sorted([uid, target_id])
931
+ cursor = await db.execute(
932
+ """SELECT COUNT(*) AS cnt FROM conversations
933
+ WHERE type = 'dm' AND user_low_id = ? AND user_high_id = ?""",
934
+ (low, high)
935
+ )
936
+ existing = (await cursor.fetchone())["cnt"]
937
+
938
+ if existing >= MAX_PRIVATE_CHATS_PER_PAIR:
939
+ raise HTTPException(
940
+ 400,
941
+ f"You already have {MAX_PRIVATE_CHATS_PER_PAIR} private chats with "
942
+ f"{target['username']} - delete one to start another"
943
+ )
944
+
945
+ cursor = await db.execute(
946
+ """INSERT INTO conversations (type, title, created_by, user_low_id, user_high_id)
947
+ VALUES ('dm', '', ?, ?, ?)""",
948
+ (uid, low, high)
949
+ )
950
+ cid = cursor.lastrowid
951
+ now = int(time.time())
952
+ await db.executemany(
953
+ "INSERT OR IGNORE INTO conversation_members "
954
+ "(conversation_id, user_id, joined_at) VALUES (?, ?, ?)",
955
+ [(cid, uid, now), (cid, target_id, now)]
956
+ )
957
+ await db.commit()
958
+ schedule_db_sync()
959
+
960
+ online = {u["id"] for u in manager.get_online_users()} if manager_ref else set()
961
+ return await _build_conversation_summary(db, cid, uid, online)
962
+
963
+
964
+ @app.post("/api/conversations/dm")
965
+ async def create_dm_rest(
966
+ user_id: int = Query(...),
967
+ token: str = Header(..., alias="X-Auth-Token")
968
+ ):
969
+ user = await authenticate_user(token)
970
+ if not user:
971
+ raise HTTPException(401)
972
+ db = await get_db()
973
+ try:
974
+ summary = await _create_dm(db, user['id'], user_id, manager)
975
+ if summary is None:
976
+ raise HTTPException(400, "Could not create private chat")
977
+ # Live-push the new conversation to both members (sidebar updates)
978
+ online = {u["id"] for u in manager.get_online_users()}
979
+ await manager.send_to_user(user['id'], {"type": "dm_created", "conversation": summary})
980
+ peer = summary.get("peer") or {}
981
+ if peer.get("id"):
982
+ peer_summary = await _build_conversation_summary(db, summary["id"], peer["id"], online)
983
+ await manager.send_to_user(peer["id"], {"type": "dm_created", "conversation": peer_summary})
984
+ return {"conversation": summary}
985
+ finally:
986
+ await db.close()
987
+
988
+
989
+ # ------------------------------------------------------------------------
990
+ # Messages (REST fallbacks for reliable edit/delete/receipts)
991
+ # ------------------------------------------------------------------------
992
+ async def _get_message_row(db: aiosqlite.Connection, mid: int):
993
+ cursor = await db.execute(
994
+ """SELECT m.*, u.username, u.display_name, u.avatar_path
995
+ FROM messages m JOIN users u ON u.id = m.sender_id
996
+ WHERE m.id = ?""",
997
+ (mid,)
998
+ )
999
+ return await cursor.fetchone()
1000
+
1001
+
1002
+ @app.patch("/api/messages/{message_id}")
1003
+ async def edit_message_rest(
1004
+ message_id: int,
1005
+ content: str = Query(..., max_length=MAX_MESSAGE_LENGTH),
1006
+ token: str = Header(..., alias="X-Auth-Token")
1007
+ ):
1008
+ user = await authenticate_user(token)
1009
+ if not user:
1010
+ raise HTTPException(401)
1011
+ content = content.strip()
1012
+ if not content:
1013
+ raise HTTPException(400, "Message cannot be empty")
1014
+ db = await get_db()
1015
+ try:
1016
+ row = await _get_message_row(db, message_id)
1017
+ if not row or row["sender_id"] != user['id'] or row["is_deleted"]:
1018
+ raise HTTPException(404, "Message not found")
1019
+ new_enc = encrypt_message(content)
1020
+ await db.execute(
1021
+ "UPDATE messages SET encrypted_content = ?, is_edited = 1 WHERE id = ?",
1022
+ (new_enc, message_id)
1023
+ )
1024
+ await db.commit()
1025
+ schedule_db_sync()
1026
+ await _broadcast_to_conversation(db, row["conversation_id"], {
1027
+ "type": "message_edited",
1028
+ "message_id": message_id,
1029
+ "conversation_id": row["conversation_id"],
1030
+ "content": content,
1031
+ "editor_id": user['id'],
1032
+ }, exclude_user_id=None) # include the author's other tabs
1033
+ members = await conversation_member_ids(db, row["conversation_id"])
1034
+ await _push_conversation_updates(user['id'], row["conversation_id"],
1035
+ [m for m in members if m != user['id']])
1036
+ return {"status": "ok", "message_id": message_id, "content": content}
1037
+ finally:
1038
+ await db.close()
1039
+
1040
+
1041
+ @app.delete("/api/messages/{message_id}")
1042
+ async def delete_message_rest(
1043
+ message_id: int,
1044
+ token: str = Header(..., alias="X-Auth-Token")
1045
+ ):
1046
+ user = await authenticate_user(token)
1047
+ if not user:
1048
+ raise HTTPException(401)
1049
+ db = await get_db()
1050
+ try:
1051
+ row = await _get_message_row(db, message_id)
1052
+ if not row or row["sender_id"] != user['id'] or row["is_deleted"]:
1053
+ raise HTTPException(404, "Message not found")
1054
+
1055
+ if row["file_path"]:
1056
+ try:
1057
+ await asyncio.to_thread(delete_file, row["file_path"])
1058
+ except Exception as e:
1059
+ logger.warning(f"Could not delete remote file {row['file_path']}: {e}")
1060
+
1061
+ await db.execute("DELETE FROM messages WHERE id = ?", (message_id,))
1062
+ await db.execute("DELETE FROM read_receipts WHERE message_id = ?", (message_id,))
1063
+ await db.commit()
1064
+ schedule_db_sync()
1065
+ await _broadcast_to_conversation(db, row["conversation_id"], {
1066
+ "type": "message_deleted",
1067
+ "message_id": message_id,
1068
+ "conversation_id": row["conversation_id"],
1069
+ "deleted_by": user['id'],
1070
+ }, exclude_user_id=None) # everyone gets the event; sender removes locally
1071
+ members = await conversation_member_ids(db, row["conversation_id"])
1072
+ await _push_conversation_updates(user['id'], row["conversation_id"],
1073
+ [m for m in members if m != user['id']])
1074
+ return {"status": "deleted", "message_id": message_id}
1075
+ finally:
1076
+ await db.close()
1077
+
1078
+
1079
+ @app.get("/api/messages/{message_id}/read-receipts")
1080
+ async def message_read_receipts(
1081
+ message_id: int,
1082
+ token: str = Header(..., alias="X-Auth-Token")
1083
+ ):
1084
+ """Who read this message and when (only visible to the message author)."""
1085
+ user = await authenticate_user(token)
1086
+ if not user:
1087
+ raise HTTPException(401)
1088
+ db = await get_db()
1089
+ try:
1090
+ row = await _get_message_row(db, message_id)
1091
+ if not row or row["is_deleted"]:
1092
+ raise HTTPException(404, "Message not found")
1093
+ if row["sender_id"] != user['id']:
1094
+ raise HTTPException(403, "Only the author can view read receipts")
1095
+ if not await user_in_conversation(db, user['id'], row["conversation_id"]):
1096
+ raise HTTPException(403, "Not a member of this conversation")
1097
+
1098
+ cursor = await db.execute(
1099
+ """SELECT r.read_at, u.id, u.username, u.display_name, u.avatar_path, u.status
1100
+ FROM read_receipts r JOIN users u ON u.id = r.user_id
1101
+ WHERE r.message_id = ? ORDER BY r.read_at ASC LIMIT 500""",
1102
+ (message_id,)
1103
+ )
1104
+ readers = []
1105
+ for r in await cursor.fetchall():
1106
+ readers.append({
1107
+ "user_id": r["id"],
1108
+ "username": r["username"],
1109
+ "display_name": r["display_name"],
1110
+ "avatar_path": r["avatar_path"],
1111
+ "read_at": r["read_at"],
1112
+ "online": r["status"] == "online" and manager.is_online(r["id"]),
1113
+ })
1114
+
1115
+ cursor = await db.execute(
1116
+ "SELECT COUNT(*) AS cnt FROM read_receipts WHERE message_id = ?", (message_id,)
1117
+ )
1118
+ reader_total = (await cursor.fetchone())["cnt"]
1119
+
1120
+ result = {
1121
+ "message_id": message_id,
1122
+ "conversation_id": row["conversation_id"],
1123
+ "readers": readers,
1124
+ "reader_count": reader_total,
1125
+ "readers_truncated": reader_total > len(readers),
1126
+ "is_dm": row["conversation_id"] != GLOBAL_CONVERSATION_ID,
1127
+ "not_read": [],
1128
+ }
1129
+
1130
+ # DM: explicitly list the other person if they haven't read yet
1131
+ if result["is_dm"]:
1132
+ cursor = await db.execute(
1133
+ """SELECT u.id, u.username, u.display_name, u.avatar_path, u.status
1134
+ FROM conversation_members cm JOIN users u ON u.id = cm.user_id
1135
+ WHERE cm.conversation_id = ? AND u.id != ?""",
1136
+ (row["conversation_id"], user['id'])
1137
+ )
1138
+ for r in await cursor.fetchall():
1139
+ already = any(p["user_id"] == r["id"] for p in readers)
1140
+ if not already:
1141
+ result["not_read"].append({
1142
+ "user_id": r["id"],
1143
+ "username": r["username"],
1144
+ "display_name": r["display_name"],
1145
+ "avatar_path": r["avatar_path"],
1146
+ "online": r["status"] == "online" and manager.is_online(r["id"]),
1147
+ })
1148
+ return result
1149
+ finally:
1150
+ await db.close()
1151
+
1152
+
1153
  # ------------------------------------------------------------------------
1154
  # Chunked Upload - assembles chunks on DISK not RAM
1155
  # ------------------------------------------------------------------------
 
1156
  upload_sessions: Dict[str, dict] = {}
1157
 
1158
+
1159
  @app.post("/api/upload/chunk")
1160
  async def upload_chunk(
1161
  file: UploadFile = File(...),
 
1171
  if not user:
1172
  raise HTTPException(401)
1173
 
 
 
1174
  if file_size > MAX_FILE_SIZE:
1175
  raise HTTPException(400, "File too large (max 500MB)")
1176
+ if len(file_name) > 300:
1177
+ raise HTTPException(400, "File name too long")
1178
+ if "\x00" in file_name or "/" in file_name or "\\" in file_name:
1179
+ raise HTTPException(400, "Invalid file name")
1180
+
1181
+ # Sweep stale sessions (aborted uploads) so the dict can't grow forever
1182
+ if len(upload_sessions) > 64:
1183
+ stale_before = time.time() - 7200
1184
+ for sid, sess in list(upload_sessions.items()):
1185
+ if sess["created_at"] < stale_before:
1186
+ del upload_sessions[sid]
1187
 
1188
  # Create session with temp dir on disk
1189
  if upload_id not in upload_sessions:
 
1203
  session = upload_sessions[upload_id]
1204
  if session['user_id'] != user['id']:
1205
  raise HTTPException(403)
1206
+ if chunk_index < 0 or chunk_index >= total_chunks or total_chunks > 10000:
1207
+ raise HTTPException(400, "Invalid chunk parameters")
1208
+ if len(session['received_chunks']) >= total_chunks:
1209
+ raise HTTPException(400, "Upload already complete")
1210
 
1211
  # Write chunk directly to disk
1212
  chunk_path = os.path.join(session['session_dir'], f"chunk_{chunk_index:06d}")
1213
  with open(chunk_path, 'wb') as f:
 
1214
  data = await file.read(65536)
1215
  while data:
1216
  f.write(data)
 
1219
  session['received_chunks'].add(chunk_index)
1220
 
1221
  if len(session['received_chunks']) == total_chunks:
 
1222
  remote_path = f"uploads/{user['username']}/{uuid.uuid4().hex}/{file_name}"
1223
  session_dir = session['session_dir']
1224
 
1225
  try:
 
1226
  assembled_path = os.path.join(session_dir, "assembled")
1227
  with open(assembled_path, 'wb') as out_f:
1228
  for i in range(total_chunks):
1229
  chunk_file = os.path.join(session_dir, f"chunk_{i:06d}")
1230
  with open(chunk_file, 'rb') as in_f:
 
1231
  buf = in_f.read(1024 * 1024)
1232
  while buf:
1233
  out_f.write(buf)
1234
  buf = in_f.read(1024 * 1024)
1235
+ os.unlink(chunk_file)
1236
 
 
 
 
 
1237
  with open(assembled_path, 'rb') as f:
1238
  full_data = f.read()
1239
+ # Blocking bucket write happens off the event loop
1240
+ await asyncio.to_thread(store_file, remote_path, full_data)
1241
+ del full_data
1242
 
1243
  finally:
 
1244
  shutil.rmtree(session_dir, ignore_errors=True)
1245
  del upload_sessions[upload_id]
1246
 
 
1258
  "total": total_chunks
1259
  }
1260
 
1261
+
1262
  # ------------------------------------------------------------------------
1263
  # File Download - streams from bucket to client in chunks
 
1264
  # ------------------------------------------------------------------------
 
 
1265
  @app.get("/api/download/{file_path:path}")
1266
  async def download_file(
1267
  file_path: str,
 
1272
  user = await authenticate_user(auth_token)
1273
  if not user:
1274
  raise HTTPException(401)
1275
+ if '..' in file_path or '\\' in file_path:
1276
  raise HTTPException(400, "Invalid path")
1277
 
1278
  try:
1279
+ # AES-GCM needs the whole ciphertext to verify its auth tag, so one full
1280
+ # read is required for security - the response is streamed in chunks.
1281
+ data = await asyncio.to_thread(retrieve_file, file_path)
 
 
 
1282
  except FileNotFoundError:
1283
  raise HTTPException(404, "File not found")
1284
  except Exception as e:
 
1289
  mt = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
1290
  file_size = len(data)
1291
 
1292
+ # sanitize for the Content-Disposition header
1293
+ disp_name = os.path.basename(file_path).replace('"', "'").replace("\r", "").replace("\n", "")
1294
+
1295
  def iter_data():
1296
  offset = 0
1297
  while offset < len(data):
 
1304
  headers={
1305
  "Content-Length": str(file_size),
1306
  "Cache-Control": "private, max-age=3600",
1307
+ "Content-Disposition": f'inline; filename="{disp_name}"'
1308
  }
1309
  )
1310
 
1311
+
1312
+ # Lightweight per-user flood guards (in-memory only)
1313
+ _send_history: Dict[int, List[float]] = {}
1314
+ _typing_history: Dict[Tuple[int, int], float] = {}
1315
+
1316
+
1317
+ def _allow_send(uid: int) -> bool:
1318
+ """Simple burst guard: max 20 sends per 5 seconds per user."""
1319
+ now = time.time()
1320
+ hist = _send_history.setdefault(uid, [])
1321
+ hist[:] = [t for t in hist if now - t < 5.0]
1322
+ if len(hist) >= 20:
1323
+ return False
1324
+ hist.append(now)
1325
+ return True
1326
+
1327
+
1328
+ def _allow_typing(uid: int, cid: int) -> bool:
1329
+ now = time.time()
1330
+ key = (uid, cid)
1331
+ last = _typing_history.get(key, 0.0)
1332
+ if now - last < 0.8:
1333
+ return False
1334
+ _typing_history[key] = now
1335
+ return True
1336
+
1337
+
1338
  # ------------------------------------------------------------------------
1339
  # WebSocket Manager
1340
  # ------------------------------------------------------------------------
 
1342
  def __init__(self):
1343
  self.active: Dict[int, Dict[str, Any]] = {}
1344
 
1345
+ async def connect(self, ws: WebSocket, user_id: int, username: str,
1346
+ display_name: str = "", avatar_path: Optional[str] = None):
1347
  await ws.accept()
1348
  if user_id not in self.active:
1349
+ self.active[user_id] = {
1350
+ "username": username,
1351
+ "display_name": display_name or username,
1352
+ "avatar_path": avatar_path,
1353
+ "connections": set(),
1354
+ }
1355
+ else:
1356
+ self.active[user_id]["display_name"] = display_name or username
1357
+ self.active[user_id]["avatar_path"] = avatar_path or self.active[user_id]["avatar_path"]
1358
  self.active[user_id]["connections"].add(ws)
1359
 
1360
  def disconnect(self, ws: WebSocket, user_id: int):
 
1363
  if not self.active[user_id]["connections"]:
1364
  del self.active[user_id]
1365
 
1366
+ async def send_to_user(self, user_id: int, msg: dict):
1367
+ if user_id in self.active:
1368
+ for ws in list(self.active[user_id]["connections"]):
1369
+ try:
1370
+ await ws.send_json(msg)
1371
+ except Exception:
1372
+ self.disconnect(ws, user_id)
1373
+
1374
+ async def send_to_users(self, user_ids: List[int], msg: dict, exclude_user_id=None):
1375
+ for uid in user_ids:
1376
+ if uid == exclude_user_id:
1377
+ continue
1378
+ await self.send_to_user(uid, msg)
1379
+
1380
  async def broadcast(self, msg: dict, exclude_user_id: Optional[int] = None):
1381
  for uid, info in list(self.active.items()):
1382
  if uid == exclude_user_id:
 
1387
  except Exception:
1388
  self.disconnect(ws, uid)
1389
 
 
 
 
 
 
 
 
 
1390
  def get_online_users(self) -> List[Dict]:
1391
  return [
1392
+ {
1393
+ "id": uid,
1394
+ "username": info["username"],
1395
+ "display_name": info.get("display_name") or info["username"],
1396
+ "avatar_path": info.get("avatar_path"),
1397
+ }
1398
  for uid, info in self.active.items()
1399
  ]
1400
 
1401
  def is_online(self, user_id: int) -> bool:
1402
  return user_id in self.active
1403
 
1404
+ async def force_close_user(self, user_id: int, code: int = 4001, reason: str = ""):
1405
+ """Close every live socket of a user (e.g. after logging out)."""
1406
+ if user_id not in self.active:
1407
+ return
1408
+ for ws in list(self.active[user_id]["connections"]):
1409
+ try:
1410
+ await ws.close(code=code, reason=reason)
1411
+ except Exception:
1412
+ pass
1413
+
1414
+
1415
  manager = ConnectionManager()
1416
 
1417
+
1418
+ async def _broadcast_to_conversation(db, cid: int, msg: dict, exclude_user_id=None):
1419
+ """Send a WS event to every online member of a conversation."""
1420
+ try:
1421
+ members = await conversation_member_ids(db, cid)
1422
+ except Exception as e:
1423
+ logger.error(f"Membership lookup failed for conv {cid}: {e}")
1424
+ return
1425
+ await manager.send_to_users(members, msg, exclude_user_id=exclude_user_id)
1426
+
1427
+
1428
+ async def _push_conversation_updates(uid: int, cid: int, other_member_ids: List[int]):
1429
+ """Refreshed sidebar summaries after activity in a conversation."""
1430
+ db = await get_db()
1431
+ try:
1432
+ online = {u["id"] for u in manager.get_online_users()}
1433
+ summary = await _build_conversation_summary(db, cid, uid, online)
1434
+ await manager.send_to_user(uid, {"type": "conversation_updated", "conversation": summary})
1435
+ if other_member_ids:
1436
+ for mid in other_member_ids:
1437
+ other_summary = await _build_conversation_summary(db, cid, mid, online)
1438
+ await manager.send_to_user(mid, {"type": "conversation_updated", "conversation": other_summary})
1439
+ except Exception as e:
1440
+ logger.error(f"conversation update push failed: {e}")
1441
+ finally:
1442
+ await db.close()
1443
+
1444
+
1445
  # ------------------------------------------------------------------------
1446
  # WebSocket endpoint
1447
  # ------------------------------------------------------------------------
 
1457
  display_name = user['display_name']
1458
  avatar_path = user['avatar_path']
1459
 
1460
+ await manager.connect(ws, uid, username, display_name, avatar_path)
1461
+
1462
+ online_ids = {u["id"] for u in manager.get_online_users()}
1463
+
1464
+ db = await get_db()
1465
+ try:
1466
+ await _ensure_global_member(db, uid)
1467
+ # persist "online" so REST presence fields (e.g. receipts modal) agree
1468
+ await db.execute(
1469
+ "UPDATE users SET status = 'online', last_seen = ? WHERE id = ?",
1470
+ (int(time.time()), uid)
1471
+ )
1472
+ await db.commit()
1473
+ conversations = await _conversations_for_user(db, uid, online_ids)
1474
+ finally:
1475
+ await db.close()
1476
 
1477
  await ws.send_json({
1478
  "type": "connection_established",
 
1480
  "username": username,
1481
  "display_name": display_name,
1482
  "avatar_path": avatar_path,
1483
+ "online_users": manager.get_online_users(),
1484
+ "conversations": conversations,
1485
+ "server_time": int(time.time() * 1000),
1486
  })
1487
 
1488
  await manager.broadcast({
 
1499
  data = await ws.receive_json()
1500
  mtype = data.get("type")
1501
 
1502
+ # ---- keepalive ----
1503
+ if mtype == "ping":
1504
+ await ws.send_json({"type": "pong", "t": data.get("t")})
1505
+ continue
1506
+
1507
+ # ---- create private chat ----
1508
+ if mtype == "create_dm":
1509
+ target_id = data.get("user_id")
1510
+ if not target_id:
1511
+ continue
1512
+ db = await get_db()
1513
+ try:
1514
+ try:
1515
+ summary = await _create_dm(db, uid, int(target_id), manager)
1516
+ if summary is None:
1517
+ continue
1518
+ except HTTPException as e:
1519
+ await ws.send_json({
1520
+ "type": "error", "code": "DM_FAILED",
1521
+ "message": e.detail
1522
+ })
1523
+ continue
1524
+ await ws.send_json({
1525
+ "type": "dm_created",
1526
+ "conversation": summary,
1527
+ })
1528
+ peer = summary.get("peer") or {}
1529
+ online = {u["id"] for u in manager.get_online_users()}
1530
+ # Send the same conversation into the other person's sidebar
1531
+ if peer.get("id"):
1532
+ db2 = await get_db()
1533
+ try:
1534
+ peer_summary = await _build_conversation_summary(
1535
+ db2, summary["id"], peer["id"], online)
1536
+ await manager.send_to_user(peer["id"], {
1537
+ "type": "dm_created",
1538
+ "conversation": peer_summary,
1539
+ })
1540
+ finally:
1541
+ await db2.close()
1542
+ finally:
1543
+ await db.close()
1544
+ continue
1545
+
1546
+ # ---- request conversations ----
1547
+ if mtype == "request_conversations":
1548
+ online = {u["id"] for u in manager.get_online_users()}
1549
+ db = await get_db()
1550
+ try:
1551
+ conversations = await _conversations_for_user(db, uid, online)
1552
+ finally:
1553
+ await db.close()
1554
+ await ws.send_json({"type": "conversations", "conversations": conversations})
1555
+ continue
1556
+
1557
+ # ---- send message ----
1558
  if mtype == "send_message":
1559
+ if not _allow_send(uid):
1560
+ await ws.send_json({"type": "error", "code": "RATE_LIMIT",
1561
+ "message": "You're sending too fast - slow down",
1562
+ "client_id": data.get("client_id")})
1563
+ continue
1564
+ content = (data.get("content") or "").strip()
1565
+ conversation_id = int(data.get("conversation_id") or GLOBAL_CONVERSATION_ID)
1566
  reply_to_id = data.get("reply_to_id")
1567
+ client_id = data.get("client_id")
1568
  file_path = data.get("file_path")
1569
  file_type = data.get("file_type")
1570
  file_name = data.get("file_name")
1571
  file_size = data.get("file_size", 0)
 
1572
 
1573
  if not content and not file_path:
1574
+ await ws.send_json({"type": "error", "code": "EMPTY", "message": "Message cannot be empty",
1575
+ "client_id": client_id})
1576
  continue
1577
+ if len(content) > MAX_MESSAGE_LENGTH:
1578
+ await ws.send_json({"type": "error", "code": "TOO_LONG", "message": "Message too long",
1579
+ "client_id": client_id})
1580
  continue
1581
 
 
 
 
1582
  db = await get_db()
1583
  try:
1584
+ if not await user_in_conversation(db, uid, conversation_id):
1585
+ await ws.send_json({"type": "error", "code": "FORBIDDEN",
1586
+ "message": "You are not in this conversation",
1587
+ "client_id": client_id})
1588
+ continue
1589
+
1590
+ # Idempotent insert: replaying a send after reconnect won't duplicate
1591
+ if client_id:
1592
+ cursor = await db.execute(
1593
+ "SELECT id FROM messages WHERE sender_id = ? AND client_id = ?",
1594
+ (uid, client_id)
1595
+ )
1596
+ existing = await cursor.fetchone()
1597
+ if existing:
1598
+ await ws.send_json({
1599
+ "type": "error", "code": "DUPLICATE",
1600
+ "message": "duplicate_message", "message_id": existing["id"],
1601
+ "client_id": client_id
1602
+ })
1603
+ continue
1604
+
1605
+ encrypted = encrypt_message(content)
1606
+ ts = int(time.time() * 1000)
1607
  cursor = await db.execute(
1608
  """INSERT INTO messages
1609
  (sender_id, encrypted_content, timestamp_ms, reply_to_id,
1610
+ file_path, file_type, file_name, file_size,
1611
+ conversation_id, client_id)
1612
+ VALUES (?,?,?,?,?,?,?,?,?,?)""",
1613
+ (uid, encrypted, ts, reply_to_id, file_path, file_type,
1614
+ file_name, file_size, conversation_id, client_id or None)
1615
  )
1616
  mid = cursor.lastrowid
1617
  await db.commit()
1618
  schedule_db_sync()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1619
 
1620
+ sender = {
1621
+ "sender_username": username,
1622
+ "sender_display_name": display_name,
1623
+ "sender_avatar_path": avatar_path,
1624
+ }
1625
+ msg_obj = {
1626
+ "id": mid,
1627
+ "conversation_id": conversation_id,
1628
+ "sender_id": uid,
1629
+ **sender,
1630
+ "content": content,
1631
+ "timestamp_ms": ts,
1632
+ "reply_to_id": reply_to_id,
1633
+ "file_path": file_path,
1634
+ "file_type": file_type,
1635
+ "file_name": file_name,
1636
+ "file_size": file_size,
1637
+ "is_edited": False,
1638
+ "is_deleted": False,
1639
+ "status": "sent",
1640
+ "readers": [],
1641
+ "reader_count": 0,
1642
+ }
1643
+
1644
+ members = await conversation_member_ids(db, conversation_id)
1645
+ for member_id in members:
1646
+ # send to every connection of the member (multi-tab safe).
1647
+ # the originator gets client_id so it can ack its pending send.
1648
+ await manager.send_to_user(member_id, {
1649
+ "type": "new_message",
1650
+ "message": msg_obj,
1651
+ "client_id": client_id if member_id == uid else None,
1652
+ })
1653
 
1654
+ for member_id in members:
1655
+ if member_id != uid:
1656
+ await _push_conversation_updates(member_id, conversation_id, [])
1657
+ finally:
1658
+ await db.close()
1659
+ continue
1660
 
1661
+ # ---- load messages (cursor pagination) ----
1662
+ if mtype == "load_messages":
1663
+ conversation_id = int(data.get("conversation_id") or GLOBAL_CONVERSATION_ID)
1664
  cursor_id = data.get("cursor")
1665
+ limit = min(int(data.get("limit", 50) or 50), 100)
1666
  db = await get_db()
1667
  try:
1668
+ if not await user_in_conversation(db, uid, conversation_id):
1669
+ await ws.send_json({"type": "error", "code": "FORBIDDEN",
1670
+ "message": "Not in conversation"})
1671
+ continue
1672
  if cursor_id:
1673
  rows = await db.execute(
1674
  """SELECT m.*, u.username, u.display_name, u.avatar_path
1675
  FROM messages m JOIN users u ON m.sender_id = u.id
1676
+ WHERE m.conversation_id = ? AND m.is_deleted = 0 AND m.id < ?
1677
  ORDER BY m.id DESC LIMIT ?""",
1678
+ (conversation_id, cursor_id, limit)
1679
  )
1680
  else:
1681
  rows = await db.execute(
1682
  """SELECT m.*, u.username, u.display_name, u.avatar_path
1683
  FROM messages m JOIN users u ON m.sender_id = u.id
1684
+ WHERE m.conversation_id = ? AND m.is_deleted = 0
1685
  ORDER BY m.id DESC LIMIT ?""",
1686
+ (conversation_id, limit)
1687
  )
1688
 
1689
  msgs = []
1690
  for row in reversed(await rows.fetchall()):
1691
+ decrypted = decrypt_message(row['encrypted_content'])
1692
+ msgs.append(_row_to_message(row, decrypted))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1693
 
1694
+ await _attach_receipt_info(db, msgs, uid)
1695
  next_cursor = msgs[0]['id'] if msgs else None
1696
  await ws.send_json({
1697
  "type": "messages_loaded",
1698
+ "conversation_id": conversation_id,
1699
  "messages": msgs,
1700
  "next_cursor": next_cursor,
1701
  "has_more": len(msgs) == limit
1702
  })
1703
  finally:
1704
  await db.close()
1705
+ continue
1706
 
1707
+ # ---- edit (via REST normally, kept for legacy clients) ----
1708
+ if mtype == "edit_message":
1709
  mid = data.get("message_id")
1710
+ new_content = (data.get("content") or "").strip()
1711
  if not mid or not new_content:
1712
  continue
1713
  db = await get_db()
 
1716
  "SELECT * FROM messages WHERE id = ? AND sender_id = ? AND is_deleted = 0",
1717
  (mid, uid)
1718
  ) as cursor:
1719
+ row = await cursor.fetchone()
1720
+ if not row:
1721
+ await ws.send_json({"type": "error", "code": "NOT_FOUND",
1722
+ "message": "Message not found"})
1723
+ continue
1724
  new_enc = encrypt_message(new_content)
1725
  await db.execute(
1726
  "UPDATE messages SET encrypted_content = ?, is_edited = 1 WHERE id = ?",
 
1728
  )
1729
  await db.commit()
1730
  schedule_db_sync()
1731
+ await _broadcast_to_conversation(db, row['conversation_id'], {
1732
  "type": "message_edited",
1733
  "message_id": mid,
1734
+ "conversation_id": row['conversation_id'],
1735
  "content": new_content,
1736
+ "editor_id": uid,
1737
+ }, exclude_user_id=uid)
1738
+ members = await conversation_member_ids(db, row['conversation_id'])
1739
+ await _push_conversation_updates(uid, row['conversation_id'],
1740
+ [m for m in members if m != uid])
1741
  finally:
1742
  await db.close()
1743
+ continue
1744
 
1745
+ # ---- delete (via REST normally, kept for legacy clients) ----
1746
+ if mtype == "delete_message":
1747
  mid = data.get("message_id")
1748
  if not mid:
1749
  continue
 
1753
  "SELECT * FROM messages WHERE id = ? AND sender_id = ?",
1754
  (mid, uid)
1755
  ) as cursor:
1756
+ row = await cursor.fetchone()
1757
+ if not row:
1758
+ await ws.send_json({"type": "error", "code": "NOT_FOUND",
1759
+ "message": "Message not found"})
1760
  continue
1761
+ if row['file_path']:
1762
  try:
1763
+ await asyncio.to_thread(delete_file, row['file_path'])
1764
  except Exception:
1765
  pass
1766
  await db.execute("DELETE FROM messages WHERE id = ?", (mid,))
1767
  await db.execute("DELETE FROM read_receipts WHERE message_id = ?", (mid,))
1768
  await db.commit()
1769
  schedule_db_sync()
1770
+ await _broadcast_to_conversation(db, row['conversation_id'], {
1771
+ "type": "message_deleted",
1772
+ "message_id": mid,
1773
+ "conversation_id": row['conversation_id'],
1774
+ "deleted_by": uid,
1775
+ })
1776
+ members = await conversation_member_ids(db, row['conversation_id'])
1777
+ await _push_conversation_updates(uid, row['conversation_id'],
1778
+ [m for m in members if m != uid])
1779
  finally:
1780
  await db.close()
1781
+ continue
1782
 
1783
+ # ---- typing (scoped to one conversation) ----
1784
+ if mtype == "typing":
1785
+ conversation_id = int(data.get("conversation_id") or GLOBAL_CONVERSATION_ID)
1786
+ if bool(data.get("is_typing", False)) and not _allow_typing(uid, conversation_id):
1787
+ continue
1788
+ db = await get_db()
1789
+ try:
1790
+ await _broadcast_to_conversation(db, conversation_id, {
1791
+ "type": "typing_indicator",
1792
+ "user_id": uid,
1793
+ "username": username,
1794
+ "display_name": display_name,
1795
+ "is_typing": bool(data.get("is_typing", False)),
1796
+ "conversation_id": conversation_id,
1797
+ }, exclude_user_id=uid)
1798
+ finally:
1799
+ await db.close()
1800
+ continue
1801
 
1802
+ # ---- mark read: instant receipts the moment the tab is open ----
1803
+ if mtype == "mark_read":
1804
  up_to = data.get("up_to_message_id")
1805
+ conversation_id = int(data.get("conversation_id") or GLOBAL_CONVERSATION_ID)
1806
  if not up_to:
1807
  continue
1808
  db = await get_db()
1809
  try:
1810
+ if not await user_in_conversation(db, uid, conversation_id):
1811
+ continue
1812
+
1813
+ # Keep the per-user watermark monotonic
1814
+ cursor = await db.execute(
1815
+ """SELECT last_read_message_id FROM conversation_members
1816
+ WHERE conversation_id = ? AND user_id = ?""",
1817
+ (conversation_id, uid)
1818
+ )
1819
+ row = await cursor.fetchone()
1820
+ watermark = row["last_read_message_id"] if row else 0
1821
+ if int(up_to) <= watermark:
1822
+ continue
1823
+
1824
+ now_ms = int(time.time() * 1000)
1825
+ cursor = await db.execute(
1826
  """SELECT m.id, m.sender_id FROM messages m
1827
+ WHERE m.conversation_id = ? AND m.id > ? AND m.id <= ?
1828
+ AND m.sender_id != ? AND m.is_deleted = 0
1829
+ ORDER BY m.id ASC""",
1830
+ (conversation_id, watermark, up_to, uid)
 
1831
  )
1832
+ rows = await cursor.fetchall()
1833
+ # Watermark still advances to up_to, but only the newest batch
1834
+ # needs per-message receipts (bounds write + notification cost).
1835
+ for r in rows[-5000:]:
1836
  await db.execute(
1837
+ "INSERT OR IGNORE INTO read_receipts (user_id, message_id, read_at) "
1838
+ "VALUES (?,?,?)",
1839
+ (uid, r['id'], now_ms)
1840
  )
1841
+ new_reads = rows[-5000:]
1842
+
1843
+ await db.execute(
1844
+ """INSERT INTO conversation_members (conversation_id, user_id, last_read_message_id)
1845
+ VALUES (?,?,?)
1846
+ ON CONFLICT(conversation_id, user_id)
1847
+ DO UPDATE SET last_read_message_id = excluded.last_read_message_id""",
1848
+ (conversation_id, uid, int(up_to))
1849
+ )
1850
  await db.commit()
1851
  if new_reads:
1852
  schedule_db_sync()
1853
+
1854
+ # Notify each author the instant one of their messages is read
1855
  for r in new_reads:
1856
  await manager.send_to_user(r['sender_id'], {
1857
  "type": "message_read",
1858
  "message_id": r['id'],
1859
+ "conversation_id": conversation_id,
1860
+ "reader": {
1861
+ "user_id": uid,
1862
+ "username": username,
1863
+ "display_name": display_name,
1864
+ "avatar_path": avatar_path,
1865
+ },
1866
+ "read_at": now_ms,
1867
  })
1868
+ await _push_conversation_updates(uid, conversation_id, [])
1869
  finally:
1870
  await db.close()
1871
+ continue
1872
 
1873
+ # ---- online users ----
1874
+ if mtype == "get_online_users":
1875
  await ws.send_json({
1876
  "type": "online_users",
1877
  "users": manager.get_online_users()
1878
  })
1879
+ continue
1880
 
1881
  except WebSocketDisconnect:
1882
  pass
1883
  except Exception as e:
1884
+ logger.error(f"WS error for {username}: {e}", exc_info=True)
1885
  finally:
1886
+ still_online = False
1887
+ # Only mark offline if no other tabs of this user remain connected
1888
+ if uid in manager.active and manager.active[uid]["connections"]:
1889
+ still_online = True
1890
  manager.disconnect(ws, uid)
1891
+ if not still_online:
1892
+ db = await get_db()
1893
+ try:
1894
+ await db.execute(
1895
+ "UPDATE users SET status = 'offline', last_seen = ? WHERE id = ?",
1896
+ (int(time.time()), uid)
1897
+ )
1898
+ await db.commit()
1899
+ schedule_db_sync()
1900
+ finally:
1901
+ await db.close()
1902
+ await manager.broadcast({
1903
+ "type": "user_status",
1904
+ "user_id": uid,
1905
+ "username": username,
1906
+ "display_name": display_name,
1907
+ "avatar_path": avatar_path,
1908
+ "status": "offline"
1909
+ })
1910
+
1911
 
1912
  @app.get("/")
1913
  async def root():