SalexAI commited on
Commit
b7d0fd9
·
verified ·
1 Parent(s): 8538da8

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +424 -247
app.py CHANGED
@@ -1,8 +1,9 @@
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,13 +11,12 @@ import asyncio
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=["*"],
@@ -25,36 +25,33 @@ app.add_middleware(
25
  )
26
 
27
  # ---------------------------
28
- # Persistent storage setup
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
-
35
  CHAT_DIR = os.path.join(PERSISTENT_ROOT, "chat")
 
 
36
  os.makedirs(CHAT_DIR, exist_ok=True)
37
 
38
  USERS_DIR = os.path.join(PERSISTENT_ROOT, "users")
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:
59
  lock = app.state.chat_locks.get(path)
60
  if lock is None:
@@ -62,90 +59,108 @@ def _lock_for(path: str) -> asyncio.Lock:
62
  app.state.chat_locks[path] = lock
63
  return lock
64
 
 
65
  def _now_iso() -> str:
66
  return datetime.now(timezone.utc).isoformat()
67
 
68
- def _safe_handle(s: str) -> str:
69
- s = (s or "").strip()
70
- s = re.sub(r"[^a-zA-Z0-9_.~-]", "_", s)
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:
84
  f.write(line)
 
85
  await asyncio.to_thread(_write)
86
 
 
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:
@@ -153,7 +168,44 @@ def _require_admin(request: Request):
153
 
154
 
155
  # ---------------------------
156
- # Models
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  # ---------------------------
158
  class NewUser(BaseModel):
159
  email: str
@@ -164,7 +216,8 @@ class NewUser(BaseModel):
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
@@ -176,43 +229,21 @@ class UpdateUser(BaseModel):
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)
197
- if not handle:
198
- raise fastapi.HTTPException(status_code=401, detail="Invalid session")
199
- users = await _read_users()
200
- user = next((u for u in users if u.get("handle") == handle), None)
201
- if not user or user.get("disabled"):
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):
211
  _require_admin(request)
212
  users = await _read_users()
 
213
  handle = _safe_handle(user.handle.lstrip("@"))
214
  if any(u.get("handle") == handle for u in users):
215
  return {"ok": False, "error": "Handle already exists"}
 
216
  rec = {
217
  "email": user.email.strip(),
218
  "first_name": user.first_name.strip(),
@@ -223,14 +254,14 @@ async def admin_create_user(user: NewUser, request: Request):
223
  "description": (user.description or "").strip(),
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"}
232
  return {"ok": True, "user": pub}
233
 
 
234
  @app.get("/admin/users")
235
  async def admin_list_users(request: Request, q: Optional[str] = None, limit: int = 200):
236
  _require_admin(request)
@@ -239,11 +270,10 @@ async def admin_list_users(request: Request, q: Optional[str] = None, limit: int
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):
@@ -268,19 +298,22 @@ async def admin_update_user(handle: str, patch: UpdateUser, request: Request):
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):
286
  users = await _read_users()
@@ -290,73 +323,64 @@ async def login(req: LoginReq):
290
  return {"ok": False, "error": "Invalid user"}
291
  if u.get("pin_hash") != _hash_pin(req.pin):
292
  return {"ok": False, "error": "Invalid PIN"}
 
293
  token = _rand_token()
294
  app.state.sessions[token] = handle
295
  await _save_sessions()
296
  return {"ok": True, "token": token}
297
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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:
331
- since_dt = datetime.fromisoformat(since.replace("Z", "+00:00"))
332
- items = [
333
- m for m in items
334
- if datetime.fromisoformat(str(m.get("created_at", "")).replace("Z", "+00:00")) > since_dt
335
- ]
336
- except Exception:
337
- pass
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,
@@ -364,120 +388,273 @@ async def post_message(room: str, msg: NewMessage, request: Request):
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"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app.py
2
+ from fastapi import FastAPI, Request
3
  from fastapi.middleware.cors import CORSMiddleware
 
4
  from pydantic import BaseModel, Field
