SalexAI commited on
Commit
2662ce1
·
verified ·
1 Parent(s): c50185e

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +206 -490
app.py CHANGED
@@ -1,10 +1,8 @@
1
  from fastapi import FastAPI, Request, UploadFile, File, Form
2
  from fastapi.middleware.cors import CORSMiddleware
3
- from fastapi.staticfiles import StaticFiles
4
- from fastapi.responses import FileResponse
5
  from pydantic import BaseModel, Field
6
- from typing import Optional, List, Dict, Any
7
- import httpx
8
  import json
9
  import logging
10
  import os
@@ -12,17 +10,13 @@ import asyncio
12
  import hashlib
13
  from datetime import datetime, timezone
14
  import re
15
- import fastapi
16
  import base64
17
  import imghdr
18
- import mimetypes
19
- import pathlib
20
- import uuid
21
 
22
  app = FastAPI()
23
  logging.basicConfig(level=logging.INFO)
24
 
25
- # Enable CORS for convenience (adjust origins for production)
26
  app.add_middleware(
27
  CORSMiddleware,
28
  allow_origins=["*"],
@@ -35,7 +29,6 @@ app.add_middleware(
35
  # ---------------------------
36
  PERSISTENT_ROOT = os.environ.get("PERSISTENT_DIR", "/data")
37
  if not os.path.isdir(PERSISTENT_ROOT):
38
- # fallback to local data folder for dev
39
  PERSISTENT_ROOT = os.path.join(".", "data")
40
  os.makedirs(PERSISTENT_ROOT, exist_ok=True)
41
 
@@ -46,29 +39,20 @@ USERS_DIR = os.path.join(PERSISTENT_ROOT, "users")
46
  os.makedirs(USERS_DIR, exist_ok=True)
47
  USERS_FILE = os.path.join(USERS_DIR, "users.jsonl")
48
  SESSIONS_FILE = os.path.join(USERS_DIR, "sessions.json")
49
- ROOMS_FILE = os.path.join(USERS_DIR, "rooms.json") # rooms persisted here
50
 
51
- STATIC_DIR = os.path.join(PERSISTENT_ROOT, "static")
52
- IMAGES_DIR = os.path.join(STATIC_DIR, "images")
53
- os.makedirs(IMAGES_DIR, exist_ok=True)
54
-
55
- ADMIN_KEY = os.environ.get("ADMIN_KEY", "") # set as secret in HF Space
56
-
57
- # Mount static files (serves uploaded images at /static/images/...)
58
- app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
59
-
60
- # Async locks
61
  app.state.chat_locks = {}
62
  app.state.users_lock = asyncio.Lock()
63
  app.state.sessions_lock = asyncio.Lock()
64
- app.state.rooms_lock = asyncio.Lock()
65
  app.state.sessions: Dict[str, str] = {}
66
 
 
67
  # ---------------------------
68
- # Helpers: filenames, locks, time
69
  # ---------------------------
70
- def _chat_file_for(room_code: str) -> str:
71
- h = hashlib.sha256(room_code.encode("utf-8")).hexdigest()[:32]
72
  return os.path.join(CHAT_DIR, f"{h}.jsonl")
73
 
74
  def _lock_for(path: str) -> asyncio.Lock:
@@ -87,15 +71,13 @@ def _safe_handle(s: str) -> str:
87
  return s[:24]
88
 
89
  def _hash_pin(pin: str) -> str:
90
- return hashlib.sha256(("tlks|" + pin).encode("utf-8")).hexdigest()
91
 
92
  def _rand_token() -> str:
93
  return hashlib.sha256(os.urandom(32)).hexdigest()
94
 
95
- # ---------------------------
96
- # JSONL read/write helpers
97
- # ---------------------------
98
- async def _append_jsonl(path: str, record: dict) -> None:
99
  line = json.dumps(record, ensure_ascii=False) + "\n"
100
  def _write():
101
  with open(path, "a", encoding="utf-8") as f:
@@ -105,149 +87,74 @@ async def _append_jsonl(path: str, record: dict) -> None:
105
  async def _read_jsonl(path: str) -> List[dict]:
106
  if not os.path.exists(path):
107
  return []
108
- def _read():
109
  with open(path, "r", encoding="utf-8") as f:
110
  return [json.loads(x) for x in f if x.strip()]
111
- return await asyncio.to_thread(_read)
112
-
113
- # ---------------------------
114
- # Users & sessions
115
- # ---------------------------
116
- async def _append_user(record: dict) -> None:
117
- line = json.dumps(record, ensure_ascii=False) + "\n"
118
- async with app.state.users_lock:
119
- def _write():
120
- with open(USERS_FILE, "a", encoding="utf-8") as f:
121
- f.write(line)
122
- await asyncio.to_thread(_write)
123
 
 
124
  async def _read_users() -> List[dict]:
125
  if not os.path.exists(USERS_FILE):
126
  return []
 
 
 
127
  async with app.state.users_lock:
128
- def _read():
129
- with open(USERS_FILE, "r", encoding="utf-8") as f:
130
- return [json.loads(x) for x in f if x.strip()]
131
- return await asyncio.to_thread(_read)
132
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  async def _load_sessions():
134
  if os.path.exists(SESSIONS_FILE):
135
- def _read():
136
  with open(SESSIONS_FILE, "r", encoding="utf-8") as f:
137
  return json.load(f)
138
- app.state.sessions = await asyncio.to_thread(_read)
139
  else:
140
  app.state.sessions = {}
141
 
142
  async def _save_sessions():
143
- def _write(data):
144
  with open(SESSIONS_FILE, "w", encoding="utf-8") as f:
145
  json.dump(data, f)
146
  async with app.state.sessions_lock:
147
- await asyncio.to_thread(_write, app.state.sessions)
148
 
149
  @app.on_event("startup")
150
- async def _startup_load_sessions():
151
  await _load_sessions()
152
- # ensure ROOMS_FILE exists with a default public 'main' room
153
- async with app.state.rooms_lock:
154
- if not os.path.exists(ROOMS_FILE):
155
- default = [
156
- {"code": "main", "name": "General", "private": False, "invite_code": "", "members": []}
157
- ]
158
- def _write():
159
- with open(ROOMS_FILE, "w", encoding="utf-8") as f:
160
- json.dump(default, f)
161
- await asyncio.to_thread(_write)
162
 
163
- # ---------------------------
164
- # Room persistence helpers
165
- # ---------------------------
166
- async def _read_rooms() -> List[dict]:
167
- async with app.state.rooms_lock:
168
- if not os.path.exists(ROOMS_FILE):
169
- return []
170
- def _read():
171
- with open(ROOMS_FILE, "r", encoding="utf-8") as f:
172
- return json.load(f)
173
- return await asyncio.to_thread(_read)
174
-
175
- async def _save_rooms(rooms: List[dict]) -> None:
176
- async with app.state.rooms_lock:
177
- def _write(r):
178
- with open(ROOMS_FILE, "w", encoding="utf-8") as f:
179
- json.dump(r, f, ensure_ascii=False, indent=2)
180
- await asyncio.to_thread(_write, rooms)
181
 
 
182
  def _require_admin(request: Request):
183
  provided = request.headers.get("x-admin-key", "")
184
  if not ADMIN_KEY or provided != ADMIN_KEY:
185
  raise fastapi.HTTPException(status_code=401, detail="Admin key required")
186
 
187
- # ---------------------------
188
- # Image helpers (save data-URL or UploadFile)
189
- # ---------------------------
190
- def _guess_ext_from_mime(mime: str) -> str:
191
- ext = mimetypes.guess_extension(mime.split(";")[0].strip()) or ""
192
- if ext == ".jpe":
193
- ext = ".jpg"
194
- return ext
195
-
196
- def _safe_image_filename(data_bytes: bytes, ext: str) -> str:
197
- h = hashlib.sha256(data_bytes).hexdigest()[:20]
198
- filename = f"{h}{ext or ''}"
199
- return filename
200
-
201
- async def save_data_url(data_url: str) -> str:
202
- """
203
- Accept data URL (data:<mime>;base64,<data>) and save to IMAGES_DIR.
204
- Returns path relative to /static (e.g. /static/images/<file>).
205
- """
206
- if not data_url.startswith("data:"):
207
- raise ValueError("Not a data URL")
208
- header, b64 = data_url.split(",", 1)
209
- # header e.g. "data:image/png;base64"
210
- try:
211
- payload = base64.b64decode(b64)
212
- except Exception as e:
213
- raise ValueError("Bad base64") from e
214
-
215
- mime_match = header.split(";")[0].split(":", 1)[-1] if ";" in header else header.split(":", 1)[-1]
216
- ext = _guess_ext_from_mime(mime_match) or (("." + imghdr.what(None, payload)) if imghdr.what(None, payload) else ".jpg")
217
- fname = _safe_image_filename(payload, ext)
218
- out_path = os.path.join(IMAGES_DIR, fname)
219
- # save
220
- def _write():
221
- with open(out_path, "wb") as f:
222
- f.write(payload)
223
- await asyncio.to_thread(_write)
224
- return f"/static/images/{fname}"
225
-
226
- async def save_upload_file(upload: UploadFile) -> str:
227
- contents = await upload.read()
228
- ext = ""
229
- # try from content-type
230
- if upload.content_type:
231
- ext = _guess_ext_from_mime(upload.content_type)
232
- if not ext:
233
- # fallback to imghdr to detect
234
- kind = imghdr.what(None, contents)
235
- ext = f".{kind}" if kind else ".jpg"
236
- fname = _safe_image_filename(contents, ext)
237
- out_path = os.path.join(IMAGES_DIR, fname)
238
- def _write():
239
- with open(out_path, "wb") as f:
240
- f.write(contents)
241
- await asyncio.to_thread(_write)
242
- return f"/static/images/{fname}"
243
 
244
  # ---------------------------
245
  # Models
246
  # ---------------------------
247
- class NewMessage(BaseModel):
248
- text: str = Field(..., min_length=1, max_length=5000)
249
- images: Optional[List[str]] = None # can be data-URLs or already-servable URLs
250
-
251
  class NewUser(BaseModel):
252
  email: str
253
  first_name: str
@@ -257,6 +164,7 @@ class NewUser(BaseModel):
257
  profile_image: Optional[str] = ""
258
  description: Optional[str] = ""
259
  pin: str = Field(..., min_length=3, max_length=32)
 
260
 
261
  class UpdateUser(BaseModel):
262
  first_name: Optional[str] = None
@@ -266,17 +174,23 @@ class UpdateUser(BaseModel):
266
  description: Optional[str] = None
267
  pin: Optional[str] = None
268
  disabled: Optional[bool] = None
 
269
 
270
  class LoginReq(BaseModel):
271
  handle: str
272
  pin: str
273
 
 
 
 
 
 
274
  # ---------------------------
275
  # Auth utilities
276
  # ---------------------------
277
  async def _require_user(request: Request) -> dict:
278
  auth = request.headers.get("authorization", "")
279
- if not auth.lower().startswith("bearer "):
280
  raise fastapi.HTTPException(status_code=401, detail="Missing bearer token")
281
  token = auth.split(" ", 1)[1].strip()
282
  handle = app.state.sessions.get(token)
@@ -288,8 +202,9 @@ async def _require_user(request: Request) -> dict:
288
  raise fastapi.HTTPException(status_code=401, detail="User disabled")
289
  return user
290
 
 
291
  # ---------------------------
292
- # Admin: user creation (existing) and room management
293
  # ---------------------------
294
  @app.post("/admin/users")
295
  async def admin_create_user(user: NewUser, request: Request):
@@ -309,8 +224,8 @@ async def admin_create_user(user: NewUser, request: Request):
309
  "pin_hash": _hash_pin(user.pin),
310
  "disabled": False,
311
  "created_at": _now_iso(),
312
- "followers": [],
313
- "following": [],
314
  }
315
  await _append_user(rec)
316
  pub = {k: v for k, v in rec.items() if k != "pin_hash"}
@@ -324,9 +239,11 @@ async def admin_list_users(request: Request, q: Optional[str] = None, limit: int
324
  ql = q.lower()
325
  users = [u for u in users if ql in u.get("email", "").lower() or ql in u.get("handle", "").lower()]
326
  users = users[: max(1, min(limit, 1000))]
 
327
  for u in users:
328
- u.pop("pin_hash", None)
329
- return {"count": len(users), "users": users}
 
330
 
331
  @app.put("/admin/users/{handle}")
332
  async def admin_update_user(handle: str, patch: UpdateUser, request: Request):
@@ -350,21 +267,19 @@ async def admin_update_user(handle: str, patch: UpdateUser, request: Request):
350
  u["disabled"] = bool(patch.disabled)
351
  if patch.pin is not None:
352
  u["pin_hash"] = _hash_pin(patch.pin)
 
 
 
 
353
  changed = True
354
  break
355
  if not changed:
356
  raise fastapi.HTTPException(status_code=404, detail="User not found")
357
- # rewrite users file
358
- async with app.state.users_lock:
359
- def _write_all():
360
- with open(USERS_FILE, "w", encoding="utf-8") as f:
361
- for rec in users:
362
- f.write(json.dumps(rec, ensure_ascii=False) + "\n")
363
- await asyncio.to_thread(_write_all)
364
  return {"ok": True}
365
 
366
  # ---------------------------
367
- # Auth: login
368
  # ---------------------------
369
  @app.post("/auth/login")
370
  async def login(req: LoginReq):
@@ -383,164 +298,33 @@ async def login(req: LoginReq):
383
  @app.get("/me")
384
  async def me(request: Request):
385
  user = await _require_user(request)
386
- pub = {k: v for k, v in user.items() if k not in ("pin_hash",)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
387
  return {"ok": True, "user": pub}
388
 
389
- # ---------------------------
390
- # Rooms endpoints
391
- # ---------------------------
392
- @app.get("/rooms")
393
- async def list_rooms(request: Request = None):
394
- """
395
- Returns rooms. If an Authorization bearer is supplied and the user is member/admin,
396
- private room info will be visible. Otherwise invite_code is hidden.
397
- """
398
- rooms = await _read_rooms()
399
- # If a valid bearer token present, get handle
400
- authed_handle = None
401
- auth = None
402
- if request:
403
- auth = request.headers.get("authorization", "")
404
- if auth and auth.lower().startswith("bearer "):
405
- token = auth.split(" ", 1)[1].strip()
406
- authed_handle = app.state.sessions.get(token)
407
- out = []
408
- for r in rooms:
409
- copy = {k: v for k, v in r.items() if k != "invite_code"}
410
- # show invite_code only to admin (x-admin-key) or to admin bearer user (not implemented)
411
- if request and request.headers.get("x-admin-key", "") == ADMIN_KEY:
412
- copy["invite_code"] = r.get("invite_code", "")
413
- # mark if current user is a member
414
- copy["member"] = authed_handle in (r.get("members") or [])
415
- out.append(copy)
416
- return {"ok": True, "rooms": out}
417
-
418
- @app.post("/admin/rooms")
419
- async def admin_create_room(payload: dict, request: Request):
420
- """
421
- Admin-only: create room:
422
- { "code": "grade6", "name": "Grade 6", "private": true }
423
- Returns invite_code (server-generated) for private rooms.
424
- """
425
- _require_admin(request)
426
- code = _safe_handle((payload.get("code") or "").lower())
427
- if not code:
428
- raise fastapi.HTTPException(status_code=400, detail="Invalid code")
429
- name = (payload.get("name") or code).strip()
430
- private = bool(payload.get("private", False))
431
- rooms = await _read_rooms()
432
- if any(r.get("code") == code for r in rooms):
433
- return {"ok": False, "error": "Room exists"}
434
- invite_code = ""
435
- if private:
436
- invite_code = hashlib.sha1(os.urandom(16)).hexdigest()[:10].upper()
437
- rec = {"code": code, "name": name, "private": private, "invite_code": invite_code, "members": []}
438
- rooms.append(rec)
439
- await _save_rooms(rooms)
440
- return {"ok": True, "room": rec}
441
-
442
- @app.get("/admin/rooms")
443
- async def admin_list_rooms(request: Request):
444
- _require_admin(request)
445
- rooms = await _read_rooms()
446
- return {"ok": True, "rooms": rooms}
447
-
448
- @app.get("/admin/rooms/{code}")
449
- async def admin_get_room(code: str, request: Request):
450
- _require_admin(request)
451
- rooms = await _read_rooms()
452
- r = next((x for x in rooms if x.get("code") == code), None)
453
- if not r:
454
- raise fastapi.HTTPException(status_code=404, detail="Room not found")
455
- return {"ok": True, "room": r}
456
-
457
- @app.delete("/admin/rooms/{code}")
458
- async def admin_delete_room(code: str, request: Request):
459
- _require_admin(request)
460
- rooms = await _read_rooms()
461
- code = _safe_handle(code)
462
- new = [r for r in rooms if r.get("code") != code]
463
- await _save_rooms(new)
464
- return {"ok": True}
465
-
466
- @app.get("/admin/rooms/{code}/members")
467
- async def admin_room_members(code: str, request: Request):
468
- _require_admin(request)
469
- rooms = await _read_rooms()
470
- r = next((x for x in rooms if x.get("code") == code), None)
471
- if not r: raise fastapi.HTTPException(status_code=404, detail="Room not found")
472
- return {"ok": True, "members": r.get("members", [])}
473
-
474
- @app.post("/admin/rooms/{code}/members/remove")
475
- async def admin_remove_room_member(code: str, body: dict, request: Request):
476
- _require_admin(request)
477
- handle = _safe_handle(body.get("handle", ""))
478
- rooms = await _read_rooms()
479
- found = False
480
- for r in rooms:
481
- if r.get("code") == code:
482
- members = r.get("members", [])
483
- if handle in members:
484
- members.remove(handle)
485
- r["members"] = members
486
- found = True
487
- break
488
- if not found:
489
- return {"ok": False, "error": "Member not found in room"}
490
- await _save_rooms(rooms)
491
- return {"ok": True}
492
-
493
- @app.post("/rooms/{code}/join")
494
- async def join_room(code: str, body: dict, request: Request):
495
- """
496
- Authenticated users call to join private rooms using invite code.
497
- Non-private rooms are joinable without invite (server adds user as member).
498
- """
499
- user = await _require_user(request)
500
- code = _safe_handle(code)
501
- rooms = await _read_rooms()
502
- r = next((x for x in rooms if x.get("code") == code), None)
503
- if not r:
504
- raise fastapi.HTTPException(status_code=404, detail="Room not found")
505
- if r.get("private"):
506
- provided = (body.get("invite") or "").strip()
507
- if not provided:
508
- raise fastapi.HTTPException(status_code=400, detail="Invite required")
509
- if provided != r.get("invite_code"):
510
- raise fastapi.HTTPException(status_code=403, detail="Invalid invite code")
511
- # add member if not already
512
- members = set(r.get("members", []))
513
- members.add(user["handle"])
514
- r["members"] = list(members)
515
- await _save_rooms(rooms)
516
- return {"ok": True, "room": r}
517
 
518
  # ---------------------------
519
- # Chat endpoints (read + write) with privacy enforcement
520
  # ---------------------------
521
- @app.get("/chat/{room_code}")
522
- async def get_messages(room_code: str, limit: int = 50, since: Optional[str] = None, request: Request = None):
523
- """
524
- Fetch messages for a room.
525
- Public rooms are readable by anyone; private rooms require membership/auth.
526
- """
527
  limit = max(1, min(limit, 200))
528
- rooms = await _read_rooms()
529
- room_code = _safe_handle(room_code)
530
- room = next((r for r in rooms if r.get("code") == room_code), None)
531
- if not room:
532
- # allow fallback to legacy behavior if no room metadata: public
533
- room = {"code": room_code, "private": False}
534
- # if private, require membership
535
- if room.get("private"):
536
- # require bearer token
537
- try:
538
- user = await _require_user(request)
539
- except Exception:
540
- raise fastapi.HTTPException(status_code=401, detail="Authentication required for this room")
541
- if user.get("handle") not in room.get("members", []):
542
- raise fastapi.HTTPException(status_code=403, detail="Not a member of this room")
543
- path = _chat_file_for(room_code)
544
  items = await _read_jsonl(path)
545
  if since:
546
  try:
@@ -554,214 +338,146 @@ async def get_messages(room_code: str, limit: int = 50, since: Optional[str] = N
554
  items.sort(key=lambda m: m.get("created_at", ""))
555
  if len(items) > limit:
556
  items = items[-limit:]
557
- return {"room": room_code, "count": len(items), "messages": items}
558
 
559
- @app.post("/chat/{room_code}")
560
- async def post_message(room_code: str, msg: NewMessage, request: Request):
561
- """
562
- Append a message to a room's chat.
563
- Accepts images (data-urls or public URLs). Data-urls will be saved server-side and replaced with /static URLs.
564
- """
565
- room_code = _safe_handle(room_code)
566
- rooms = await _read_rooms()
567
- room = next((r for r in rooms if r.get("code") == room_code), None)
568
- if not room:
569
- # if room doesn't exist, disallow posting
570
- raise fastapi.HTTPException(status_code=404, detail="Room not found")
571
- # require auth
572
  user = await _require_user(request)
573
- if room.get("private") and user.get("handle") not in room.get("members", []):
574
- raise fastapi.HTTPException(status_code=403, detail="Not a member of this room")
 
 
575
  text = (msg.text or "").strip()
576
- if not text and not msg.images:
577
  return {"ok": False, "error": "Empty message"}
578
- images_out: List[str] = []
579
- if msg.images:
580
- # images can be data-urls or already public URLs; save data-urls
581
- for im in msg.images[:8]:
582
- try:
583
- if isinstance(im, str) and im.startswith("data:"):
584
- url = await save_data_url(im)
585
- images_out.append(url)
586
- else:
587
- # treat as already a served URL (validate basic)
588
- images_out.append(im)
589
- except Exception as e:
590
- logging.exception("image save failed")
591
- author = f"{user.get('first_name','')} {user.get('last_name','')}".strip() or user.get("handle")
592
  created = _now_iso()
593
- mid = hashlib.sha1(f"{room_code}|{author}|{created}|{text}".encode("utf-8")).hexdigest()[:16]
594
- ip = request.client.host if request and request.client else None
595
  rec = {
596
  "id": mid,
597
- "room": room_code,
598
  "author": author,
599
- "handle": user.get("handle"),
600
- "klass": user.get("klass", ""),
601
- "profile_image": user.get("profile_image", ""),
602
  "text": text,
603
- "images": images_out,
604
  "created_at": created,
605
- "ip": ip,
606
- "ua": request.headers.get("user-agent", "")[:200],
607
  }
608
- path = _chat_file_for(room_code)
609
- lock = _lock_for(path)
610
  async with lock:
611
  await _append_jsonl(path, rec)
612
  return {"ok": True, "message": rec}
613
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
614
  # ---------------------------
615
- # Upload endpoint (multipart or JSON data-url)
616
  # ---------------------------
617
  @app.post("/upload_image")
618
- async def upload_image(file: Optional[UploadFile] = File(None), data_url: Optional[str] = Form(None), request: Request = None):
619
  """
620
- Accepts:
621
- - multipart form 'file' (UploadFile)
622
- - OR form field 'data_url' with a base64 data:... URL
623
- Returns JSON: { ok: True, url: "/static/images/<file>" }
624
  """
 
 
 
625
  if file:
626
- try:
627
- url = await save_upload_file(file)
628
- return {"ok": True, "url": url}
629
- except Exception as e:
630
- logging.exception("upload failed")
631
- raise fastapi.HTTPException(status_code=400, detail="Upload failed")
 
 
 
632
  if data_url:
 
 
 
633
  try:
634
- url = await save_data_url(data_url)
635
- return {"ok": True, "url": url}
636
- except Exception as e:
637
- logging.exception("data-url save failed")
638
- raise fastapi.HTTPException(status_code=400, detail="Bad data URL")
 
 
 
 
 
 
639
  raise fastapi.HTTPException(status_code=400, detail="No file or data_url provided")
640
 
641
  # ---------------------------
642
- # (leftover original icloud endpoints unchanged)
643
  # ---------------------------
644
- BASE_62_MAP = {c: i for i, c in enumerate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")}
645
-
646
- async def get_client() -> httpx.AsyncClient:
647
- if not hasattr(app.state, "client"):
648
- app.state.client = httpx.AsyncClient(timeout=15.0)
649
- return app.state.client
650
-
651
- def base62_to_int(token: str) -> int:
652
- result = 0
653
- for ch in token:
654
- result = result * 62 + BASE_62_MAP[ch]
655
- return result
656
-
657
- async def get_base_url(token: str) -> str:
658
- first = token[0]
659
- if first == "A":
660
- n = base62_to_int(token[1])
661
- else:
662
- n = base62_to_int(token[1:3])
663
- return f"https://p{n:02d}-sharedstreams.icloud.com/{token}/sharedstreams/"
664
-
665
- ICLOUD_HEADERS = {
666
- "Origin": "https://www.icloud.com",
667
- "Content-Type": "text/plain",
668
- }
669
- ICLOUD_PAYLOAD = '{"streamCtag":null}'
670
-
671
- async def get_redirected_base_url(base_url: str, token: str) -> str:
672
- client = await get_client()
673
- resp = await client.post(
674
- f"{base_url}webstream", headers=ICLOUD_HEADERS, data=ICLOUD_PAYLOAD, follow_redirects=False
675
- )
676
- if resp.status_code == 330:
677
- try:
678
- body = resp.json()
679
- host = body.get("X-Apple-MMe-Host")
680
- if not host:
681
- raise ValueError("Missing X-Apple-MMe-Host in 330 response")
682
- logging.info(f"Redirected to {host}")
683
- return f"https://{host}/{token}/sharedstreams/"
684
- except Exception as e:
685
- logging.error(f"Redirect parsing failed: {e}")
686
- raise
687
- elif resp.status_code == 200:
688
- return base_url
689
- else:
690
- resp.raise_for_status()
691
-
692
- async def post_json(path: str, base_url: str, payload: str) -> dict:
693
- client = await get_client()
694
- resp = await client.post(f"{base_url}{path}", headers=ICLOUD_HEADERS, data=payload)
695
- resp.raise_for_status()
696
- return resp.json()
697
-
698
- async def get_metadata(base_url: str) -> list:
699
- data = await post_json("webstream", base_url, ICLOUD_PAYLOAD)
700
- return data.get("photos", [])
701
-
702
- async def get_asset_urls(base_url: str, guids: list) -> dict:
703
- payload = json.dumps({"photoGuids": guids})
704
- data = await post_json("webasseturls", base_url, payload)
705
- return data.get("items", {})
706
-
707
- @app.get("/album/{token}")
708
- async def get_album(token: str):
709
- try:
710
- base_url = await get_base_url(token)
711
- base_url = await get_redirected_base_url(base_url, token)
712
-
713
- metadata = await get_metadata(base_url)
714
- guids = [photo["photoGuid"] for photo in metadata]
715
- asset_map = await get_asset_urls(base_url, guids)
716
-
717
- videos = []
718
- for photo in metadata:
719
- if photo.get("mediaAssetType", "").lower() != "video":
720
- continue
721
-
722
- derivatives = photo.get("derivatives", {})
723
- best = max(
724
- (d for k, d in derivatives.items() if k.lower() != "posterframe"),
725
- key=lambda d: int(d.get("fileSize") or 0),
726
- default=None,
727
- )
728
- if not best:
729
- continue
730
-
731
- checksum = best.get("checksum")
732
- info = asset_map.get(checksum)
733
- if not info:
734
- continue
735
- video_url = f"https://{info['url_location']}{info['url_path']}"
736
-
737
- poster = None
738
- pf = derivatives.get("PosterFrame")
739
- if pf:
740
- pf_info = asset_map.get(pf.get("checksum"))
741
- if pf_info:
742
- poster = f"https://{pf_info['url_location']}{pf_info['url_path']}"
743
-
744
- videos.append({
745
- "caption": photo.get("caption", ""),
746
- "url": video_url,
747
- "poster": poster or "",
748
- })
749
-
750
- return {"videos": videos}
751
-
752
- except Exception as e:
753
- logging.exception("Error in get_album")
754
- return {"error": str(e)}
755
-
756
- @app.get("/album/{token}/raw")
757
- async def get_album_raw(token: str):
758
- try:
759
- base_url = await get_base_url(token)
760
- base_url = await get_redirected_base_url(base_url, token)
761
- metadata = await get_metadata(base_url)
762
- guids = [photo["photoGuid"] for photo in metadata]
763
- asset_map = await get_asset_urls(base_url, guids)
764
- return {"metadata": metadata, "asset_urls": asset_map}
765
- except Exception as e:
766
- logging.exception("Error in get_album_raw")
767
- return {"error": str(e)}
 
1
  from fastapi import FastAPI, Request, UploadFile, File, Form
2
  from fastapi.middleware.cors import CORSMiddleware
3
+ from fastapi.responses import JSONResponse
 
4
  from pydantic import BaseModel, Field
5
+ from typing import Optional, List, Dict
 
6
  import json
7
  import logging
8
  import os
 
10
  import hashlib
11
  from datetime import datetime, timezone
12
  import re
 
13
  import base64
14
  import imghdr
 
 
 
15
 
16
  app = FastAPI()
17
  logging.basicConfig(level=logging.INFO)
18
 
19
+ # CORS
20
  app.add_middleware(
21
  CORSMiddleware,
22
  allow_origins=["*"],
 
29
  # ---------------------------
30
  PERSISTENT_ROOT = os.environ.get("PERSISTENT_DIR", "/data")
31
  if not os.path.isdir(PERSISTENT_ROOT):
 
32
  PERSISTENT_ROOT = os.path.join(".", "data")
33
  os.makedirs(PERSISTENT_ROOT, exist_ok=True)
34
 
 
39
  os.makedirs(USERS_DIR, exist_ok=True)
40
  USERS_FILE = os.path.join(USERS_DIR, "users.jsonl")
41
  SESSIONS_FILE = os.path.join(USERS_DIR, "sessions.json")
42
+ ADMIN_KEY = os.environ.get("ADMIN_KEY", "")
43
 
44
+ # locks & in-memory
 
 
 
 
 
 
 
 
 
45
  app.state.chat_locks = {}
46
  app.state.users_lock = asyncio.Lock()
47
  app.state.sessions_lock = asyncio.Lock()
 
48
  app.state.sessions: Dict[str, str] = {}
49
 
50
+
51
  # ---------------------------
52
+ # Helpers
53
  # ---------------------------
54
+ def _chat_file_for(room: str) -> str:
55
+ h = hashlib.sha256(room.encode("utf-8")).hexdigest()[:32]
56
  return os.path.join(CHAT_DIR, f"{h}.jsonl")
57
 
58
  def _lock_for(path: str) -> asyncio.Lock:
 
71
  return s[:24]
72
 
73
  def _hash_pin(pin: str) -> str:
74
+ return hashlib.sha256(("tlks|" + (pin or "")).encode("utf-8")).hexdigest()
75
 
76
  def _rand_token() -> str:
77
  return hashlib.sha256(os.urandom(32)).hexdigest()
78
 
79
+ # JSONL read/write
80
+ async def _append_jsonl(path: str, record: dict):
 
 
81
  line = json.dumps(record, ensure_ascii=False) + "\n"
82
  def _write():
83
  with open(path, "a", encoding="utf-8") as f:
 
87
  async def _read_jsonl(path: str) -> List[dict]:
88
  if not os.path.exists(path):
89
  return []
90
+ def _r():
91
  with open(path, "r", encoding="utf-8") as f:
92
  return [json.loads(x) for x in f if x.strip()]
93
+ return await asyncio.to_thread(_r)
 
 
 
 
 
 
 
 
 
 
 
94
 
95
+ # users load/write helpers
96
  async def _read_users() -> List[dict]:
97
  if not os.path.exists(USERS_FILE):
98
  return []
99
+ def _r():
100
+ with open(USERS_FILE, "r", encoding="utf-8") as f:
101
+ return [json.loads(x) for x in f if x.strip()]
102
  async with app.state.users_lock:
103
+ return await asyncio.to_thread(_r)
 
 
 
104
 
105
+ async def _write_users(users: List[dict]):
106
+ def _w():
107
+ with open(USERS_FILE, "w", encoding="utf-8") as f:
108
+ for u in users:
109
+ f.write(json.dumps(u, ensure_ascii=False) + "\n")
110
+ async with app.state.users_lock:
111
+ await asyncio.to_thread(_w)
112
+
113
+ async def _append_user(rec: dict):
114
+ # ensure default fields
115
+ rec.setdefault("disabled", False)
116
+ rec.setdefault("created_at", _now_iso())
117
+ rec.setdefault("verified", "") # "", "staff", "dev"
118
+ rec.setdefault("seen_intro", False)
119
+ line = json.dumps(rec, ensure_ascii=False) + "\n"
120
+ async with app.state.users_lock:
121
+ def _w():
122
+ with open(USERS_FILE, "a", encoding="utf-8") as f:
123
+ f.write(line)
124
+ await asyncio.to_thread(_w)
125
+
126
+ # sessions
127
  async def _load_sessions():
128
  if os.path.exists(SESSIONS_FILE):
129
+ def _r():
130
  with open(SESSIONS_FILE, "r", encoding="utf-8") as f:
131
  return json.load(f)
132
+ app.state.sessions = await asyncio.to_thread(_r)
133
  else:
134
  app.state.sessions = {}
135
 
136
  async def _save_sessions():
137
+ def _w(data):
138
  with open(SESSIONS_FILE, "w", encoding="utf-8") as f:
139
  json.dump(data, f)
140
  async with app.state.sessions_lock:
141
+ await asyncio.to_thread(_w, app.state.sessions)
142
 
143
  @app.on_event("startup")
144
+ async def _startup():
145
  await _load_sessions()
 
 
 
 
 
 
 
 
 
 
146
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
 
148
+ # admin guard
149
  def _require_admin(request: Request):
150
  provided = request.headers.get("x-admin-key", "")
151
  if not ADMIN_KEY or provided != ADMIN_KEY:
152
  raise fastapi.HTTPException(status_code=401, detail="Admin key required")
153
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
 
155
  # ---------------------------
156
  # Models
157
  # ---------------------------
 
 
 
 
158
  class NewUser(BaseModel):
159
  email: str
160
  first_name: str
 
164
  profile_image: Optional[str] = ""
165
  description: Optional[str] = ""
166
  pin: str = Field(..., min_length=3, max_length=32)
167
+ verified: Optional[str] = "" # optional initial verified status
168
 
169
  class UpdateUser(BaseModel):
170
  first_name: Optional[str] = None
 
174
  description: Optional[str] = None
175
  pin: Optional[str] = None
176
  disabled: Optional[bool] = None
177
+ verified: Optional[str] = None
178
 
179
  class LoginReq(BaseModel):
180
  handle: str
181
  pin: str
182
 
183
+ class NewMessage(BaseModel):
184
+ text: str = Field(..., min_length=1, max_length=5000)
185
+ images: Optional[List[str]] = None # (base64 data URLs or URLs)
186
+
187
+
188
  # ---------------------------
189
  # Auth utilities
190
  # ---------------------------
191
  async def _require_user(request: Request) -> dict:
192
  auth = request.headers.get("authorization", "")
193
+ if not auth or not auth.lower().startswith("bearer "):
194
  raise fastapi.HTTPException(status_code=401, detail="Missing bearer token")
195
  token = auth.split(" ", 1)[1].strip()
196
  handle = app.state.sessions.get(token)
 
202
  raise fastapi.HTTPException(status_code=401, detail="User disabled")
203
  return user
204
 
205
+
206
  # ---------------------------
207
+ # Admin: create/list/update users (pin stored hashed); includes 'verified'
208
  # ---------------------------
209
  @app.post("/admin/users")
210
  async def admin_create_user(user: NewUser, request: Request):
 
224
  "pin_hash": _hash_pin(user.pin),
225
  "disabled": False,
226
  "created_at": _now_iso(),
227
+ "verified": (user.verified or ""),
228
+ "seen_intro": False
229
  }
230
  await _append_user(rec)
231
  pub = {k: v for k, v in rec.items() if k != "pin_hash"}
 
239
  ql = q.lower()
240
  users = [u for u in users if ql in u.get("email", "").lower() or ql in u.get("handle", "").lower()]
241
  users = users[: max(1, min(limit, 1000))]
242
+ out = []
243
  for u in users:
244
+ copy = {k: v for k, v in u.items() if k != "pin_hash"}
245
+ out.append(copy)
246
+ return {"count": len(out), "users": out}
247
 
248
  @app.put("/admin/users/{handle}")
249
  async def admin_update_user(handle: str, patch: UpdateUser, request: Request):
 
267
  u["disabled"] = bool(patch.disabled)
268
  if patch.pin is not None:
269
  u["pin_hash"] = _hash_pin(patch.pin)
270
+ if patch.verified is not None:
271
+ # allow "", "staff", "dev"
272
+ val = (patch.verified or "").strip()
273
+ u["verified"] = val
274
  changed = True
275
  break
276
  if not changed:
277
  raise fastapi.HTTPException(status_code=404, detail="User not found")
278
+ await _write_users(users)
 
 
 
 
 
 
279
  return {"ok": True}
280
 
281
  # ---------------------------
282
+ # Auth: login & me with intro support
283
  # ---------------------------
284
  @app.post("/auth/login")
285
  async def login(req: LoginReq):
 
298
  @app.get("/me")
299
  async def me(request: Request):
300
  user = await _require_user(request)
301
+ # copy raising sensitive info out
302
+ pub = {k: v for k, v in user.items() if k != "pin_hash"}
303
+ # Intro behavior: if user hasn't seen intro, return show_intro and update their flag
304
+ show_intro = False
305
+ if not user.get("seen_intro"):
306
+ show_intro = True
307
+ # mark seen_intro true and rewrite users file
308
+ users = await _read_users()
309
+ for u in users:
310
+ if u.get("handle") == user.get("handle"):
311
+ u["seen_intro"] = True
312
+ break
313
+ await _write_users(users)
314
+ # Access notice string for frontend popup
315
+ access_notice = "Access is rolling out class by class — you must be invited. If you entered an incorrect name or PIN, ask your teacher for help."
316
+ pub["show_intro"] = show_intro
317
+ pub["access_notice"] = access_notice
318
  return {"ok": True, "user": pub}
319
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
320
 
321
  # ---------------------------
322
+ # Chat endpoints (post + read + delete)
323
  # ---------------------------
324
+ @app.get("/chat/{room}")
325
+ async def get_messages(room: str, limit: int = 50, since: Optional[str] = None):
 
 
 
 
326
  limit = max(1, min(limit, 200))
327
+ path = _chat_file_for(room)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
  items = await _read_jsonl(path)
329
  if since:
330
  try:
 
338
  items.sort(key=lambda m: m.get("created_at", ""))
339
  if len(items) > limit:
340
  items = items[-limit:]
341
+ return {"room": room, "count": len(items), "messages": items}
342
 
343
+ @app.post("/chat/{room}")
344
+ async def post_message(room: str, msg: NewMessage, request: Request):
345
+ path = _chat_file_for(room)
346
+ lock = _lock_for(path)
 
 
 
 
 
 
 
 
 
347
  user = await _require_user(request)
348
+ author = f"{user.get('first_name','')} {user.get('last_name','')}".strip() or user.get("handle")
349
+ handle = user.get("handle")
350
+ klass = user.get("klass", "")
351
+ profile_image = user.get("profile_image", "")
352
  text = (msg.text or "").strip()
353
+ if not text and not (msg.images or []):
354
  return {"ok": False, "error": "Empty message"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
355
  created = _now_iso()
356
+ mid = hashlib.sha1(f"{room}|{author}|{created}|{text}".encode("utf-8")).hexdigest()[:16]
 
357
  rec = {
358
  "id": mid,
359
+ "room": room,
360
  "author": author,
361
+ "handle": handle,
362
+ "klass": klass,
363
+ "profile_image": profile_image,
364
  "text": text,
365
+ "images": msg.images or [],
366
  "created_at": created,
367
+ "ua": request.headers.get("user-agent", "")[:200]
 
368
  }
 
 
369
  async with lock:
370
  await _append_jsonl(path, rec)
371
  return {"ok": True, "message": rec}
372
 
373
+ @app.delete("/chat/{room}/{msgid}")
374
+ async def delete_message(room: str, msgid: str, request: Request):
375
+ """
376
+ Verified users (staff/dev) may delete posts; admin via x-admin-key may delete too.
377
+ """
378
+ # admin bypass?
379
+ if request.headers.get("x-admin-key", "") == ADMIN_KEY and ADMIN_KEY:
380
+ # allow admin delete
381
+ pass
382
+ else:
383
+ user = await _require_user(request)
384
+ if user.get("verified") not in ("staff", "dev"):
385
+ raise fastapi.HTTPException(status_code=403, detail="Only verified staff/developers may delete posts")
386
+ path = _chat_file_for(room)
387
+ items = await _read_jsonl(path)
388
+ new = [m for m in items if m.get("id") != msgid]
389
+ if len(new) == len(items):
390
+ raise fastapi.HTTPException(status_code=404, detail="Message not found")
391
+ # rewrite file
392
+ def _write():
393
+ with open(path, "w", encoding="utf-8") as f:
394
+ for rec in new:
395
+ f.write(json.dumps(rec, ensure_ascii=False) + "\n")
396
+ await asyncio.to_thread(_write)
397
+ return {"ok": True, "deleted": msgid}
398
+
399
+ # ---------------------------
400
+ # Admin endpoints for posts (view and delete)
401
+ # ---------------------------
402
+ @app.get("/admin/users/{handle}/posts")
403
+ async def admin_user_posts(handle: str, request: Request):
404
+ _require_admin(request)
405
+ handle = _safe_handle(handle)
406
+ posts = []
407
+ # scan chat files
408
+ for fn in os.listdir(CHAT_DIR):
409
+ path = os.path.join(CHAT_DIR, fn)
410
+ try:
411
+ items = await _read_jsonl(path)
412
+ for m in items:
413
+ if m.get("handle") == handle:
414
+ posts.append(m)
415
+ except Exception:
416
+ continue
417
+ # sort newest first
418
+ posts.sort(key=lambda x: x.get("created_at", ""), reverse=True)
419
+ return {"ok": True, "count": len(posts), "posts": posts}
420
+
421
+ @app.delete("/admin/posts/{room}/{msgid}")
422
+ async def admin_delete_post(room: str, msgid: str, request: Request):
423
+ _require_admin(request)
424
+ path = _chat_file_for(room)
425
+ items = await _read_jsonl(path)
426
+ new = [m for m in items if m.get("id") != msgid]
427
+ if len(new) == len(items):
428
+ raise fastapi.HTTPException(status_code=404, detail="Message not found")
429
+ def _write():
430
+ with open(path, "w", encoding="utf-8") as f:
431
+ for rec in new:
432
+ f.write(json.dumps(rec, ensure_ascii=False) + "\n")
433
+ await asyncio.to_thread(_write)
434
+ return {"ok": True, "deleted": msgid}
435
+
436
  # ---------------------------
437
+ # Optional: upload_data_url endpoint (kept for future use)
438
  # ---------------------------
439
  @app.post("/upload_image")
440
+ async def upload_image(file: Optional[UploadFile] = File(None), data_url: Optional[str] = Form(None)):
441
  """
442
+ Accepts multipart file OR a data_url form field. Saves to disk and returns /static path.
443
+ (You asked to keep base64 as-is; we keep this endpoint but you can keep sending base64/images directly.)
 
 
444
  """
445
+ # simple saving implementation
446
+ IMAGES_DIR = os.path.join(PERSISTENT_ROOT, "static_images")
447
+ os.makedirs(IMAGES_DIR, exist_ok=True)
448
  if file:
449
+ contents = await file.read()
450
+ ext = "." + (imghdr.what(None, contents) or "jpg")
451
+ name = hashlib.sha256(contents).hexdigest()[:20] + ext
452
+ out = os.path.join(IMAGES_DIR, name)
453
+ def _w():
454
+ with open(out, "wb") as f:
455
+ f.write(contents)
456
+ await asyncio.to_thread(_w)
457
+ return {"ok": True, "url": f"/static_images/{name}"}
458
  if data_url:
459
+ if not data_url.startswith("data:"):
460
+ raise fastapi.HTTPException(status_code=400, detail="Not a data URL")
461
+ header, b64 = data_url.split(",", 1)
462
  try:
463
+ payload = base64.b64decode(b64)
464
+ except Exception:
465
+ raise fastapi.HTTPException(status_code=400, detail="Bad base64")
466
+ ext = "." + (imghdr.what(None, payload) or "jpg")
467
+ name = hashlib.sha256(payload).hexdigest()[:20] + ext
468
+ out = os.path.join(IMAGES_DIR, name)
469
+ def _w():
470
+ with open(out, "wb") as f:
471
+ f.write(payload)
472
+ await asyncio.to_thread(_w)
473
+ return {"ok": True, "url": f"/static_images/{name}"}
474
  raise fastapi.HTTPException(status_code=400, detail="No file or data_url provided")
475
 
476
  # ---------------------------
477
+ # Keep existing /album endpoints if you use them unchanged (omitted here)
478
  # ---------------------------
479
+
480
+ # minimal root
481
+ @app.get("/")
482
+ async def root():
483
+ return {"ok": True, "msg": "Central TLKS backend running"}