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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +84 -140
app.py CHANGED
@@ -1,4 +1,3 @@
1
- # app.py
2
  from fastapi import FastAPI, Request
3
  from fastapi.middleware.cors import CORSMiddleware
4
  from pydantic import BaseModel, Field
@@ -12,6 +11,7 @@ import hashlib
12
  from datetime import datetime, timezone
13
  import re
14
  import fastapi
 
15
 
16
  app = FastAPI()
17
  logging.basicConfig(level=logging.INFO)
@@ -114,6 +114,12 @@ 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
 
@@ -171,9 +177,8 @@ def _require_admin(request: Request):
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}")
@@ -216,7 +221,6 @@ class NewUser(BaseModel):
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):
@@ -227,7 +231,6 @@ class UpdateUser(BaseModel):
227
  description: Optional[str] = None
228
  pin: Optional[str] = None
229
  disabled: Optional[bool] = None
230
- verified: Optional[str] = None
231
 
232
 
233
  class LoginReq(BaseModel):
@@ -254,7 +257,7 @@ async def admin_create_user(user: NewUser, request: Request):
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)
@@ -275,6 +278,22 @@ async def admin_list_users(request: Request, q: Optional[str] = None, limit: int
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):
280
  _require_admin(request)
@@ -297,8 +316,7 @@ async def admin_update_user(handle: str, patch: UpdateUser, request: Request):
297
  u["disabled"] = bool(patch.disabled)
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:
@@ -314,6 +332,53 @@ async def admin_update_user(handle: str, patch: UpdateUser, request: Request):
314
  return {"ok": True}
315
 
316
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
  @app.post("/auth/login")
318
  async def login(req: LoginReq):
319
  users = await _read_users()
@@ -327,7 +392,8 @@ async def login(req: LoginReq):
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:
@@ -357,7 +423,7 @@ async def me(request: Request):
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)
@@ -370,8 +436,8 @@ async def post_message(video_id: str, msg: NewMessage, request: Request):
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]
@@ -380,13 +446,11 @@ async def post_message(video_id: str, msg: NewMessage, request: Request):
380
  record = {
381
  "id": mid,
382
  "video_id": video_id,
383
- "room": video_id,
384
  "author": author,
385
  "handle": handle,
386
  "klass": klass,
387
  "profile_image": profile_image,
388
  "text": text,
389
- "images": msg.images or [],
390
  "created_at": created,
391
  "ip": ip,
392
  "ua": request.headers.get("user-agent", "")[:200],
@@ -398,132 +462,7 @@ async def post_message(video_id: str, msg: NewMessage, request: Request):
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
 
@@ -549,6 +488,11 @@ async def get_base_url(token: str) -> str:
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",
 
 
1
  from fastapi import FastAPI, Request
2
  from fastapi.middleware.cors import CORSMiddleware
3
  from pydantic import BaseModel, Field
 
11
  from datetime import datetime, timezone
12
  import re
13
  import fastapi
14
+ import random
15
 
16
  app = FastAPI()
17
  logging.basicConfig(level=logging.INFO)
 
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
 
 
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}")
 
221
  profile_image: Optional[str] = ""
222
  description: Optional[str] = ""
223
  pin: str = Field(..., min_length=3, max_length=32)
 
224
 
225
 
226
  class UpdateUser(BaseModel):
 
231
  description: Optional[str] = None
232
  pin: Optional[str] = None
233
  disabled: Optional[bool] = None
 
234
 
235
 
236
  class LoginReq(BaseModel):
 
257
  "description": (user.description or "").strip(),
258
  "pin_hash": _hash_pin(user.pin),
259
  "disabled": False,
260
+ "must_change_pin": False,
261
  "created_at": _now_iso(),
262
  }
263
  await _append_user(rec)
 
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
+ """
284
+ Return user details (names, email, class, disabled, must_change_pin, created_at).
285
+ NOTE: For security we DO NOT return the existing PIN or pin_hash.
286
+ """
287
+ _require_admin(request)
288
+ handle = _safe_handle(handle)
289
+ users = await _read_users()
290
+ u = next((x for x in users if x.get("handle") == handle), None)
291
+ if not u:
292
+ raise fastapi.HTTPException(status_code=404, detail="User not found")
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)
 
316
  u["disabled"] = bool(patch.disabled)
317
  if patch.pin is not None:
318
  u["pin_hash"] = _hash_pin(patch.pin)
319
+ u["must_change_pin"] = False
 
320
  changed = True
321
  break
322
  if not changed:
 
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):
337
+ """
338
+ Admin-only endpoint to reset a user's PIN.
339
+ Request body (optional): { "new_pin": "123456" }
340
+ If new_pin is not provided, the server will generate a numeric temporary PIN (6 digits)
341
+ and set must_change_pin = true on the user record.
342
+ The endpoint returns {"ok": True, "new_pin": "..."} so the admin can communicate it.
343
+ NOTE: We DO NOT return the user's old PIN for security reasons.
344
+ """
345
+ _require_admin(request)
346
+ handle = _safe_handle(handle)
347
+ users = await _read_users()
348
+ found = False
349
+ new_pin = None
350
+ for u in users:
351
+ if u.get("handle") == handle:
352
+ found = True
353
+ new_pin = (body or {}).get("new_pin")
354
+ if new_pin:
355
+ # basic validation
356
+ new_pin = str(new_pin).strip()
357
+ if len(new_pin) < 3 or len(new_pin) > 32:
358
+ raise fastapi.HTTPException(status_code=400, detail="PIN length invalid")
359
+ u["pin_hash"] = _hash_pin(new_pin)
360
+ u["must_change_pin"] = False
361
+ else:
362
+ # generate temporary numeric PIN and flag must_change_pin
363
+ new_pin = _gen_temp_pin(6)
364
+ u["pin_hash"] = _hash_pin(new_pin)
365
+ u["must_change_pin"] = True
366
+ break
367
+ if not found:
368
+ raise fastapi.HTTPException(status_code=404, detail="User not found")
369
+
370
+ # rewrite file with updated pin_hash
371
+ async with app.state.users_lock:
372
+ def _write_all():
373
+ with open(USERS_FILE, "w", encoding="utf-8") as f:
374
+ for rec in users:
375
+ f.write(json.dumps(rec, ensure_ascii=False) + "\n")
376
+ await asyncio.to_thread(_write_all)
377
+
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()
 
392
  token = _rand_token()
393
  app.state.sessions[token] = handle
394
  await _save_sessions()
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:
 
423
  async def post_message(video_id: str, msg: NewMessage, request: Request):
424
  """
425
  Append a message to a room's chat.
426
+ Body: { "text": "hello" }
427
  """
428
  path = _chat_file_for(video_id)
429
  lock = _lock_for(path)
 
436
  profile_image = user.get("profile_image", "")
437
 
438
  text = _valid_text(msg.text)
439
+ if not text:
440
+ return {"ok": False, "error": "Empty message"}
441
 
442
  created = _now_iso()
443
  mid = hashlib.sha1(f"{video_id}|{author}|{created}|{text}".encode("utf-8")).hexdigest()[:16]
 
446
  record = {
447
  "id": mid,
448
  "video_id": video_id,
 
449
  "author": author,
450
  "handle": handle,
451
  "klass": klass,
452
  "profile_image": profile_image,
453
  "text": text,
 
454
  "created_at": created,
455
  "ip": ip,
456
  "ua": request.headers.get("user-agent", "")[:200],
 
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
 
 
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",