5
  from typing import Optional, List, Dict
6
+ import httpx
7
  import json
8
  import logging
9
  import os
 
11
  import hashlib
12
  from datetime import datetime, timezone
13
  import re
14
+ import fastapi
 
15
 
16
  app = FastAPI()
17
  logging.basicConfig(level=logging.INFO)
18
 
19
+ # Enable CORS for all origins
20
  app.add_middleware(
21
  CORSMiddleware,
22
  allow_origins=["*"],
 
25
  )
26
 
27
  # ---------------------------
28
+ # 📦 Persistent storage setup
29
  # ---------------------------
30
  PERSISTENT_ROOT = os.environ.get("PERSISTENT_DIR", "/data")
 
 
 
 
31
  CHAT_DIR = os.path.join(PERSISTENT_ROOT, "chat")
32
+ if not os.path.isdir(PERSISTENT_ROOT):
33
+ CHAT_DIR = os.path.join(".", "data", "chat")
34
  os.makedirs(CHAT_DIR, exist_ok=True)
35
 
36
  USERS_DIR = os.path.join(PERSISTENT_ROOT, "users")
37
  os.makedirs(USERS_DIR, exist_ok=True)
38
  USERS_FILE = os.path.join(USERS_DIR, "users.jsonl")
39
  SESSIONS_FILE = os.path.join(USERS_DIR, "sessions.json")
40
+ ADMIN_KEY = os.environ.get("ADMIN_KEY", "") # set in HF Space secrets
41
 
42
+ # Async file locks
43
  app.state.chat_locks = {}
44
  app.state.users_lock = asyncio.Lock()
45
  app.state.sessions_lock = asyncio.Lock()
46
  app.state.sessions: Dict[str, str] = {}
47
 
48
 
49
+ def _chat_file_for(video_id: str) -> str:
50
+ # Stable filename to avoid path traversal
51
+ h = hashlib.sha256(video_id.encode("utf-8")).hexdigest()[:32]
 
 
52
  return os.path.join(CHAT_DIR, f"{h}.jsonl")
53
 
54
+
55
  def _lock_for(path: str) -> asyncio.Lock:
56
  lock = app.state.chat_locks.get(path)
57
  if lock is None:
 
59
  app.state.chat_locks[path] = lock
60
  return lock
61
 
62
+
63
  def _now_iso() -> str:
64
  return datetime.now(timezone.utc).isoformat()
65
 
 
 
 
 
66
 
67
+ def _valid_author(s: Optional[str]) -> str:
68
+ s = (s or "anon").strip()
69
+ s = re.sub(r"\s+", " ", s)
70
+ return s[:32] or "anon"
71
 
 
 
72
 
73
+ def _valid_text(s: str) -> str:
74
+ s = (s or "").rstrip("\n")
75
+ return s[:2000]
76
+
77
+
78
+ async def _append_jsonl(path: str, record: dict) -> None:
79
  line = json.dumps(record, ensure_ascii=False) + "\n"
80
+
81
  def _write():
82
  with open(path, "a", encoding="utf-8") as f:
83
  f.write(line)
84
+
85
  await asyncio.to_thread(_write)
86
 
87
+
88
  async def _read_jsonl(path: str) -> List[dict]:
89
  if not os.path.exists(path):
90
  return []
91
+
92
+ def _read():
93
  with open(path, "r", encoding="utf-8") as f:
94
  return [json.loads(x) for x in f if x.strip()]
 
95
 
96
+ return await asyncio.to_thread(_read)
97
+
98
+
99
+ # ---------------------------
100
+ # 👤 Users & Auth helpers
101
+ # ---------------------------
102
+
103
+ def _safe_handle(s: str) -> str:
104
+ s = (s or "").strip()
105
+ s = re.sub(r"[^a-zA-Z0-9_.~-]", "_", s)
106
+ return s[:24]
107
+
108
+
109
+ def _hash_pin(pin: str) -> str:
110
+ return hashlib.sha256(("tlks|" + pin).encode("utf-8")).hexdigest()
111
+
112
+
113
+ def _rand_token() -> str:
114
+ return hashlib.sha256(os.urandom(32)).hexdigest()
115
+
116
+
117
+ async def _append_user(record: dict) -> None:
118
+ line = json.dumps(record, ensure_ascii=False) + "\n"
119
+
120
+ def _write():
121
+ with open(USERS_FILE, "a", encoding="utf-8") as f:
122
+ f.write(line)
123
+
124
+ async with app.state.users_lock:
125
+ await asyncio.to_thread(_write)
126
+
127
+
128
  async def _read_users() -> List[dict]:
