Spaces:
Running
Running
Update app.py
Browse files
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
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
# ------------------------------------------------------------------------
|
| 25 |
# Configuration
|
|
@@ -36,9 +39,17 @@ logging.basicConfig(level=logging.INFO)
|
|
| 36 |
logger = logging.getLogger("InfinityChat")
|
| 37 |
|
| 38 |
# ------------------------------------------------------------------------
|
| 39 |
-
# Database initialization
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 161 |
user = await cursor.fetchone()
|
| 162 |
if user:
|
| 163 |
-
await db.execute(
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 179 |
-
|
| 180 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 (?,?,?,?,?,
|
| 191 |
(username.lower(), display, pwd_hash, salt, token)
|
| 192 |
)
|
| 193 |
await db.commit()
|
| 194 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
|
| 245 |
return {"user": user}
|
| 246 |
|
| 247 |
@app.patch("/api/profile")
|
| 248 |
-
async def update_profile(
|
|
|
|
|
|
|
|
|
|
| 249 |
user = await authenticate_user(token)
|
| 250 |
-
if not user:
|
|
|
|
| 251 |
db = await get_db()
|
| 252 |
try:
|
| 253 |
-
await db.execute(
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 262 |
user = await authenticate_user(token)
|
| 263 |
-
if not user:
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
|
|
|
|
| 296 |
if upload_id not in upload_sessions:
|
| 297 |
upload_sessions[upload_id] = {
|
| 298 |
-
"chunks": {},
|
| 299 |
-
"
|
| 300 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
}
|
|
|
|
| 302 |
session = upload_sessions[upload_id]
|
| 303 |
-
if session['user_id'] != user['id']:
|
|
|
|
|
|
|
| 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 {
|
| 313 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
|
| 315 |
# ------------------------------------------------------------------------
|
| 316 |
-
# File Download
|
|
|
|
| 317 |
# ------------------------------------------------------------------------
|
| 318 |
@app.get("/api/download/{file_path:path}")
|
| 319 |
-
async def download_file(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
auth_token = token or t
|
| 321 |
user = await authenticate_user(auth_token)
|
| 322 |
-
if not user:
|
| 323 |
-
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
|
| 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 [
|
|
|
|
|
|
|
|
|
|
| 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({
|
|
|
|
|
|
|
|
|
|
|
|
|
| 430 |
continue
|
| 431 |
if len(content) > 10000:
|
| 432 |
-
await ws.send_json({
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 442 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 < ?
|
|
|
|
| 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
|
|
|
|
| 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({
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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({
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 (
|
|
|
|
|
|
|
| 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({
|