SalexAI commited on
Commit
7b09fd2
·
verified ·
1 Parent(s): 5753928

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +388 -59
app.py CHANGED
@@ -1,4 +1,4 @@
1
- from fastapi import FastAPI, Request
2
  from fastapi.middleware.cors import CORSMiddleware
3
  from pydantic import BaseModel, Field
4
  from typing import Optional, List, Dict
@@ -25,7 +25,7 @@ app.add_middleware(
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")
@@ -37,21 +37,32 @@ 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,45 +70,35 @@ def _lock_for(path: str) -> asyncio.Lock:
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:
@@ -105,44 +106,34 @@ def _safe_handle(s: str) -> str:
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
  def _gen_temp_pin(digits: int = 6) -> str:
118
  # numeric temp PIN (easier to convey to students)
119
  lower = 10 ** (digits - 1)
120
  return str(random.randint(lower, 10 ** digits - 1))
121
 
122
-
123
  async def _append_user(record: dict) -> None:
124
  line = json.dumps(record, ensure_ascii=False) + "\n"
125
-
126
  def _write():
127
  with open(USERS_FILE, "a", encoding="utf-8") as f:
128
  f.write(line)
129
-
130
  async with app.state.users_lock:
131
  await asyncio.to_thread(_write)
132
 
133
-
134
  async def _read_users() -> List[dict]:
135
  if not os.path.exists(USERS_FILE):
136
  return []
137
-
138
  def _read():
139
  with open(USERS_FILE, "r", encoding="utf-8") as f:
140
  return [json.loads(x) for x in f if x.strip()]
141
-
142
  async with app.state.users_lock:
143
  return await asyncio.to_thread(_read)
144
 
145
-
146
  async def _load_sessions():
147
  if os.path.exists(SESSIONS_FILE):
148
  def _read():
@@ -152,35 +143,94 @@ async def _load_sessions():
152
  else:
153
  app.state.sessions = {}
154
 
155
-
156
  async def _save_sessions():
157
  def _write(data):
158
  with open(SESSIONS_FILE, "w", encoding="utf-8") as f:
159
  json.dump(data, f)
160
-
161
  async with app.state.sessions_lock:
162
  await asyncio.to_thread(_write, app.state.sessions)
163
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
 
165
  @app.on_event("startup")
166
- async def _startup_load_sessions():
167
  await _load_sessions()
168
-
 
 
169
 
170
  def _require_admin(request: Request):
171
  provided = request.headers.get("x-admin-key", "")
172
  if not ADMIN_KEY or provided != ADMIN_KEY:
173
  raise fastapi.HTTPException(status_code=401, detail="Admin key required")
174
 
 
 
 
 
175
 
176
  # ---------------------------
177
- # 💬 Chat API (public read, authed write)
178
  # ---------------------------
179
  class NewMessage(BaseModel):
180
  author: Optional[str] = Field(default="anon", max_length=64) # ignored on server write
181
  text: str = Field(..., min_length=1, max_length=5000)
182
 
183
-
184
  @app.get("/chat/{video_id}")
185
  async def get_messages(video_id: str, limit: int = 50, since: Optional[str] = None):
186
  """
@@ -206,11 +256,56 @@ async def get_messages(video_id: str, limit: int = 50, since: Optional[str] = No
206
  if len(items) > limit:
207
  items = items[-limit:]
208
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  return {"video_id": video_id, "count": len(items), "messages": items}
210
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
 
212
  # ---------------------------
213
- # 👤 Users & Auth API
214
  # ---------------------------
215
  class NewUser(BaseModel):
216
  email: str
@@ -222,7 +317,6 @@ class NewUser(BaseModel):
222
  description: Optional[str] = ""
223
  pin: str = Field(..., min_length=3, max_length=32)
224
 
225
-
226
  class UpdateUser(BaseModel):
227
  first_name: Optional[str] = None
228
  last_name: Optional[str] = None
@@ -232,12 +326,15 @@ class UpdateUser(BaseModel):
232
  pin: Optional[str] = None
233
  disabled: Optional[bool] = None
234
 
 
 
 
 
235
 
236
  class LoginReq(BaseModel):
237
  handle: str
238
  pin: str
239
 
240
-
241
  @app.post("/admin/users")
242
  async def admin_create_user(user: NewUser, request: Request):
243
  _require_admin(request)
@@ -264,7 +361,6 @@ async def admin_create_user(user: NewUser, request: Request):
264
  pub = {k: v for k, v in rec.items() if k != "pin_hash"}
265
  return {"ok": True, "user": pub}
266
 
267
-
268
  @app.get("/admin/users")
269
  async def admin_list_users(request: Request, q: Optional[str] = None, limit: int = 200):
270
  _require_admin(request)
@@ -277,7 +373,6 @@ async def admin_list_users(request: Request, q: Optional[str] = None, limit: int
277
  u.pop("pin_hash", None)
278
  return {"count": len(users), "users": users}
279
 
280
-
281
  @app.get("/admin/users/{handle}")
282
  async def admin_get_user(handle: str, request: Request):
283
  """
@@ -293,7 +388,6 @@ async def admin_get_user(handle: str, request: Request):
293
  pub = {k: v for k, v in u.items() if k != "pin_hash"}
294
  return {"ok": True, "user": pub}
295
 
296
-
297
  @app.put("/admin/users/{handle}")
298
  async def admin_update_user(handle: str, patch: UpdateUser, request: Request):
299
  _require_admin(request)
@@ -331,6 +425,39 @@ async def admin_update_user(handle: str, patch: UpdateUser, request: Request):
331
  await asyncio.to_thread(_write_all)
332
  return {"ok": True}
333
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
334
 
335
  @app.post("/admin/users/{handle}/reset_pin")
336
  async def admin_reset_pin(handle: str, body: dict = None, request: Request = None):
@@ -378,7 +505,6 @@ async def admin_reset_pin(handle: str, body: dict = None, request: Request = Non
378
  logging.info(f"Admin reset PIN for @{handle}")
379
  return {"ok": True, "new_pin": new_pin}
380
 
381
-
382
  @app.post("/auth/login")
383
  async def login(req: LoginReq):
384
  users = await _read_users()
@@ -395,7 +521,6 @@ async def login(req: LoginReq):
395
  # include must_change_pin to allow client to force password change
396
  return {"ok": True, "token": token, "must_change_pin": bool(u.get("must_change_pin", False))}
397
 
398
-
399
  async def _require_user(request: Request) -> dict:
400
  auth = request.headers.get("authorization", "")
401
  if not auth.lower().startswith("bearer "):
@@ -411,13 +536,232 @@ async def _require_user(request: Request) -> dict:
411
  raise fastapi.HTTPException(status_code=401, detail="User disabled")
412
  return user
413
 
414
-
415
  @app.get("/me")
416
  async def me(request: Request):
417
  user = await _require_user(request)
418
  pub = {k: v for k, v in user.items() if k not in ("pin_hash",)}
419
  return {"ok": True, "user": pub}
420
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
421
 
422
  @app.post("/chat/{video_id}")
423
  async def post_message(video_id: str, msg: NewMessage, request: Request):
@@ -461,24 +805,22 @@ async def post_message(video_id: str, msg: NewMessage, request: Request):
461
 
462
  return {"ok": True, "message": record}
463
 
464
-
465
- # (rest of your iCloud album endpoints unchanged)
 
466
  BASE_62_MAP = {c: i for i, c in enumerate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")}
467
 
468
-
469
  async def get_client() -> httpx.AsyncClient:
470
  if not hasattr(app.state, "client"):
471
  app.state.client = httpx.AsyncClient(timeout=15.0)
472
  return app.state.client
473
 
474
-
475
  def base62_to_int(token: str) -> int:
476
  result = 0
477
  for ch in token:
478
  result = result * 62 + BASE_62_MAP[ch]
479
  return result
480
 
481
-
482
  async def get_base_url(token: str) -> str:
483
  first = token[0]
484
  if first == "A":
@@ -487,19 +829,12 @@ async def get_base_url(token: str) -> str:
487
  n = base62_to_int(token[1:3])
488
  return f"https://p{n:02d}-sharedstreams.icloud.com/{token}/sharedstreams/"
489
 
490
-
491
-
492
-
493
- # ... (keep your existing iCloud helper endpoints as-is) ...
494
-
495
-
496
  ICLOUD_HEADERS = {
497
  "Origin": "https://www.icloud.com",
498
  "Content-Type": "text/plain",
499
  }
500
  ICLOUD_PAYLOAD = '{"streamCtag":null}'
501
 
502
-
503
  async def get_redirected_base_url(base_url: str, token: str) -> str:
504
  client = await get_client()
505
  resp = await client.post(
@@ -521,31 +856,26 @@ async def get_redirected_base_url(base_url: str, token: str) -> str:
521
  else:
522
  resp.raise_for_status()
523
 
524
-
525
  async def post_json(path: str, base_url: str, payload: str) -> dict:
526
  client = await get_client()
527
  resp = await client.post(f"{base_url}{path}", headers=ICLOUD_HEADERS, data=payload)
528
  resp.raise_for_status()
529
  return resp.json()
530
 
531
-
532
  async def get_metadata(base_url: str) -> list:
533
  data = await post_json("webstream", base_url, ICLOUD_PAYLOAD)
534
  return data.get("photos", [])
535
 
536
-
537
  async def get_asset_urls(base_url: str, guids: list) -> dict:
538
  payload = json.dumps({"photoGuids": guids})
539
  data = await post_json("webasseturls", base_url, payload)
540
  return data.get("items", {})
541
 
542
-
543
  @app.get("/album/{token}")
544
  async def get_album(token: str):
545
  try:
546
  base_url = await get_base_url(token)
547
  base_url = await get_redirected_base_url(base_url, token)
548
-
549
  metadata = await get_metadata(base_url)
550
  guids = [photo["photoGuid"] for photo in metadata]
551
  asset_map = await get_asset_urls(base_url, guids)
@@ -589,7 +919,6 @@ async def get_album(token: str):
589
  logging.exception("Error in get_album")
590
  return {"error": str(e)}
591
 
592
-
593
  @app.get("/album/{token}/raw")
594
  async def get_album_raw(token: str):
595
  try:
@@ -601,4 +930,4 @@ async def get_album_raw(token: str):
601
  return {"metadata": metadata, "asset_urls": asset_map}
602
  except Exception as e:
603
  logging.exception("Error in get_album_raw")
604
- return {"error": str(e)}
 
1
+ from fastapi import FastAPI, Request, HTTPException
2
  from fastapi.middleware.cors import CORSMiddleware
3
  from pydantic import BaseModel, Field
4
  from typing import Optional, List, Dict
 
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")
 
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
+
41
+ # New storage files
42
+ ROOMS_FILE = os.path.join(USERS_DIR, "rooms.json")
43
+ VOTES_FILE = os.path.join(USERS_DIR, "votes.json")
44
+ BADGES_FILE = os.path.join(USERS_DIR, "badges.json")
45
+
46
  ADMIN_KEY = os.environ.get("ADMIN_KEY", "") # set in HF Space secrets
47
 
48
  # Async file locks
49
  app.state.chat_locks = {}
50
  app.state.users_lock = asyncio.Lock()
51
  app.state.sessions_lock = asyncio.Lock()
52
+ app.state.rooms_lock = asyncio.Lock()
53
+ app.state.votes_lock = asyncio.Lock()
54
+ app.state.badges_lock = asyncio.Lock()
55
 
56
+ app.state.sessions: Dict[str, str] = {}
57
+ app.state.rooms: List[Dict] = []
58
+ app.state.votes: Dict[str, Dict] = {} # post_id -> {upvotes: int, downvotes: int, voters: {user_handle: "up"|"down"}}
59
+ app.state.badges: Dict[str, str] = {} # user_handle -> badge_type
60
 
61
  def _chat_file_for(video_id: str) -> str:
62
  # Stable filename to avoid path traversal
63
  h = hashlib.sha256(video_id.encode("utf-8")).hexdigest()[:32]
64
  return os.path.join(CHAT_DIR, f"{h}.jsonl")
65
 
 
66
  def _lock_for(path: str) -> asyncio.Lock:
67
  lock = app.state.chat_locks.get(path)
68
  if lock is None:
 
70
  app.state.chat_locks[path] = lock
71
  return lock
72
 
 
73
  def _now_iso() -> str:
74
  return datetime.now(timezone.utc).isoformat()
75
 
 
76
  def _valid_author(s: Optional[str]) -> str:
77
  s = (s or "anon").strip()
78
  s = re.sub(r"\s+", " ", s)
79
  return s[:32] or "anon"
80
 
 
81
  def _valid_text(s: str) -> str:
82
  s = (s or "").rstrip("\n")
83
  return s[:2000]
84
 
 
85
  async def _append_jsonl(path: str, record: dict) -> None:
86
  line = json.dumps(record, ensure_ascii=False) + "\n"
 
87
  def _write():
88
  with open(path, "a", encoding="utf-8") as f:
89
  f.write(line)
 
90
  await asyncio.to_thread(_write)
91
 
 
92
  async def _read_jsonl(path: str) -> List[dict]:
93
  if not os.path.exists(path):
94
  return []
 
95
  def _read():
96
  with open(path, "r", encoding="utf-8") as f:
97
  return [json.loads(x) for x in f if x.strip()]
 
98
  return await asyncio.to_thread(_read)
99
 
 
100
  # ---------------------------
101
+ # Users & Auth helpers
102
  # ---------------------------
103
 
104
  def _safe_handle(s: str) -> str:
 
106
  s = re.sub(r"[^a-zA-Z0-9_.~-]", "_", s)
107
  return s[:24]
108
 
 
109
  def _hash_pin(pin: str) -> str:
110
  return hashlib.sha256(("tlks|" + pin).encode("utf-8")).hexdigest()
111
 
 
112
  def _rand_token() -> str:
113
  return hashlib.sha256(os.urandom(32)).hexdigest()
114
 
 
115
  def _gen_temp_pin(digits: int = 6) -> str:
116
  # numeric temp PIN (easier to convey to students)
117
  lower = 10 ** (digits - 1)
118
  return str(random.randint(lower, 10 ** digits - 1))
119
 
 
120
  async def _append_user(record: dict) -> None:
121
  line = json.dumps(record, ensure_ascii=False) + "\n"
 
122
  def _write():
123
  with open(USERS_FILE, "a", encoding="utf-8") as f:
124
  f.write(line)
 
125
  async with app.state.users_lock:
126
  await asyncio.to_thread(_write)
127
 
 
128
  async def _read_users() -> List[dict]:
129
  if not os.path.exists(USERS_FILE):
130
  return []
 
131
  def _read():
132
  with open(USERS_FILE, "r", encoding="utf-8") as f:
133
  return [json.loads(x) for x in f if x.strip()]
 
134
  async with app.state.users_lock:
135
  return await asyncio.to_thread(_read)
136
 
 
137
  async def _load_sessions():
138
  if os.path.exists(SESSIONS_FILE):
139
  def _read():
 
143
  else:
144
  app.state.sessions = {}
145
 
 
146
  async def _save_sessions():
147
  def _write(data):
148
  with open(SESSIONS_FILE, "w", encoding="utf-8") as f:
149
  json.dump(data, f)
 
150
  async with app.state.sessions_lock:
151
  await asyncio.to_thread(_write, app.state.sessions)
152
 
153
+ # New storage functions
154
+ async def _load_rooms():
155
+ if os.path.exists(ROOMS_FILE):
156
+ def _read():
157
+ with open(ROOMS_FILE, "r", encoding="utf-8") as f:
158
+ return json.load(f)
159
+ app.state.rooms = await asyncio.to_thread(_read)
160
+ else:
161
+ # Default rooms
162
+ app.state.rooms = [
163
+ {"id": "main", "name": "General", "private": False},
164
+ {"id": "math101", "name": "Math 101", "private": True, "invite": "MATH-101-2024"},
165
+ {"id": "eng101", "name": "English 101", "private": True, "invite": "ENG-101-2024"},
166
+ {"id": "grade6", "name": "Grade 6", "private": True, "invite": "GRADE6-OK"},
167
+ {"id": "grade7", "name": "Grade 7", "private": True, "invite": "GRADE7-OK"}
168
+ ]
169
+ await _save_rooms()
170
+
171
+ async def _save_rooms():
172
+ def _write(data):
173
+ with open(ROOMS_FILE, "w", encoding="utf-8") as f:
174
+ json.dump(data, f)
175
+ async with app.state.rooms_lock:
176
+ await asyncio.to_thread(_write, app.state.rooms)
177
+
178
+ async def _load_votes():
179
+ if os.path.exists(VOTES_FILE):
180
+ def _read():
181
+ with open(VOTES_FILE, "r", encoding="utf-8") as f:
182
+ return json.load(f)
183
+ app.state.votes = await asyncio.to_thread(_read)
184
+ else:
185
+ app.state.votes = {}
186
+
187
+ async def _save_votes():
188
+ def _write(data):
189
+ with open(VOTES_FILE, "w", encoding="utf-8") as f:
190
+ json.dump(data, f)
191
+ async with app.state.votes_lock:
192
+ await asyncio.to_thread(_write, app.state.votes)
193
+
194
+ async def _load_badges():
195
+ if os.path.exists(BADGES_FILE):
196
+ def _read():
197
+ with open(BADGES_FILE, "r", encoding="utf-8") as f:
198
+ return json.load(f)
199
+ app.state.badges = await asyncio.to_thread(_read)
200
+ else:
201
+ app.state.badges = {}
202
+
203
+ async def _save_badges():
204
+ def _write(data):
205
+ with open(BADGES_FILE, "w", encoding="utf-8") as f:
206
+ json.dump(data, f)
207
+ async with app.state.badges_lock:
208
+ await asyncio.to_thread(_write, app.state.badges)
209
 
210
  @app.on_event("startup")
211
+ async def _startup_load_data():
212
  await _load_sessions()
213
+ await _load_rooms()
214
+ await _load_votes()
215
+ await _load_badges()
216
 
217
  def _require_admin(request: Request):
218
  provided = request.headers.get("x-admin-key", "")
219
  if not ADMIN_KEY or provided != ADMIN_KEY:
220
  raise fastapi.HTTPException(status_code=401, detail="Admin key required")
221
 
222
+ def _is_verified_staff(handle: str) -> bool:
223
+ """Check if user is verified staff (can moderate)"""
224
+ VERIFIED_STAFF = {"teacher1", "staff", "alex"} # Configure as needed
225
+ return handle in VERIFIED_STAFF
226
 
227
  # ---------------------------
228
+ # Chat API (public read, authed write)
229
  # ---------------------------
230
  class NewMessage(BaseModel):
231
  author: Optional[str] = Field(default="anon", max_length=64) # ignored on server write
232
  text: str = Field(..., min_length=1, max_length=5000)
233
 
 
234
  @app.get("/chat/{video_id}")
235
  async def get_messages(video_id: str, limit: int = 50, since: Optional[str] = None):
236
  """
 
256
  if len(items) > limit:
257
  items = items[-limit:]
258
 
259
+ # Add vote counts and badges to messages
260
+ for item in items:
261
+ post_id = item.get("id")
262
+ if post_id and post_id in app.state.votes:
263
+ vote_data = app.state.votes[post_id]
264
+ item["upvotes"] = vote_data.get("upvotes", 0)
265
+ item["downvotes"] = vote_data.get("downvotes", 0)
266
+ else:
267
+ item["upvotes"] = 0
268
+ item["downvotes"] = 0
269
+
270
+ # Add badge info
271
+ handle = item.get("handle")
272
+ if handle and handle in app.state.badges:
273
+ item["badge"] = app.state.badges[handle]
274
+
275
  return {"video_id": video_id, "count": len(items), "messages": items}
276
 
277
+ @app.delete("/chat/{video_id}/{message_id}")
278
+ async def delete_message(video_id: str, message_id: str, request: Request):
279
+ """
280
+ Delete a message (staff only)
281
+ """
282
+ user = await _require_user(request)
283
+ if not _is_verified_staff(user["handle"]):
284
+ raise HTTPException(status_code=403, detail="Staff access required")
285
+
286
+ path = _chat_file_for(video_id)
287
+ lock = _lock_for(path)
288
+
289
+ async with lock:
290
+ items = await _read_jsonl(path)
291
+ original_count = len(items)
292
+ items = [m for m in items if m.get("id") != message_id]
293
+
294
+ if len(items) == original_count:
295
+ raise HTTPException(status_code=404, detail="Message not found")
296
+
297
+ # Rewrite file without the deleted message
298
+ def _write_all():
299
+ with open(path, "w", encoding="utf-8") as f:
300
+ for item in items:
301
+ f.write(json.dumps(item, ensure_ascii=False) + "\n")
302
+ await asyncio.to_thread(_write_all)
303
+
304
+ logging.info(f"Message {message_id} deleted by {user['handle']}")
305
+ return {"ok": True}
306
 
307
  # ---------------------------
308
+ # Users & Auth API
309
  # ---------------------------
310
  class NewUser(BaseModel):
311
  email: str
 
317
  description: Optional[str] = ""
318
  pin: str = Field(..., min_length=3, max_length=32)
319
 
 
320
  class UpdateUser(BaseModel):
321
  first_name: Optional[str] = None
322
  last_name: Optional[str] = None
 
326
  pin: Optional[str] = None
327
  disabled: Optional[bool] = None
328
 
329
+ class UpdateProfile(BaseModel):
330
+ first_name: Optional[str] = None
331
+ last_name: Optional[str] = None
332
+ profile_image: Optional[str] = None
333
 
334
  class LoginReq(BaseModel):
335
  handle: str
336
  pin: str
337
 
 
338
  @app.post("/admin/users")
339
  async def admin_create_user(user: NewUser, request: Request):
340
  _require_admin(request)
 
361
  pub = {k: v for k, v in rec.items() if k != "pin_hash"}
362
  return {"ok": True, "user": pub}
363
 
 
364
  @app.get("/admin/users")
365
  async def admin_list_users(request: Request, q: Optional[str] = None, limit: int = 200):
366
  _require_admin(request)
 
373
  u.pop("pin_hash", None)
374
  return {"count": len(users), "users": users}
375
 
 
376
  @app.get("/admin/users/{handle}")
377
  async def admin_get_user(handle: str, request: Request):
378
  """
 
388
  pub = {k: v for k, v in u.items() if k != "pin_hash"}
389
  return {"ok": True, "user": pub}
390
 
 
391
  @app.put("/admin/users/{handle}")
392
  async def admin_update_user(handle: str, patch: UpdateUser, request: Request):
393
  _require_admin(request)
 
425
  await asyncio.to_thread(_write_all)
426
  return {"ok": True}
427
 
428
+ @app.put("/me/profile")
429
+ async def update_my_profile(patch: UpdateProfile, request: Request):
430
+ """
431
+ Update current user's profile
432
+ """
433
+ user = await _require_user(request)
434
+ handle = user["handle"]
435
+
436
+ users = await _read_users()
437
+ changed = False
438
+ for u in users:
439
+ if u.get("handle") == handle:
440
+ if patch.first_name is not None:
441
+ u["first_name"] = patch.first_name.strip()
442
+ if patch.last_name is not None:
443
+ u["last_name"] = patch.last_name.strip()
444
+ if patch.profile_image is not None:
445
+ u["profile_image"] = patch.profile_image.strip()
446
+ changed = True
447
+ break
448
+
449
+ if not changed:
450
+ raise HTTPException(status_code=404, detail="User not found")
451
+
452
+ # rewrite file
453
+ async with app.state.users_lock:
454
+ def _write_all():
455
+ with open(USERS_FILE, "w", encoding="utf-8") as f:
456
+ for rec in users:
457
+ f.write(json.dumps(rec, ensure_ascii=False) + "\n")
458
+ await asyncio.to_thread(_write_all)
459
+
460
+ return {"ok": True}
461
 
462
  @app.post("/admin/users/{handle}/reset_pin")
463
  async def admin_reset_pin(handle: str, body: dict = None, request: Request = None):
 
505
  logging.info(f"Admin reset PIN for @{handle}")
506
  return {"ok": True, "new_pin": new_pin}
507
 
 
508
  @app.post("/auth/login")
509
  async def login(req: LoginReq):
510
  users = await _read_users()
 
521
  # include must_change_pin to allow client to force password change
522
  return {"ok": True, "token": token, "must_change_pin": bool(u.get("must_change_pin", False))}
523
 
 
524
  async def _require_user(request: Request) -> dict:
525
  auth = request.headers.get("authorization", "")
526
  if not auth.lower().startswith("bearer "):
 
536
  raise fastapi.HTTPException(status_code=401, detail="User disabled")
537
  return user
538
 
 
539
  @app.get("/me")
540
  async def me(request: Request):
541
  user = await _require_user(request)
542
  pub = {k: v for k, v in user.items() if k not in ("pin_hash",)}
543
  return {"ok": True, "user": pub}
544
 
545
+ # ---------------------------
546
+ # Rooms API
547
+ # ---------------------------
548
+
549
+ class NewRoom(BaseModel):
550
+ name: str = Field(..., min_length=1, max_length=64)
551
+ room_id: str = Field(..., min_length=1, max_length=32)
552
+ private: bool = False
553
+ invite_code: Optional[str] = None
554
+
555
+ class RoomAccessRequest(BaseModel):
556
+ invite_code: str
557
+
558
+ @app.get("/rooms")
559
+ async def get_rooms():
560
+ """
561
+ Get all rooms (without invite codes for security)
562
+ """
563
+ rooms = []
564
+ for room in app.state.rooms:
565
+ room_data = {
566
+ "id": room["id"],
567
+ "name": room["name"],
568
+ "private": room.get("private", False)
569
+ }
570
+ rooms.append(room_data)
571
+ return {"rooms": rooms}
572
+
573
+ @app.post("/rooms")
574
+ async def create_room(room_data: NewRoom, request: Request):
575
+ """
576
+ Create a new room (staff only)
577
+ """
578
+ user = await _require_user(request)
579
+ if not _is_verified_staff(user["handle"]):
580
+ raise HTTPException(status_code=403, detail="Staff access required")
581
+
582
+ # Validate room_id
583
+ room_id = re.sub(r"[^a-zA-Z0-9_.~-]", "_", room_data.room_id.strip())
584
+ if not room_id:
585
+ raise HTTPException(status_code=400, detail="Invalid room ID")
586
+
587
+ # Check if room already exists
588
+ if any(r["id"] == room_id for r in app.state.rooms):
589
+ raise HTTPException(status_code=400, detail="Room ID already exists")
590
+
591
+ # Validate invite code for private rooms
592
+ if room_data.private and not room_data.invite_code:
593
+ raise HTTPException(status_code=400, detail="Private rooms require an invite code")
594
+
595
+ new_room = {
596
+ "id": room_id,
597
+ "name": room_data.name.strip(),
598
+ "private": room_data.private,
599
+ "created_by": user["handle"],
600
+ "created_at": _now_iso()
601
+ }
602
+
603
+ if room_data.private and room_data.invite_code:
604
+ new_room["invite"] = room_data.invite_code.strip()
605
+
606
+ app.state.rooms.append(new_room)
607
+ await _save_rooms()
608
+
609
+ logging.info(f"Room {room_id} created by {user['handle']}")
610
+
611
+ # Return room without invite code
612
+ public_room = {k: v for k, v in new_room.items() if k != "invite"}
613
+ return {"ok": True, "room": public_room}
614
+
615
+ @app.post("/rooms/{room_id}/access")
616
+ async def request_room_access(room_id: str, access_req: RoomAccessRequest, request: Request):
617
+ """
618
+ Request access to a private room with invite code
619
+ """
620
+ user = await _require_user(request)
621
+
622
+ room = next((r for r in app.state.rooms if r["id"] == room_id), None)
623
+ if not room:
624
+ raise HTTPException(status_code=404, detail="Room not found")
625
+
626
+ if not room.get("private"):
627
+ return {"ok": True, "access_granted": True} # Public room
628
+
629
+ if room.get("invite") == access_req.invite_code.strip():
630
+ return {"ok": True, "access_granted": True}
631
+ else:
632
+ return {"ok": False, "access_granted": False, "error": "Invalid invite code"}
633
+
634
+ # ---------------------------
635
+ # Voting API
636
+ # ---------------------------
637
+
638
+ class VoteRequest(BaseModel):
639
+ vote_type: str # "up" or "down"
640
+
641
+ @app.post("/chat/{video_id}/{message_id}/vote")
642
+ async def vote_message(video_id: str, message_id: str, vote_req: VoteRequest, request: Request):
643
+ """
644
+ Vote on a message
645
+ """
646
+ user = await _require_user(request)
647
+ handle = user["handle"]
648
+
649
+ if vote_req.vote_type not in ["up", "down"]:
650
+ raise HTTPException(status_code=400, detail="Invalid vote type")
651
+
652
+ # Initialize vote data if doesn't exist
653
+ if message_id not in app.state.votes:
654
+ app.state.votes[message_id] = {
655
+ "upvotes": 0,
656
+ "downvotes": 0,
657
+ "voters": {}
658
+ }
659
+
660
+ vote_data = app.state.votes[message_id]
661
+ current_vote = vote_data["voters"].get(handle)
662
+
663
+ # Remove previous vote
664
+ if current_vote == "up":
665
+ vote_data["upvotes"] = max(0, vote_data["upvotes"] - 1)
666
+ elif current_vote == "down":
667
+ vote_data["downvotes"] = max(0, vote_data["downvotes"] - 1)
668
+
669
+ # Add new vote (or remove if same)
670
+ if current_vote == vote_req.vote_type:
671
+ # Toggle off - remove vote
672
+ del vote_data["voters"][handle]
673
+ else:
674
+ # Add new vote
675
+ vote_data["voters"][handle] = vote_req.vote_type
676
+ if vote_req.vote_type == "up":
677
+ vote_data["upvotes"] += 1
678
+ else:
679
+ vote_data["downvotes"] += 1
680
+
681
+ await _save_votes()
682
+
683
+ return {
684
+ "ok": True,
685
+ "upvotes": vote_data["upvotes"],
686
+ "downvotes": vote_data["downvotes"],
687
+ "user_vote": vote_data["voters"].get(handle)
688
+ }
689
+
690
+ @app.get("/chat/{video_id}/{message_id}/votes")
691
+ async def get_message_votes(video_id: str, message_id: str, request: Request):
692
+ """
693
+ Get vote counts for a message
694
+ """
695
+ user = await _require_user(request)
696
+ handle = user["handle"]
697
+
698
+ if message_id not in app.state.votes:
699
+ return {
700
+ "upvotes": 0,
701
+ "downvotes": 0,
702
+ "user_vote": None
703
+ }
704
+
705
+ vote_data = app.state.votes[message_id]
706
+ return {
707
+ "upvotes": vote_data["upvotes"],
708
+ "downvotes": vote_data["downvotes"],
709
+ "user_vote": vote_data["voters"].get(handle)
710
+ }
711
+
712
+ # ---------------------------
713
+ # Badge API
714
+ # ---------------------------
715
+
716
+ class BadgeRequest(BaseModel):
717
+ badge_type: str # "gold", "silver", "green", or "none" to remove
718
+
719
+ @app.post("/users/{target_handle}/badge")
720
+ async def assign_badge(target_handle: str, badge_req: BadgeRequest, request: Request):
721
+ """
722
+ Assign badge to user (staff only)
723
+ """
724
+ user = await _require_user(request)
725
+ if not _is_verified_staff(user["handle"]):
726
+ raise HTTPException(status_code=403, detail="Staff access required")
727
+
728
+ target_handle = _safe_handle(target_handle)
729
+
730
+ # Verify target user exists
731
+ users = await _read_users()
732
+ target_user = next((u for u in users if u.get("handle") == target_handle), None)
733
+ if not target_user:
734
+ raise HTTPException(status_code=404, detail="User not found")
735
+
736
+ valid_badges = {"gold", "silver", "green", "none"}
737
+ if badge_req.badge_type not in valid_badges:
738
+ raise HTTPException(status_code=400, detail="Invalid badge type")
739
+
740
+ if badge_req.badge_type == "none":
741
+ # Remove badge
742
+ if target_handle in app.state.badges:
743
+ del app.state.badges[target_handle]
744
+ else:
745
+ # Assign badge
746
+ app.state.badges[target_handle] = badge_req.badge_type
747
+
748
+ await _save_badges()
749
+
750
+ logging.info(f"Badge {badge_req.badge_type} assigned to {target_handle} by {user['handle']}")
751
+ return {"ok": True}
752
+
753
+ @app.get("/users/{handle}/badge")
754
+ async def get_user_badge(handle: str):
755
+ """
756
+ Get user's badge
757
+ """
758
+ handle = _safe_handle(handle)
759
+ badge = app.state.badges.get(handle)
760
+ return {"badge": badge}
761
+
762
+ # ---------------------------
763
+ # Enhanced Chat API
764
+ # ---------------------------
765
 
766
  @app.post("/chat/{video_id}")
767
  async def post_message(video_id: str, msg: NewMessage, request: Request):
 
805
 
806
  return {"ok": True, "message": record}
807
 
808
+ # ---------------------------
809
+ # iCloud album endpoints (unchanged)
810
+ # ---------------------------
811
  BASE_62_MAP = {c: i for i, c in enumerate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")}
812
 
 
813
  async def get_client() -> httpx.AsyncClient:
814
  if not hasattr(app.state, "client"):
815
  app.state.client = httpx.AsyncClient(timeout=15.0)
816
  return app.state.client
817
 
 
818
  def base62_to_int(token: str) -> int:
819
  result = 0
820
  for ch in token:
821
  result = result * 62 + BASE_62_MAP[ch]
822
  return result
823
 
 
824
  async def get_base_url(token: str) -> str:
825
  first = token[0]
826
  if first == "A":
 
829
  n = base62_to_int(token[1:3])
830
  return f"https://p{n:02d}-sharedstreams.icloud.com/{token}/sharedstreams/"
831
 
 
 
 
 
 
 
832
  ICLOUD_HEADERS = {
833
  "Origin": "https://www.icloud.com",
834
  "Content-Type": "text/plain",
835
  }
836
  ICLOUD_PAYLOAD = '{"streamCtag":null}'
837
 
 
838
  async def get_redirected_base_url(base_url: str, token: str) -> str:
839
  client = await get_client()
840
  resp = await client.post(
 
856
  else:
857
  resp.raise_for_status()
858
 
 
859
  async def post_json(path: str, base_url: str, payload: str) -> dict:
860
  client = await get_client()
861
  resp = await client.post(f"{base_url}{path}", headers=ICLOUD_HEADERS, data=payload)
862
  resp.raise_for_status()
863
  return resp.json()
864
 
 
865
  async def get_metadata(base_url: str) -> list:
866
  data = await post_json("webstream", base_url, ICLOUD_PAYLOAD)
867
  return data.get("photos", [])
868
 
 
869
  async def get_asset_urls(base_url: str, guids: list) -> dict:
870
  payload = json.dumps({"photoGuids": guids})
871
  data = await post_json("webasseturls", base_url, payload)
872
  return data.get("items", {})
873
 
 
874
  @app.get("/album/{token}")
875
  async def get_album(token: str):
876
  try:
877
  base_url = await get_base_url(token)
878
  base_url = await get_redirected_base_url(base_url, token)
 
879
  metadata = await get_metadata(base_url)
880
  guids = [photo["photoGuid"] for photo in metadata]
881
  asset_map = await get_asset_urls(base_url, guids)
 
919
  logging.exception("Error in get_album")
920
  return {"error": str(e)}
921
 
 
922
  @app.get("/album/{token}/raw")
923
  async def get_album_raw(token: str):
924
  try:
 
930
  return {"metadata": metadata, "asset_urls": asset_map}
931
  except Exception as e:
932
  logging.exception("Error in get_album_raw")
933
+ return {"error": str(e)}