129
  if not os.path.exists(USERS_FILE):
130
  return []
131
+
132
+ def _read():
133
  with open(USERS_FILE, "r", encoding="utf-8") as f:
134
  return [json.loads(x) for x in f if x.strip()]
 
 
135
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  async with app.state.users_lock:
137
+ return await asyncio.to_thread(_read)
138
+
 
 
139
 
 
140
  async def _load_sessions():
141
  if os.path.exists(SESSIONS_FILE):
142
+ def _read():
143
  with open(SESSIONS_FILE, "r", encoding="utf-8") as f:
144
  return json.load(f)
145
+ app.state.sessions = await asyncio.to_thread(_read)
146
  else:
147
  app.state.sessions = {}
148
 
149
+
150
  async def _save_sessions():
151
+ def _write(data):
152
  with open(SESSIONS_FILE, "w", encoding="utf-8") as f:
153
  json.dump(data, f)
154
+
155
  async with app.state.sessions_lock:
156
+ await asyncio.to_thread(_write, app.state.sessions)
157
+
158
 
159
  @app.on_event("startup")
160
+ async def _startup_load_sessions():
161
  await _load_sessions()
162
 
163
 
 
164
  def _require_admin(request: Request):
165
  provided = request.headers.get("x-admin-key", "")
166
  if not ADMIN_KEY or provided != ADMIN_KEY:
 
168
 
169
 
170
  # ---------------------------
171
+ # 💬 Chat API (public read, authed write)
172
+ # ---------------------------
173
+ class NewMessage(BaseModel):
174
+ # author is ignored and server uses authenticated user's profile.
175
+ text: str = Field(..., min_length=1, max_length=5000)
176
+ images: Optional[List[str]] = [] # base64 data-urls or URLs
177
+
178
+
179
+ @app.get("/chat/{video_id}")
180
+ async def get_messages(video_id: str, limit: int = 50, since: Optional[str] = None):
181
+ """
182
+ Fetch messages for a room/video.
183
+ - limit: max messages (default 50)
184
+ - since: ISO8601 timestamp; return only messages newer than this
185
+ """
186
+ limit = max(1, min(limit, 200))
187
+ path = _chat_file_for(video_id)
188
+ items = await _read_jsonl(path)
189
+
190
+ if since:
191
+ try:
192
+ since_dt = datetime.fromisoformat(since.replace("Z", "+00:00"))
193
+ items = [
194
+ m for m in items
195
+ if datetime.fromisoformat(str(m.get("created_at", "")).replace("Z", "+00:00")) > since_dt
196
+ ]
197
+ except Exception:
198
+ pass # Ignore bad since param
199
+
200
+ items.sort(key=lambda m: m.get("created_at", ""))
201
+ if len(items) > limit:
202
+ items = items[-limit:]
203
+
204
+ return {"video_id": video_id, "count": len(items), "messages": items}
205
+
206
+
207
+ # ---------------------------
208
+ # 👤 Users & Auth API
209
  # ---------------------------
210
  class NewUser(BaseModel):
211
  email: str
 
216
  profile_image: Optional[str] = ""
217
  description: Optional[str] = ""
218
  pin: str = Field(..., min_length=3, max_length=32)
219
+ verified: Optional[str] = None # 'staff' or 'dev' or None
220
+
221
 
222
  class UpdateUser(BaseModel):
223
  first_name: Optional[str] = None
 
229
  disabled: Optional[bool] = None
