smodusermc commited on
Commit
69abd0a
·
verified ·
1 Parent(s): e117f1c

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +199 -54
app.py CHANGED
@@ -19,7 +19,10 @@ 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
@@ -36,9 +39,17 @@ 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;")
@@ -90,6 +101,9 @@ async def init_database():
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
  # ------------------------------------------------------------------------
@@ -99,9 +113,17 @@ async def init_database():
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
  # ------------------------------------------------------------------------
@@ -114,6 +136,11 @@ async def get_db() -> aiosqlite.Connection:
114
  await conn.execute("PRAGMA foreign_keys=ON")
115
  return conn
116
 
 
 
 
 
 
117
  # ------------------------------------------------------------------------
118
  # Encryption helpers (AES-256-GCM)
119
  # ------------------------------------------------------------------------
@@ -138,7 +165,13 @@ def decrypt_message(encrypted_b64: str) -> str:
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
 
@@ -157,10 +190,16 @@ async def authenticate_user(token: str) -> Optional[dict]:
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
@@ -175,9 +214,13 @@ 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(..., min_length=3, max_length=30), password: str = Query(..., min_length=6), display_name: str = Query(None, max_length=50)):
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:
@@ -187,11 +230,21 @@ async def signup(username: str = Query(..., min_length=3, max_length=30), passwo
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": {"id": None, "username": username, "display_name": display, "avatar_path": None, "status": "online"}}
 
 
 
 
 
 
 
 
 
 
195
  finally:
196
  await db.close()
197
 
@@ -204,8 +257,12 @@ async def login(username: str = Query(...), password: str = Query(...)):
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 {
210
  "token": token,
211
  "user": {
@@ -222,15 +279,20 @@ async def login(username: str = Query(...), password: str = Query(...)):
222
  @app.get("/api/auth/verify")
223
  async def verify_token(token: str = Header(..., alias="X-Auth-Token")):
224
  user = await authenticate_user(token)
225
- if not user: raise HTTPException(401)
 
226
  return {"user": user}
227
 
228
  @app.post("/api/auth/logout")
229
  async def logout(token: str = Header(..., alias="X-Auth-Token")):
230
  db = await get_db()
231
  try:
232
- await db.execute("UPDATE users SET token = NULL, status = 'offline', last_seen = ? WHERE token = ?", (int(time.time()), token))
 
 
 
233
  await db.commit()
 
234
  return {"status": "logged_out"}
235
  finally:
236
  await db.close()
@@ -241,36 +303,53 @@ async def logout(token: str = Header(..., alias="X-Auth-Token")):
241
  @app.get("/api/profile")
242
  async def get_profile(token: str = Header(..., alias="X-Auth-Token")):
243
  user = await authenticate_user(token)
244
- if not user: raise HTTPException(401)
 
245
  return {"user": user}
246
 
247
  @app.patch("/api/profile")
248
- async def update_profile(display_name: str = Query(...), token: str = Header(..., alias="X-Auth-Token")):
 
 
 
249
  user = await authenticate_user(token)
250
- if not user: raise HTTPException(401)
 
251
  db = await get_db()
252
  try:
253
- await db.execute("UPDATE users SET display_name = ? WHERE id = ?", (display_name, user['id']))
 
 
 
254
  await db.commit()
 
255
  user['display_name'] = display_name
256
  return {"user": user}
257
  finally:
258
  await db.close()
259
 
260
  @app.post("/api/profile/avatar")
261
- async def upload_avatar(file: UploadFile = File(...), token: str = Header(..., alias="X-Auth-Token")):
 
 
 
262
  user = await authenticate_user(token)
263
- if not user: raise HTTPException(401)
 
264
  data = await file.read()
265
- if len(data) > 5*1024*1024:
266
- raise HTTPException(400, "Image too large")
267
  ext = os.path.splitext(file.filename)[1] if file.filename else '.jpg'
268
  remote_path = f"avatars/{user['username']}_{uuid.uuid4().hex}{ext}"
269
  store_file(remote_path, data)
270
  db = await get_db()
271
  try:
272
- await db.execute("UPDATE users SET avatar_path = ? WHERE id = ?", (remote_path, user['id']))
 
 
 
273
  await db.commit()
 
274
  finally:
275
  await db.close()
276
  return {"avatar_path": remote_path}
@@ -292,42 +371,78 @@ async def upload_chunk(
292
  token: str = Header(..., alias="X-Auth-Token")
293
  ):
294
  user = await authenticate_user(token)
295
- if not user: raise HTTPException(401)
 
 
296
  if upload_id not in upload_sessions:
297
  upload_sessions[upload_id] = {
298
- "chunks": {}, "filename": file_name, "file_type": file_type,
299
- "file_size": file_size, "total_chunks": total_chunks, "received": 0,
300
- "user_id": user['id'], "created_at": time.time()
 
 
 
 
 
301
  }
 
302
  session = upload_sessions[upload_id]
303
- if session['user_id'] != user['id']: raise HTTPException(403)
 
 
304
  chunk_data = await file.read()
305
  session['chunks'][chunk_index] = chunk_data
306
  session['received'] += 1
 
307
  if session['received'] == total_chunks:
308
  full_data = b"".join(session['chunks'][i] for i in sorted(session['chunks']))
309
  remote_path = f"uploads/{user['username']}/{uuid.uuid4().hex}/{file_name}"
310
  store_file(remote_path, full_data)
311
  del upload_sessions[upload_id]
312
- return {"status": "complete", "file_path": remote_path, "file_name": file_name, "file_type": file_type, "file_size": file_size}
313
- return {"status": "in_progress", "received": session['received'], "total": total_chunks}
 
 
 
 
 
 
 
 
 
 
 
314
 
315
  # ------------------------------------------------------------------------
316
- # File Download
 
317
  # ------------------------------------------------------------------------
318
  @app.get("/api/download/{file_path:path}")
319
- async def download_file(file_path: str, token: str = Header(None, alias="X-Auth-Token"), t: str = Query(None)):
 
 
 
 
320
  auth_token = token or t
321
  user = await authenticate_user(auth_token)
322
- if not user: raise HTTPException(401)
323
- if '..' in file_path: raise HTTPException(400)
 
 
324
  try:
325
  data = retrieve_file(file_path)
326
  except FileNotFoundError:
327
- raise HTTPException(404)
 
 
 
328
  import mimetypes
329
  mt = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
330
- return Response(content=data, media_type=mt)
 
 
 
 
331
 
332
  # ------------------------------------------------------------------------
333
  # WebSocket Manager
@@ -350,7 +465,8 @@ class ConnectionManager:
350
 
351
  async def broadcast(self, msg: dict, exclude_user_id: Optional[int] = None):
352
  for uid, info in list(self.active.items()):
353
- if uid == exclude_user_id: continue
 
354
  for ws in list(info["connections"]):
355
  try:
356
  await ws.send_json(msg)
@@ -366,7 +482,10 @@ class ConnectionManager:
366
  self.disconnect(ws, user_id)
367
 
368
  def get_online_users(self) -> List[Dict]:
369
- return [{"id": uid, "username": info["username"]} for uid, info in self.active.items()]
 
 
 
370
 
371
  def is_online(self, user_id: int) -> bool:
372
  return user_id in self.active
@@ -390,7 +509,6 @@ async def ws_endpoint(ws: WebSocket, token: str = Query(...)):
390
 
391
  await manager.connect(ws, uid, username)
392
 
393
- # Send initial data
394
  await ws.send_json({
395
  "type": "connection_established",
396
  "user_id": uid,
@@ -400,7 +518,6 @@ async def ws_endpoint(ws: WebSocket, token: str = Query(...)):
400
  "online_users": manager.get_online_users()
401
  })
402
 
403
- # Broadcast new online status
404
  await manager.broadcast({
405
  "type": "user_status",
406
  "user_id": uid,
@@ -426,10 +543,18 @@ async def ws_endpoint(ws: WebSocket, token: str = Query(...)):
426
  client_id = data.get("client_id")
427
 
428
  if not content and not file_path:
429
- await ws.send_json({"type": "error", "code": "EMPTY", "message": "Message cannot be empty"})
 
 
 
 
430
  continue
431
  if len(content) > 10000:
432
- await ws.send_json({"type": "error", "code": "TOO_LONG", "message": "Message too long"})
 
 
 
 
433
  continue
434
 
435
  encrypted = encrypt_message(content)
@@ -438,11 +563,16 @@ async def ws_endpoint(ws: WebSocket, token: str = Query(...)):
438
  db = await get_db()
439
  try:
440
  cursor = await db.execute(
441
- "INSERT INTO messages (sender_id, encrypted_content, timestamp_ms, reply_to_id, file_path, file_type, file_name, file_size) VALUES (?,?,?,?,?,?,?,?)",
442
- (uid, encrypted, ts, reply_to_id, file_path, file_type, file_name, file_size)
 
 
 
 
443
  )
444
  mid = cursor.lastrowid
445
  await db.commit()
 
446
  finally:
447
  await db.close()
448
 
@@ -464,14 +594,12 @@ async def ws_endpoint(ws: WebSocket, token: str = Query(...)):
464
  "status": "sent"
465
  }
466
 
467
- # Confirm to sender with delivered status
468
  await ws.send_json({
469
  "type": "new_message",
470
  "message": {**msg_obj, "status": "delivered"},
471
  "client_id": client_id
472
  })
473
 
474
- # Broadcast to others
475
  await manager.broadcast({
476
  "type": "new_message",
477
  "message": msg_obj
@@ -487,24 +615,26 @@ async def ws_endpoint(ws: WebSocket, token: str = Query(...)):
487
  rows = await db.execute(
488
  """SELECT m.*, u.username, u.display_name, u.avatar_path
489
  FROM messages m JOIN users u ON m.sender_id = u.id
490
- WHERE m.is_deleted = 0 AND m.id < ? ORDER BY m.id DESC LIMIT ?""",
 
491
  (cursor_id, limit)
492
  )
493
  else:
494
  rows = await db.execute(
495
  """SELECT m.*, u.username, u.display_name, u.avatar_path
496
  FROM messages m JOIN users u ON m.sender_id = u.id
497
- WHERE m.is_deleted = 0 ORDER BY m.id DESC LIMIT ?""",
 
498
  (limit,)
499
  )
 
500
  msgs = []
501
  for row in reversed(await rows.fetchall()):
502
  try:
503
  decrypted = decrypt_message(row['encrypted_content'])
504
- except:
505
  decrypted = "[Decryption Error]"
506
 
507
- # Determine read status
508
  read_cursor = await db.execute(
509
  "SELECT COUNT(*) as cnt FROM read_receipts WHERE message_id = ?",
510
  (row['id'],)
@@ -552,7 +682,11 @@ async def ws_endpoint(ws: WebSocket, token: str = Query(...)):
552
  (mid, uid)
553
  ) as cursor:
554
  if not await cursor.fetchone():
555
- await ws.send_json({"type": "error", "code": "NOT_FOUND", "message": "Message not found"})
 
 
 
 
556
  continue
557
  new_enc = encrypt_message(new_content)
558
  await db.execute(
@@ -560,6 +694,7 @@ async def ws_endpoint(ws: WebSocket, token: str = Query(...)):
560
  (new_enc, mid)
561
  )
562
  await db.commit()
 
563
  await manager.broadcast({
564
  "type": "message_edited",
565
  "message_id": mid,
@@ -582,16 +717,21 @@ async def ws_endpoint(ws: WebSocket, token: str = Query(...)):
582
  ) as cursor:
583
  msg = await cursor.fetchone()
584
  if not msg:
585
- await ws.send_json({"type": "error", "code": "NOT_FOUND", "message": "Message not found"})
 
 
 
 
586
  continue
587
  if msg['file_path']:
588
  try:
589
  delete_file(msg['file_path'])
590
- except:
591
  pass
592
  await db.execute("DELETE FROM messages WHERE id = ?", (mid,))
593
  await db.execute("DELETE FROM read_receipts WHERE message_id = ?", (mid,))
594
  await db.commit()
 
595
  await manager.broadcast({
596
  "type": "message_deleted",
597
  "message_id": mid
@@ -620,7 +760,9 @@ async def ws_endpoint(ws: WebSocket, token: str = Query(...)):
620
  rows = await db.execute(
621
  """SELECT m.id, m.sender_id FROM messages m
622
  WHERE m.id <= ? AND m.sender_id != ? AND m.is_deleted = 0
623
- AND m.id NOT IN (SELECT message_id FROM read_receipts WHERE user_id = ?)""",
 
 
624
  (up_to, uid, uid)
625
  )
626
  new_reads = []
@@ -631,6 +773,8 @@ async def ws_endpoint(ws: WebSocket, token: str = Query(...)):
631
  )
632
  new_reads.append(r)
633
  await db.commit()
 
 
634
  for r in new_reads:
635
  await manager.send_to_user(r['sender_id'], {
636
  "type": "message_read",
@@ -660,6 +804,7 @@ async def ws_endpoint(ws: WebSocket, token: str = Query(...)):
660
  (int(time.time()), uid)
661
  )
662
  await db.commit()
 
663
  finally:
664
  await db.close()
665
  await manager.broadcast({
 
19
  from cryptography.hazmat.primitives import hashes
20
  from cryptography.hazmat.backends import default_backend
21
 
22
+ from storage_handler import (
23
+ store_file, retrieve_file, delete_file, file_exists,
24
+ download_database, upload_database, start_db_sync
25
+ )
26
 
27
  # ------------------------------------------------------------------------
28
  # Configuration
 
39
  logger = logging.getLogger("InfinityChat")
40
 
41
  # ------------------------------------------------------------------------
42
+ # Database initialization
43
  # ------------------------------------------------------------------------
44
  async def init_database():
45
+ os.makedirs(os.path.dirname(DATABASE_URL), exist_ok=True)
46
+
47
+ db_existed = download_database(DATABASE_URL)
48
+ if db_existed:
49
+ logger.info("✅ Restored database from bucket")
50
+ else:
51
+ logger.info("🆕 Starting with fresh database")
52
+
53
  async with aiosqlite.connect(DATABASE_URL) as db:
54
  db.row_factory = aiosqlite.Row
55
  await db.execute("PRAGMA journal_mode=WAL;")
 
101
  await db.execute("CREATE INDEX IF NOT EXISTS idx_messages_not_deleted ON messages(is_deleted, id)")
102
  await db.execute("CREATE INDEX IF NOT EXISTS idx_receipts_user_msg ON read_receipts(user_id, message_id)")
103
  await db.commit()
104
+
105
+ upload_database(DATABASE_URL)
106
+ start_db_sync(DATABASE_URL)
107
  logger.info("✅ Database initialized successfully")
108
 
109
  # ------------------------------------------------------------------------
 
113
  async def lifespan(app: FastAPI):
114
  await init_database()
115
  yield
116
+ logger.info("🔄 Final database sync on shutdown...")
117
+ upload_database(DATABASE_URL)
118
 
119
  app = FastAPI(title="InfinityChat", version="1.0.0", lifespan=lifespan)
120
+ app.add_middleware(
121
+ CORSMiddleware,
122
+ allow_origins=["*"],
123
+ allow_credentials=True,
124
+ allow_methods=["*"],
125
+ allow_headers=["*"]
126
+ )
127
  app.mount("/static", StaticFiles(directory="static"), name="static")
128
 
129
  # ------------------------------------------------------------------------
 
136
  await conn.execute("PRAGMA foreign_keys=ON")
137
  return conn
138
 
139
+ def schedule_db_sync():
140
+ import threading
141
+ t = threading.Thread(target=upload_database, args=(DATABASE_URL,), daemon=True)
142
+ t.start()
143
+
144
  # ------------------------------------------------------------------------
145
  # Encryption helpers (AES-256-GCM)
146
  # ------------------------------------------------------------------------
 
165
  def hash_password(password: str, salt: Optional[str] = None) -> tuple[str, str]:
166
  if salt is None:
167
  salt = os.urandom(32).hex()
168
+ kdf = PBKDF2HMAC(
169
+ algorithm=hashes.SHA256(),
170
+ length=32,
171
+ salt=salt.encode(),
172
+ iterations=600000,
173
+ backend=default_backend()
174
+ )
175
  key = base64.urlsafe_b64encode(kdf.derive(password.encode())).decode()
176
  return key, salt
177
 
 
190
  return None
191
  db = await get_db()
192
  try:
193
+ cursor = await db.execute(
194
+ "SELECT id, username, display_name, avatar_path, status FROM users WHERE token = ?",
195
+ (token,)
196
+ )
197
  user = await cursor.fetchone()
198
  if user:
199
+ await db.execute(
200
+ "UPDATE users SET last_seen = ? WHERE id = ?",
201
+ (int(time.time()), user['id'])
202
+ )
203
  await db.commit()
204
  return dict(user)
205
  return None
 
214
  return {"status": "healthy", "timestamp": int(time.time()), "version": "1.0.0"}
215
 
216
  @app.post("/api/auth/signup")
217
+ async def signup(
218
+ username: str = Query(..., min_length=3, max_length=30),
219
+ password: str = Query(..., min_length=6),
220
+ display_name: str = Query(None, max_length=50)
221
+ ):
222
+ if not username.replace('_', '').isalnum():
223
+ raise HTTPException(400, "Invalid username - only letters, numbers and underscores allowed")
224
  db = await get_db()
225
  try:
226
  async with db.execute("SELECT id FROM users WHERE username = ?", (username.lower(),)) as cursor:
 
230
  token = generate_token()
231
  display = display_name or username
232
  await db.execute(
233
+ "INSERT INTO users (username, display_name, password_hash, salt, token, status) VALUES (?,?,?,?,?,'online')",
234
  (username.lower(), display, pwd_hash, salt, token)
235
  )
236
  await db.commit()
237
+ schedule_db_sync()
238
+ return {
239
+ "token": token,
240
+ "user": {
241
+ "id": None,
242
+ "username": username.lower(),
243
+ "display_name": display,
244
+ "avatar_path": None,
245
+ "status": "online"
246
+ }
247
+ }
248
  finally:
249
  await db.close()
250
 
 
257
  if not user or not verify_password(password, user['salt'], user['password_hash']):
258
  raise HTTPException(401, "Invalid credentials")
259
  token = generate_token()
260
+ await db.execute(
261
+ "UPDATE users SET token = ?, last_seen = ?, status = 'online' WHERE id = ?",
262
+ (token, int(time.time()), user['id'])
263
+ )
264
  await db.commit()
265
+ schedule_db_sync()
266
  return {
267
  "token": token,
268
  "user": {
 
279
  @app.get("/api/auth/verify")
280
  async def verify_token(token: str = Header(..., alias="X-Auth-Token")):
281
  user = await authenticate_user(token)
282
+ if not user:
283
+ raise HTTPException(401)
284
  return {"user": user}
285
 
286
  @app.post("/api/auth/logout")
287
  async def logout(token: str = Header(..., alias="X-Auth-Token")):
288
  db = await get_db()
289
  try:
290
+ await db.execute(
291
+ "UPDATE users SET token = NULL, status = 'offline', last_seen = ? WHERE token = ?",
292
+ (int(time.time()), token)
293
+ )
294
  await db.commit()
295
+ schedule_db_sync()
296
  return {"status": "logged_out"}
297
  finally:
298
  await db.close()
 
303
  @app.get("/api/profile")
304
  async def get_profile(token: str = Header(..., alias="X-Auth-Token")):
305
  user = await authenticate_user(token)
306
+ if not user:
307
+ raise HTTPException(401)
308
  return {"user": user}
309
 
310
  @app.patch("/api/profile")
311
+ async def update_profile(
312
+ display_name: str = Query(...),
313
+ token: str = Header(..., alias="X-Auth-Token")
314
+ ):
315
  user = await authenticate_user(token)
316
+ if not user:
317
+ raise HTTPException(401)
318
  db = await get_db()
319
  try:
320
+ await db.execute(
321
+ "UPDATE users SET display_name = ? WHERE id = ?",
322
+ (display_name, user['id'])
323
+ )
324
  await db.commit()
325
+ schedule_db_sync()
326
  user['display_name'] = display_name
327
  return {"user": user}
328
  finally:
329
  await db.close()
330
 
331
  @app.post("/api/profile/avatar")
332
+ async def upload_avatar(
333
+ file: UploadFile = File(...),
334
+ token: str = Header(..., alias="X-Auth-Token")
335
+ ):
336
  user = await authenticate_user(token)
337
+ if not user:
338
+ raise HTTPException(401)
339
  data = await file.read()
340
+ if len(data) > 5 * 1024 * 1024:
341
+ raise HTTPException(400, "Image too large (max 5MB)")
342
  ext = os.path.splitext(file.filename)[1] if file.filename else '.jpg'
343
  remote_path = f"avatars/{user['username']}_{uuid.uuid4().hex}{ext}"
344
  store_file(remote_path, data)
345
  db = await get_db()
346
  try:
347
+ await db.execute(
348
+ "UPDATE users SET avatar_path = ? WHERE id = ?",
349
+ (remote_path, user['id'])
350
+ )
351
  await db.commit()
352
+ schedule_db_sync()
353
  finally:
354
  await db.close()
355
  return {"avatar_path": remote_path}
 
371
  token: str = Header(..., alias="X-Auth-Token")
372
  ):
373
  user = await authenticate_user(token)
374
+ if not user:
375
+ raise HTTPException(401)
376
+
377
  if upload_id not in upload_sessions:
378
  upload_sessions[upload_id] = {
379
+ "chunks": {},
380
+ "filename": file_name,
381
+ "file_type": file_type,
382
+ "file_size": file_size,
383
+ "total_chunks": total_chunks,
384
+ "received": 0,
385
+ "user_id": user['id'],
386
+ "created_at": time.time()
387
  }
388
+
389
  session = upload_sessions[upload_id]
390
+ if session['user_id'] != user['id']:
391
+ raise HTTPException(403)
392
+
393
  chunk_data = await file.read()
394
  session['chunks'][chunk_index] = chunk_data
395
  session['received'] += 1
396
+
397
  if session['received'] == total_chunks:
398
  full_data = b"".join(session['chunks'][i] for i in sorted(session['chunks']))
399
  remote_path = f"uploads/{user['username']}/{uuid.uuid4().hex}/{file_name}"
400
  store_file(remote_path, full_data)
401
  del upload_sessions[upload_id]
402
+ return {
403
+ "status": "complete",
404
+ "file_path": remote_path,
405
+ "file_name": file_name,
406
+ "file_type": file_type,
407
+ "file_size": file_size
408
+ }
409
+
410
+ return {
411
+ "status": "in_progress",
412
+ "received": session['received'],
413
+ "total": total_chunks
414
+ }
415
 
416
  # ------------------------------------------------------------------------
417
+ # File Download - accepts token as header OR ?t= query param
418
+ # so <img src> and window.open() work without fetch headers
419
  # ------------------------------------------------------------------------
420
  @app.get("/api/download/{file_path:path}")
421
+ async def download_file(
422
+ file_path: str,
423
+ token: str = Header(None, alias="X-Auth-Token"),
424
+ t: str = Query(None)
425
+ ):
426
  auth_token = token or t
427
  user = await authenticate_user(auth_token)
428
+ if not user:
429
+ raise HTTPException(401)
430
+ if '..' in file_path:
431
+ raise HTTPException(400, "Invalid path")
432
  try:
433
  data = retrieve_file(file_path)
434
  except FileNotFoundError:
435
+ raise HTTPException(404, "File not found")
436
+ except Exception as e:
437
+ logger.error(f"Download error for {file_path}: {e}")
438
+ raise HTTPException(500, "Failed to retrieve file")
439
  import mimetypes
440
  mt = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
441
+ return Response(
442
+ content=data,
443
+ media_type=mt,
444
+ headers={"Cache-Control": "private, max-age=3600"}
445
+ )
446
 
447
  # ------------------------------------------------------------------------
448
  # WebSocket Manager
 
465
 
466
  async def broadcast(self, msg: dict, exclude_user_id: Optional[int] = None):
467
  for uid, info in list(self.active.items()):
468
+ if uid == exclude_user_id:
469
+ continue
470
  for ws in list(info["connections"]):
471
  try:
472
  await ws.send_json(msg)
 
482
  self.disconnect(ws, user_id)
483
 
484
  def get_online_users(self) -> List[Dict]:
485
+ return [
486
+ {"id": uid, "username": info["username"]}
487
+ for uid, info in self.active.items()
488
+ ]
489
 
490
  def is_online(self, user_id: int) -> bool:
491
  return user_id in self.active
 
509
 
510
  await manager.connect(ws, uid, username)
511
 
 
512
  await ws.send_json({
513
  "type": "connection_established",
514
  "user_id": uid,
 
518
  "online_users": manager.get_online_users()
519
  })
520
 
 
521
  await manager.broadcast({
522
  "type": "user_status",
523
  "user_id": uid,
 
543
  client_id = data.get("client_id")
544
 
545
  if not content and not file_path:
546
+ await ws.send_json({
547
+ "type": "error",
548
+ "code": "EMPTY",
549
+ "message": "Message cannot be empty"
550
+ })
551
  continue
552
  if len(content) > 10000:
553
+ await ws.send_json({
554
+ "type": "error",
555
+ "code": "TOO_LONG",
556
+ "message": "Message too long"
557
+ })
558
  continue
559
 
560
  encrypted = encrypt_message(content)
 
563
  db = await get_db()
564
  try:
565
  cursor = await db.execute(
566
+ """INSERT INTO messages
567
+ (sender_id, encrypted_content, timestamp_ms, reply_to_id,
568
+ file_path, file_type, file_name, file_size)
569
+ VALUES (?,?,?,?,?,?,?,?)""",
570
+ (uid, encrypted, ts, reply_to_id,
571
+ file_path, file_type, file_name, file_size)
572
  )
573
  mid = cursor.lastrowid
574
  await db.commit()
575
+ schedule_db_sync()
576
  finally:
577
  await db.close()
578
 
 
594
  "status": "sent"
595
  }
596
 
 
597
  await ws.send_json({
598
  "type": "new_message",
599
  "message": {**msg_obj, "status": "delivered"},
600
  "client_id": client_id
601
  })
602
 
 
603
  await manager.broadcast({
604
  "type": "new_message",
605
  "message": msg_obj
 
615
  rows = await db.execute(
616
  """SELECT m.*, u.username, u.display_name, u.avatar_path
617
  FROM messages m JOIN users u ON m.sender_id = u.id
618
+ WHERE m.is_deleted = 0 AND m.id < ?
619
+ ORDER BY m.id DESC LIMIT ?""",
620
  (cursor_id, limit)
621
  )
622
  else:
623
  rows = await db.execute(
624
  """SELECT m.*, u.username, u.display_name, u.avatar_path
625
  FROM messages m JOIN users u ON m.sender_id = u.id
626
+ WHERE m.is_deleted = 0
627
+ ORDER BY m.id DESC LIMIT ?""",
628
  (limit,)
629
  )
630
+
631
  msgs = []
632
  for row in reversed(await rows.fetchall()):
633
  try:
634
  decrypted = decrypt_message(row['encrypted_content'])
635
+ except Exception:
636
  decrypted = "[Decryption Error]"
637
 
 
638
  read_cursor = await db.execute(
639
  "SELECT COUNT(*) as cnt FROM read_receipts WHERE message_id = ?",
640
  (row['id'],)
 
682
  (mid, uid)
683
  ) as cursor:
684
  if not await cursor.fetchone():
685
+ await ws.send_json({
686
+ "type": "error",
687
+ "code": "NOT_FOUND",
688
+ "message": "Message not found"
689
+ })
690
  continue
691
  new_enc = encrypt_message(new_content)
692
  await db.execute(
 
694
  (new_enc, mid)
695
  )
696
  await db.commit()
697
+ schedule_db_sync()
698
  await manager.broadcast({
699
  "type": "message_edited",
700
  "message_id": mid,
 
717
  ) as cursor:
718
  msg = await cursor.fetchone()
719
  if not msg:
720
+ await ws.send_json({
721
+ "type": "error",
722
+ "code": "NOT_FOUND",
723
+ "message": "Message not found"
724
+ })
725
  continue
726
  if msg['file_path']:
727
  try:
728
  delete_file(msg['file_path'])
729
+ except Exception:
730
  pass
731
  await db.execute("DELETE FROM messages WHERE id = ?", (mid,))
732
  await db.execute("DELETE FROM read_receipts WHERE message_id = ?", (mid,))
733
  await db.commit()
734
+ schedule_db_sync()
735
  await manager.broadcast({
736
  "type": "message_deleted",
737
  "message_id": mid
 
760
  rows = await db.execute(
761
  """SELECT m.id, m.sender_id FROM messages m
762
  WHERE m.id <= ? AND m.sender_id != ? AND m.is_deleted = 0
763
+ AND m.id NOT IN (
764
+ SELECT message_id FROM read_receipts WHERE user_id = ?
765
+ )""",
766
  (up_to, uid, uid)
767
  )
768
  new_reads = []
 
773
  )
774
  new_reads.append(r)
775
  await db.commit()
776
+ if new_reads:
777
+ schedule_db_sync()
778
  for r in new_reads:
779
  await manager.send_to_user(r['sender_id'], {
780
  "type": "message_read",
 
804
  (int(time.time()), uid)
805
  )
806
  await db.commit()
807
+ schedule_db_sync()
808
  finally:
809
  await db.close()
810
  await manager.broadcast({