SalexAI commited on
Commit
9f1d2f2
·
verified ·
1 Parent(s): 307e206

Update app.py

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