230
  verified: Optional[str] = None
231
 
232
+
233
  class LoginReq(BaseModel):
234
  handle: str
235
  pin: str
236
 
 
 
 
 
237
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  @app.post("/admin/users")
239
  async def admin_create_user(user: NewUser, request: Request):
240
  _require_admin(request)
241
  users = await _read_users()
242
+
243
  handle = _safe_handle(user.handle.lstrip("@"))
244
  if any(u.get("handle") == handle for u in users):
245
  return {"ok": False, "error": "Handle already exists"}
246
+
247
  rec = {
248
  "email": user.email.strip(),
249
  "first_name": user.first_name.strip(),
 
254
  "description": (user.description or "").strip(),
255
  "pin_hash": _hash_pin(user.pin),
256
  "disabled": False,
257
+ "verified": (user.verified or None),
258
  "created_at": _now_iso(),
 
 
259
  }
260
  await _append_user(rec)
261
  pub = {k: v for k, v in rec.items() if k != "pin_hash"}
262
  return {"ok": True, "user": pub}
263
 
264
+
265
  @app.get("/admin/users")
266
  async def admin_list_users(request: Request, q: Optional[str] = None, limit: int = 200):
267
  _require_admin(request)
 
270
  ql = q.lower()
271
  users = [u for u in users if ql in u.get("email", "").lower() or ql in u.get("handle", "").lower()]
272
  users = users[: max(1, min(limit, 1000))]
 
273
  for u in users:
274
+ u.pop("pin_hash", None)
275
+ return {"count": len(users), "users": users}
276
+
277
 
278
  @app.put("/admin/users/{handle}")
279
  async def admin_update_user(handle: str, patch: UpdateUser, request: Request):
 
298
  if patch.pin is not None:
299
  u["pin_hash"] = _hash_pin(patch.pin)
300
  if patch.verified is not None:
301
+ u["verified"] = patch.verified
 
 
302
  changed = True
303
  break
304
  if not changed:
305
  raise fastapi.HTTPException(status_code=404, detail="User not found")
306
+
307
+ # rewrite file
308
+ async with app.state.users_lock:
309
+ def _write_all():
310
+ with open(USERS_FILE, "w", encoding="utf-8") as f:
311
+ for rec in users:
312
+ f.write(json.dumps(rec, ensure_ascii=False) + "\n")
313
+ await asyncio.to_thread(_write_all)
314
  return {"ok": True}
315
 
316
+
 
 
317
  @app.post("/auth/login")
318
  async def login(req: LoginReq):
319
  users = await _read_users()
 
323
  return {"ok": False, "error": "Invalid user"}
324
  if u.get("pin_hash") != _hash_pin(req.pin):
325
  return {"ok": False, "error": "Invalid PIN"}
326
+
327
  token = _rand_token()
328
  app.state.sessions[token] = handle
329
  await _save_sessions()
330
  return {"ok": True, "token": token}
331
 
332
+
333
+ async def _require_user(request: Request) -> dict:
334
+ auth = request.headers.get("authorization", "")
335
+ if not auth.lower().startswith("bearer "):
336
+ raise fastapi.HTTPException(status_code=401, detail="Missing bearer token")
337
+ token = auth.split(" ", 1)[1].strip()
338
+ handle = app.state.sessions.get(token)
339
+ if not handle:
340
+ raise fastapi.HTTPException(status_code=401, detail="Invalid session")
341
+
342
+ users = await _read_users()
343
+ user = next((u for u in users if u.get("handle") == handle), None)
344
+ if not user or user.get("disabled"):
345
+ raise fastapi.HTTPException(status_code=401, detail="User disabled")
346
+ return user
347
+
348
+
349
  @app.get("/me")
350
  async def me(request: Request):
351
  user = await _require_user(request)
