smodusermc commited on
Commit
a581495
·
verified ·
1 Parent(s): a1d84eb

Update app.py

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