352
+ pub = {k: v for k, v in user.items() if k not in ("pin_hash",)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
353
  return {"ok": True, "user": pub}
354
 
355
 
356
+ @app.post("/chat/{video_id}")
357
+ async def post_message(video_id: str, msg: NewMessage, request: Request):
358
+ """
359
+ Append a message to a room's chat.
360
+ Body: { "text": "hello", "images": [...] }
361
+ """
362
+ path = _chat_file_for(video_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
363
  lock = _lock_for(path)
364
+
365
+ # Require a valid session and take author from the user profile
366
  user = await _require_user(request)
367
+ author = _valid_author(f"{user.get('first_name','')} {user.get('last_name','')}".strip() or user["handle"])
368
+ handle = user["handle"]
369
  klass = user.get("klass", "")
370
  profile_image = user.get("profile_image", "")
371
+
372
+ text = _valid_text(msg.text)
373
+ if not text and not (msg.images and len(msg.images) > 0):
374
+ return {"ok": False, "error": "Empty message and no images"}
375
+
376
  created = _now_iso()
377
+ mid = hashlib.sha1(f"{video_id}|{author}|{created}|{text}".encode("utf-8")).hexdigest()[:16]
378
+ ip = request.client.host if request and request.client else None
379
+
380
+ record = {
381
  "id": mid,
382
+ "video_id": video_id,
383
+ "room": video_id,
384
  "author": author,
385
  "handle": handle,
386
  "klass": klass,
 
388
  "text": text,
389
  "images": msg.images or [],
390
  "created_at": created,
391
+ "ip": ip,
392
+ "ua": request.headers.get("user-agent", "")[:200],
393
  }
394
+
395
  async with lock:
396
+ await _append_jsonl(path, record)
 
397
 
398
+ return {"ok": True, "message": record}
399
+
400
+
401
+ # ---------------------------
402
+ # DM endpoints (map to deterministic dm room names)
403
+ # ---------------------------
404
+ @app.get("/dm/{other_handle}")
405
+ async def get_dm(other_handle: str, request: Request, limit: int = 50, since: Optional[str] = None):
406
+ user = await _require_user(request)
407
+ a = user["handle"]
408
+ b = _safe_handle(other_handle.lstrip("@"))
409
+ if a == b:
410
+ # Allow reading your own DMs (empty)
411
+ room = f"dm_{a}_{b}"
412
  else:
413
+ handles = sorted([a, b])
414
+ room = f"dm_{handles[0]}_{handles[1]}"
415
+ return await get_messages(room, limit=limit, since=since)
416
+
417
+
418
+ @app.post("/dm/{other_handle}")
419
+ async def post_dm(other_handle: str, msg: NewMessage, request: Request):
420
+ user = await _require_user(request)
421
+ a = user["handle"]
422
+ b = _safe_handle(other_handle.lstrip("@"))
423
+ handles = sorted([a, b])
424
+ room = f"dm_{handles[0]}_{handles[1]}"
425
+ # reuse post_message logic: make a NewMessage-like object and call post_message
426
+ return await post_message(room, msg, request)
427
+
428
 
429
  # ---------------------------
430
+ # Admin: user posts listing & deletion
431
  # ---------------------------
432
+ @app.get("/admin/user_posts/{handle}")
433
+ async def admin_user_posts(handle: str, request: Request, limit: int = 500):
434
+ """Return a list of messages authored by handle across chat files (admin-only)."""
435
  _require_admin(request)
436
+ handle = _safe_handle(handle.lstrip("@"))
437
+ collected = []
438
+ # scan all chat files
439
+ for fname in os.listdir(CHAT_DIR):
440
+ if not fname.endswith(".jsonl"):
441
+ continue
442
+ path = os.path.join(CHAT_DIR, fname)
443
  try:
444
+ msgs = await _read_jsonl(path)
 
 
 
445
  except Exception:
446
+ msgs = []
447
+ for m in msgs:
448
+ if m.get("handle") == handle:
449
+ collected.append(m)
450
+ if len(collected) >= limit:
451
+ break
452
+ if len(collected) >= limit:
453
+ break
454
+ # sort descending by created_at
455
+ collected.sort(key=lambda m: m.get("created_at", ""), reverse=True)
456
+ return {"count": len(collected), "messages": collected}
457
+
458
 
459
+ @app.delete("/admin/posts/{post_id}")
460
+ async def admin_delete_post_admin(post_id: str, request: Request):
461
+ """Admin-only deletion endpoint (x-admin-key)."""
462
  _require_admin(request)
463
+ # reuse deletion routine
464
+ ok = await _delete_post_by_id(post_id)
465
+ if not ok:
466
+ raise fastapi.HTTPException(status_code=404, detail="Post not found")
467
+ return {"ok": True}
 
 
 
 
 
 
468
 
469
+
470
+ @app.delete("/posts/{post_id}")
471
+ async def delete_post(post_id: str, request: Request):
 
 
472
  """
473
+ Delete a post if the caller is admin (x-admin-key) or a verified moderator (staff/dev).
 
474
  """
475
+ # admin via header
476
+ provided = request.headers.get("x-admin-key", "")
477
+ if ADMIN_KEY and provided == ADMIN_KEY:
478
+ ok = await _delete_post_by_id(post_id)
479
+ if not ok:
480
+ raise fastapi.HTTPException(status_code=404, detail="Post not found")
481
+ return {"ok": True}
482
+
483
+ # otherwise require user and verified
484
+ user = await _require_user(request)
485
+ if user.get("verified") not in ("staff", "dev"):
486
+ raise fastapi.HTTPException(status_code=403, detail="Not authorized to delete posts")
487
+
488
+ ok = await _delete_post_by_id(post_id)
489
+ if not ok:
490
+ raise fastapi.HTTPException(status_code=404, detail="Post not found")
491
+ return {"ok": True}
492
+
493
+
494
+ async def _delete_post_by_id(post_id: str) -> bool:
495
+ """
496
+ Scan chat files and remove the message with id==post_id.
497
+ Returns True if deleted, False if not found.
498
+ """
499
+ for fname in os.listdir(CHAT_DIR):
500
+ if not fname.endswith(".jsonl"):
501
+ continue
502
+ path = os.path.join(CHAT_DIR, fname)
503
+ lock = _lock_for(path)
504
+ async with lock:
505
+ msgs = await _read_jsonl(path)
506
+ found = False
507
+ new_msgs = []
508
+ for m in msgs:
509
+ if m.get("id") == post_id:
510
+ found = True
511
+ continue
512
+ new_msgs.append(m)
513
+ if found:
514
+ # rewrite file atomically
515
+ def _write_all():
516
+ with open(path, "w", encoding="utf-8") as f:
517
+ for rec in new_msgs:
518
+ f.write(json.dumps(rec, ensure_ascii=False) + "\n")
519
+ await asyncio.to_thread(_write_all)
520
+ return True
521
+ return False
522
+
523
 
524
  # ---------------------------
525
+ # (Original iCloud album endpoints)
526
  # ---------------------------
527
+ BASE_62_MAP = {c: i for i, c in enumerate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")}
528
 
529
+
530
+ async def get_client() -> httpx.AsyncClient:
531
+ if not hasattr(app.state, "client"):
532
+ app.state.client = httpx.AsyncClient(timeout=15.0)
533
+ return app.state.client
534
+
535
+
536
+ def base62_to_int(token: str) -> int:
537
+ result = 0
538
+ for ch in token:
539
+ result = result * 62 + BASE_62_MAP[ch]
540
+ return result
541
+
542
+
543
+ async def get_base_url(token: str) -> str:
544
+ first = token[0]
545
+ if first == "A":
546
+ n = base62_to_int(token[1])
547
+ else:
548
+ n = base62_to_int(token[1:3])
549
+ return f"https://p{n:02d}-sharedstreams.icloud.com/{token}/sharedstreams/"
550
+
551
+
552
+ ICLOUD_HEADERS = {
553
+ "Origin": "https://www.icloud.com",
554
+ "Content-Type": "text/plain",
555
+ }
556
+ ICLOUD_PAYLOAD = '{"streamCtag":null}'
557
+
558
+
559
+ async def get_redirected_base_url(base_url: str, token: str) -> str:
560
+ client = await get_client()
561
+ resp = await client.post(
562
+ f"{base_url}webstream", headers=ICLOUD_HEADERS, data=ICLOUD_PAYLOAD, follow_redirects=False
563
+ )
564
+ if resp.status_code == 330:
565
+ try:
566
+ body = resp.json()
567
+ host = body.get("X-Apple-MMe-Host")
568
+ if not host:
569
+ raise ValueError("Missing X-Apple-MMe-Host in 330 response")
570
+ logging.info(f"Redirected to {host}")
571
+ return f"https://{host}/{token}/sharedstreams/"
572
+ except Exception as e:
573
+ logging.error(f"Redirect parsing failed: {e}")
574
+ raise
575
+ elif resp.status_code == 200:
576
+ return base_url
577
+ else:
578
+ resp.raise_for_status()
579
+
580
+
581
+ async def post_json(path: str, base_url: str, payload: str) -> dict:
582
+ client = await get_client()
583
+ resp = await client.post(f"{base_url}{path}", headers=ICLOUD_HEADERS, data=payload)
584
+ resp.raise_for_status()
585
+ return resp.json()
586
+
587
+
588
+ async def get_metadata(base_url: str) -> list:
589
+ data = await post_json("webstream", base_url, ICLOUD_PAYLOAD)
590
+ return data.get("photos", [])
591
+
592
+
593
+ async def get_asset_urls(base_url: str, guids: list) -> dict:
594
+ payload = json.dumps({"photoGuids": guids})
595
+ data = await post_json("webasseturls", base_url, payload)
596
+ return data.get("items", {})
597
+
598
+
599
+ @app.get("/album/{token}")
600
+ async def get_album(token: str):
601
+ try:
602
+ base_url = await get_base_url(token)
603
+ base_url = await get_redirected_base_url(base_url, token)
604
+
605
+ metadata = await get_metadata(base_url)
606
+ guids = [photo["photoGuid"] for photo in metadata]
607
+ asset_map = await get_asset_urls(base_url, guids)
608
+
609
+ videos = []
610
+ for photo in metadata:
611
+ if photo.get("mediaAssetType", "").lower() != "video":
612
+ continue
613
+
614
+ derivatives = photo.get("derivatives", {})
615
+ best = max(
616
+ (d for k, d in derivatives.items() if k.lower() != "posterframe"),
617
+ key=lambda d: int(d.get("fileSize") or 0),
618
+ default=None,
619
+ )
620
+ if not best:
621
+ continue
622
+
623
+ checksum = best.get("checksum")
624
+ info = asset_map.get(checksum)
625
+ if not info:
626
+ continue
627
+ video_url = f"https://{info['url_location']}{info['url_path']}"
628
+
629
+ poster = None
630
+ pf = derivatives.get("PosterFrame")
631
+ if pf:
632
+ pf_info = asset_map.get(pf.get("checksum"))
633
+ if pf_info:
634
+ poster = f"https://{pf_info['url_location']}{pf_info['url_path']}"
635
+
636
+ videos.append({
637
+ "caption": photo.get("caption", ""),
638
+ "url": video_url,
639
+ "poster": poster or "",
640
+ })
641
+
642
+ return {"videos": videos}
643
+
644
+ except Exception as e:
645
+ logging.exception("Error in get_album")
646
+ return {"error": str(e)}
647
+
648
+
649
+ @app.get("/album/{token}/raw")
650
+ async def get_album_raw(token: str):
651
+ try:
652
+ base_url = await get_base_url(token)
653
+ base_url = await get_redirected_base_url(base_url, token)
654
+ metadata = await get_metadata(base_url)
655
+ guids = [photo["photoGuid"] for photo in metadata]
656
+ asset_map = await get_asset_urls(base_url, guids)
657
+ return {"metadata": metadata, "asset_urls": asset_map}
658
+ except Exception as e:
659
+ logging.exception("Error in get_album_raw")
660
+ return {"error": str(e)}