muhammadpriv001 commited on
Commit
df98da1
·
1 Parent(s): 41b754a

Fix async core calls, thread-local DB clients, WS case fix, schemas, mail

Browse files
Files changed (13) hide show
  1. README.md +0 -0
  2. db.py +24 -31
  3. engine.py +14 -2
  4. engine_user.py +141 -4
  5. mail.py +73 -0
  6. package-lock.json +6 -0
  7. requirements.txt +1 -0
  8. schemas/API_INVENTORY.md +46 -0
  9. schemas/__init__.py +23 -0
  10. schemas/dtos.py +190 -0
  11. server.py +571 -251
  12. smoke_test.py +64 -0
  13. stack_test.py +69 -0
README.md CHANGED
Binary files a/README.md and b/README.md differ
 
db.py CHANGED
@@ -1,49 +1,42 @@
1
  """
2
  Database helper — thin wrapper around Supabase client.
 
3
  """
4
  import os
5
  import threading
6
- from supabase import create_client, Client
7
- from dotenv import load_dotenv
8
  from pathlib import Path
9
 
 
 
 
 
10
  load_dotenv(Path(__file__).parent.parent / ".env")
11
-
12
- SUPABASE_URL = os.environ.get("SUPABASE_URL", "")
13
- SUPABASE_KEY = os.environ.get("SUPABASE_KEY", "")
14
- SUPABASE_SERVICE_KEY = os.environ.get("SUPABASE_SERVICE_KEY", "")
15
 
16
- _client = None
17
- _service_client = None
18
- _lock = threading.Lock()
 
 
 
 
 
 
19
 
20
  def get_client() -> Client:
21
- """Returns a shared Supabase client."""
22
- global _client
23
- with _lock:
24
- if _client is None:
25
- opts = ClientOptions(postgrest_client_timeout=30.0)
26
- _client = create_client(SUPABASE_URL, SUPABASE_KEY, options=opts)
27
- return _client
28
 
29
  def new_client() -> Client:
30
  return get_client()
31
 
32
- from supabase.client import ClientOptions
33
-
34
  def get_service_client() -> Client:
35
- """Returns a shared Supabase client with the service role key."""
36
- global _service_client
37
- with _lock:
38
- if _service_client is None:
39
- key = SUPABASE_SERVICE_KEY or SUPABASE_KEY
40
- # Use ClientOptions to increase timeout and potentially stability
41
- # postgrest_client_timeout defaults to 5.0, let's bump it
42
- opts = ClientOptions(postgrest_client_timeout=30.0)
43
- _service_client = create_client(SUPABASE_URL, key, options=opts)
44
- return _service_client
45
 
46
  def reset_service_client():
47
- global _service_client
48
- with _lock:
49
- _service_client = None
 
1
  """
2
  Database helper — thin wrapper around Supabase client.
3
+ Each thread gets its own client instance to avoid HTTP/2 connection sharing issues.
4
  """
5
  import os
6
  import threading
 
 
7
  from pathlib import Path
8
 
9
+ from dotenv import load_dotenv
10
+ from supabase import create_client, Client
11
+ from supabase.client import ClientOptions
12
+
13
  load_dotenv(Path(__file__).parent.parent / ".env")
 
 
 
 
14
 
15
+ SUPABASE_URL = os.environ.get("SUPABASE_URL", "").strip()
16
+ SUPABASE_KEY = (
17
+ os.environ.get("SUPABASE_KEY", "").strip()
18
+ or os.environ.get("SUPABASE_ANON_KEY", "").strip()
19
+ )
20
+ SUPABASE_SERVICE_KEY = os.environ.get("SUPABASE_SERVICE_KEY", "").strip()
21
+
22
+ _local = threading.local()
23
+ _opts = ClientOptions(postgrest_client_timeout=30.0)
24
 
25
  def get_client() -> Client:
26
+ """Returns a thread-local Supabase client (anon key)."""
27
+ if not getattr(_local, "client", None):
28
+ _local.client = create_client(SUPABASE_URL, SUPABASE_KEY, options=_opts)
29
+ return _local.client
 
 
 
30
 
31
  def new_client() -> Client:
32
  return get_client()
33
 
 
 
34
  def get_service_client() -> Client:
35
+ """Returns a thread-local Supabase client (service key)."""
36
+ if not getattr(_local, "service_client", None):
37
+ key = SUPABASE_SERVICE_KEY or SUPABASE_KEY
38
+ _local.service_client = create_client(SUPABASE_URL, key, options=_opts)
39
+ return _local.service_client
 
 
 
 
 
40
 
41
  def reset_service_client():
42
+ _local.service_client = None
 
 
engine.py CHANGED
@@ -14,8 +14,8 @@ class Engine:
14
  # ========================
15
  # AUTH
16
  # ========================
17
- def register_user(self, fn, ln, gender, uname, email, pw):
18
- engine_user.register_user(fn, ln, gender, uname, email, pw)
19
 
20
  def login(self, username, password):
21
  return engine_user.login(username, password)
@@ -68,6 +68,18 @@ class Engine:
68
  def update_password(self, username, old_pass, new_pass):
69
  return engine_user.update_password(username, old_pass, new_pass)
70
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  def delete_user(self, username):
72
  return engine_user.delete_user(username)
73
 
 
14
  # ========================
15
  # AUTH
16
  # ========================
17
+ def register_user(self, fn, ln, gender, uname, email, pw, theme=5):
18
+ engine_user.register_user(fn, ln, gender, uname, email, pw, theme)
19
 
20
  def login(self, username, password):
21
  return engine_user.login(username, password)
 
68
  def update_password(self, username, old_pass, new_pass):
69
  return engine_user.update_password(username, old_pass, new_pass)
70
 
71
+ def create_password_reset_token(self, username):
72
+ return engine_user.create_password_reset_token(username)
73
+
74
+ def validate_password_reset_token(self, token_plain):
75
+ return engine_user.validate_password_reset_token(token_plain)
76
+
77
+ def get_user_public_for_reset(self, username):
78
+ return engine_user.get_user_public_for_reset(username)
79
+
80
+ def confirm_password_reset(self, token_plain, new_password):
81
+ return engine_user.confirm_password_reset(token_plain, new_password)
82
+
83
  def delete_user(self, username):
84
  return engine_user.delete_user(username)
85
 
engine_user.py CHANGED
@@ -1,9 +1,30 @@
1
  """User module"""
2
- import hashlib, secrets, time
 
 
 
 
3
  from db import get_service_client
4
 
5
  _dev_cache: set | None = None
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  def is_developer(username: str) -> bool:
8
  global _dev_cache
9
  if _dev_cache is None:
@@ -19,7 +40,15 @@ def _generate_salt(length=32):
19
  def _hash_password(password, salt):
20
  return hashlib.sha256((salt + password).encode()).hexdigest()
21
 
22
- def register_user(fn, ln, gender, uname, email, pw):
 
 
 
 
 
 
 
 
23
  sb = get_service_client()
24
  existing = sb.table("users").select("username").eq("username", uname).execute()
25
  if existing.data:
@@ -30,10 +59,11 @@ def register_user(fn, ln, gender, uname, email, pw):
30
  else: avatar = "/static/assets/icons/other.png"
31
  salt = _generate_salt()
32
  h = _hash_password(pw, salt)
 
33
  sb.table("users").insert({
34
  "username": uname, "firstName": fn, "lastName": ln, "gender": gender,
35
  "email": email, "password_hash": h, "password_salt": salt,
36
- "bio": "", "followers": 0, "follows": 0, "avatar": avatar, "heroBanner": "", "theme": 4
37
  }).execute()
38
  return True
39
 
@@ -81,7 +111,7 @@ def get_all_users_detailed():
81
  def get_theme(username):
82
  sb = get_service_client()
83
  rows = sb.table("users").select("theme").eq("username", username).execute()
84
- if not rows.data: return "4"
85
  return str(rows.data[0]["theme"])
86
 
87
  def update_theme(username, theme):
@@ -139,6 +169,113 @@ def update_password(username, old_pass, new_pass):
139
  sb.table("users").update({"password_hash": new_hash, "password_salt": new_salt}).eq("username", username).execute()
140
  return True
141
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  def delete_user(username):
143
  sb = get_service_client()
144
  # Cascading deletes handle everything else!
 
1
  """User module"""
2
+ import hashlib
3
+ import secrets
4
+ import time
5
+ from datetime import datetime, timedelta, timezone
6
+
7
  from db import get_service_client
8
 
9
  _dev_cache: set | None = None
10
 
11
+
12
+ class PasswordResetUnavailableError(RuntimeError):
13
+ """Supabase is missing the password_reset_tokens table (migration not applied)."""
14
+
15
+
16
+ RESET_TABLE_INSTRUCTIONS = (
17
+ "Password reset is not set up: the database table is missing. "
18
+ "In Supabase → SQL → run the script in docs/Design/password_reset_tokens.sql, then try again."
19
+ )
20
+
21
+
22
+ def _missing_password_reset_table(exc: BaseException) -> bool:
23
+ s = str(exc)
24
+ if "PGRST205" in s:
25
+ return True
26
+ return "password_reset_tokens" in s and "Could not find" in s
27
+
28
  def is_developer(username: str) -> bool:
29
  global _dev_cache
30
  if _dev_cache is None:
 
40
  def _hash_password(password, salt):
41
  return hashlib.sha256((salt + password).encode()).hexdigest()
42
 
43
+ def _clamp_theme(theme_val):
44
+ try:
45
+ t = int(theme_val)
46
+ except (TypeError, ValueError):
47
+ return 5
48
+ return max(0, min(6, t))
49
+
50
+
51
+ def register_user(fn, ln, gender, uname, email, pw, theme=5):
52
  sb = get_service_client()
53
  existing = sb.table("users").select("username").eq("username", uname).execute()
54
  if existing.data:
 
59
  else: avatar = "/static/assets/icons/other.png"
60
  salt = _generate_salt()
61
  h = _hash_password(pw, salt)
62
+ theme_i = _clamp_theme(theme)
63
  sb.table("users").insert({
64
  "username": uname, "firstName": fn, "lastName": ln, "gender": gender,
65
  "email": email, "password_hash": h, "password_salt": salt,
66
+ "bio": "", "followers": 0, "follows": 0, "avatar": avatar, "heroBanner": "", "theme": theme_i
67
  }).execute()
68
  return True
69
 
 
111
  def get_theme(username):
112
  sb = get_service_client()
113
  rows = sb.table("users").select("theme").eq("username", username).execute()
114
+ if not rows.data: return "5"
115
  return str(rows.data[0]["theme"])
116
 
117
  def update_theme(username, theme):
 
169
  sb.table("users").update({"password_hash": new_hash, "password_salt": new_salt}).eq("username", username).execute()
170
  return True
171
 
172
+
173
+ def _parse_expires_at(val):
174
+ if not val:
175
+ return None
176
+ s = str(val).replace("Z", "+00:00")
177
+ try:
178
+ return datetime.fromisoformat(s)
179
+ except Exception:
180
+ return None
181
+
182
+
183
+ def create_password_reset_token(username: str):
184
+ """Create a one-time reset token; returns plaintext token for email, or None if user missing."""
185
+ if not user_exists(username):
186
+ return None
187
+ sb = get_service_client()
188
+ token_plain = secrets.token_urlsafe(32)
189
+ token_hash = hashlib.sha256(token_plain.encode()).hexdigest()
190
+ expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
191
+ try:
192
+ sb.table("password_reset_tokens").insert(
193
+ {
194
+ "username": username,
195
+ "token_hash": token_hash,
196
+ "expires_at": expires_at.isoformat(),
197
+ "used_at": None,
198
+ }
199
+ ).execute()
200
+ except Exception as e:
201
+ if _missing_password_reset_table(e):
202
+ raise PasswordResetUnavailableError(RESET_TABLE_INSTRUCTIONS) from e
203
+ raise
204
+ return token_plain
205
+
206
+
207
+ def validate_password_reset_token(token_plain: str):
208
+ """
209
+ Returns dict with username if token is valid and unused, else None.
210
+ """
211
+ if not token_plain or not isinstance(token_plain, str):
212
+ return None
213
+ sb = get_service_client()
214
+ token_hash = hashlib.sha256(token_plain.encode()).hexdigest()
215
+ try:
216
+ rows = (
217
+ sb.table("password_reset_tokens")
218
+ .select("username, expires_at, used_at")
219
+ .eq("token_hash", token_hash)
220
+ .execute()
221
+ )
222
+ except Exception as e:
223
+ if _missing_password_reset_table(e):
224
+ raise PasswordResetUnavailableError(RESET_TABLE_INSTRUCTIONS) from e
225
+ raise
226
+ if not rows.data:
227
+ return None
228
+ r = rows.data[0]
229
+ if r.get("used_at"):
230
+ return None
231
+ exp = _parse_expires_at(r.get("expires_at"))
232
+ if exp is None:
233
+ return None
234
+ if exp.tzinfo is None:
235
+ exp = exp.replace(tzinfo=timezone.utc)
236
+ if datetime.now(timezone.utc) > exp:
237
+ return None
238
+ return {"username": r["username"]}
239
+
240
+
241
+ def get_user_public_for_reset(username: str):
242
+ """Safe fields for reset-password page."""
243
+ u = get_user_by_username(username)
244
+ if not u:
245
+ return None
246
+ return {
247
+ "username": u.get("username", ""),
248
+ "firstName": u.get("firstName", ""),
249
+ "lastName": u.get("lastName", ""),
250
+ "email": u.get("email", ""),
251
+ "avatar": get_avatar(username),
252
+ }
253
+
254
+
255
+ def confirm_password_reset(token_plain: str, new_password: str):
256
+ if not new_password or len(new_password) < 1:
257
+ return False
258
+ info = validate_password_reset_token(token_plain)
259
+ if not info:
260
+ return False
261
+ username = info["username"]
262
+ sb = get_service_client()
263
+ new_salt = _generate_salt()
264
+ new_hash = _hash_password(new_password, new_salt)
265
+ sb.table("users").update({"password_hash": new_hash, "password_salt": new_salt}).eq(
266
+ "username", username
267
+ ).execute()
268
+ token_hash = hashlib.sha256(token_plain.encode()).hexdigest()
269
+ now = datetime.now(timezone.utc).isoformat()
270
+ try:
271
+ sb.table("password_reset_tokens").update({"used_at": now}).eq("token_hash", token_hash).execute()
272
+ except Exception as e:
273
+ if _missing_password_reset_table(e):
274
+ raise PasswordResetUnavailableError(RESET_TABLE_INSTRUCTIONS) from e
275
+ raise
276
+ return True
277
+
278
+
279
  def delete_user(username):
280
  sb = get_service_client()
281
  # Cascading deletes handle everything else!
mail.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Optional SMTP email for password reset and notifications."""
2
+
3
+ import os
4
+ import smtplib
5
+ from email.mime.multipart import MIMEMultipart
6
+ from email.mime.text import MIMEText
7
+
8
+
9
+ def _smtp_configured():
10
+ return bool(os.environ.get("SMTP_HOST", "").strip())
11
+
12
+
13
+ def send_password_reset_email(to_email: str, username: str, reset_url: str) -> bool:
14
+ """
15
+ Send password reset link. Returns True if sent via SMTP.
16
+ When SMTP is not configured, returns False (caller should log link instead).
17
+ """
18
+ host = os.environ.get("SMTP_HOST", "").strip()
19
+ if not host or not to_email:
20
+ return False
21
+
22
+ port = int(os.environ.get("SMTP_PORT", "587"))
23
+ user = os.environ.get("SMTP_USER", "").strip()
24
+ password_raw = os.environ.get("SMTP_PASSWORD", "").strip()
25
+ # Gmail shows app passwords as "abcd efgh ijkl mnop"; SMTP login uses the 16 chars without spaces.
26
+ password = "".join(password_raw.split()) if password_raw else ""
27
+ mail_from = os.environ.get("MAIL_FROM", user or "noreply@localhost").strip()
28
+
29
+ subject = "YapStation — reset your password"
30
+ body = (
31
+ f"Hi {username},\n\n"
32
+ f"We received a request to reset your YapStation password.\n\n"
33
+ f"Open this link (valid for 1 hour):\n{reset_url}\n\n"
34
+ f"If you did not request this, ignore this email.\n\n"
35
+ f"— YapStation"
36
+ )
37
+
38
+ msg = MIMEMultipart()
39
+ msg["Subject"] = subject
40
+ msg["From"] = mail_from
41
+ msg["To"] = to_email
42
+ msg.attach(MIMEText(body, "plain", "utf-8"))
43
+
44
+ try:
45
+ if port == 465:
46
+ with smtplib.SMTP_SSL(host, port, timeout=30) as smtp:
47
+ if user and password:
48
+ smtp.login(user, password)
49
+ smtp.sendmail(mail_from, [to_email], msg.as_string())
50
+ else:
51
+ with smtplib.SMTP(host, port, timeout=30) as smtp:
52
+ smtp.ehlo()
53
+ smtp.starttls()
54
+ smtp.ehlo()
55
+ if user and password:
56
+ smtp.login(user, password)
57
+ smtp.sendmail(mail_from, [to_email], msg.as_string())
58
+ return True
59
+ except smtplib.SMTPAuthenticationError as e:
60
+ print(
61
+ f"[mail] SMTP authentication failed for {user!r}: {e}\n"
62
+ "[mail] Gmail: create an App Password at "
63
+ "https://myaccount.google.com/apppasswords (enable 2-Step Verification first). "
64
+ "Put the 16-character password in SMTP_PASSWORD — not your normal Gmail password."
65
+ )
66
+ return False
67
+ except Exception as e:
68
+ print(f"[mail] SMTP send failed ({type(e).__name__}): {e}")
69
+ return False
70
+
71
+
72
+ def smtp_configured() -> bool:
73
+ return _smtp_configured()
package-lock.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "name": "backend",
3
+ "lockfileVersion": 3,
4
+ "requires": true,
5
+ "packages": {}
6
+ }
requirements.txt CHANGED
@@ -4,3 +4,4 @@ python-dotenv
4
  pyjwt
5
  httpx
6
  supabase
 
 
4
  pyjwt
5
  httpx
6
  supabase
7
+ pydantic
schemas/API_INVENTORY.md ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # API response inventory (DTO sources)
2
+
3
+ Data originates from Supabase via `engine_*` modules and is exposed as JSON through FastAPI (`server.py`). The browser calls `/api/*` (Next.js rewrite to FastAPI).
4
+
5
+ ## POST list item (feed, userPosts, station posts)
6
+
7
+ Typical keys after engine normalization:
8
+
9
+ | Field | Type (JSON) | Notes |
10
+ |-------|----------------|-------|
11
+ | id | string | |
12
+ | username | string | Author or station pseudo-user |
13
+ | content | string | |
14
+ | media_path | string | URL or path |
15
+ | timestamp | string | Unix or parsed string |
16
+ | comments_count | string | Coerced in engine |
17
+ | likes_count | string | |
18
+ | station_name | string | Empty for personal posts |
19
+ | avatar | string | User or station profile pic |
20
+ | is_liked | boolean | When viewer set |
21
+
22
+ ## GET `/api/post/{post_id}`
23
+
24
+ Success: `{ "status": "success", "post": { ... } }` — post includes `select("*")` columns as strings plus `is_liked`, `avatar`.
25
+
26
+ Restricted: HTTP 403 `{ "status": "restricted", "username": ... }`.
27
+
28
+ ## POST `/api/post/feed` and GET `/api/post/feed`
29
+
30
+ Success: `{ "status": "success", "feed": Post[], "posts": Post[] }` (same array twice for client compatibility).
31
+
32
+ ## POST `/api/post/userPosts`
33
+
34
+ Success: `{ "status": "success", "posts": Post[] }` — optional `{ "blocked": true }` when viewer blocked.
35
+
36
+ ## POST `/api/station/get`
37
+
38
+ Success: `{ "status": "success", "station": { station_name, admin, user_count, bio, hero_banner, profile_pic, users, members_info } | null }`.
39
+
40
+ ## GET `/api/user/{username}/full`
41
+
42
+ Success: `{ "user": {...}, "avatar": str, "heroBanner": str }` — errors use `{ "error": str }`.
43
+
44
+ ## Optional pagination (feed)
45
+
46
+ `FeedRequest` accepts optional `limit` (max 500) and `offset` for slicing the in-memory feed list after `get_feed` (does not change DB query yet).
schemas/__init__.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """API DTOs and response mappers (Pydantic)."""
2
+
3
+ from .dtos import (
4
+ FeedRequest,
5
+ UserPostsRequest,
6
+ map_post_dict,
7
+ map_posts_list,
8
+ build_feed_response,
9
+ build_user_posts_response,
10
+ build_post_detail_response,
11
+ build_station_get_response,
12
+ )
13
+
14
+ __all__ = [
15
+ "FeedRequest",
16
+ "UserPostsRequest",
17
+ "map_post_dict",
18
+ "map_posts_list",
19
+ "build_feed_response",
20
+ "build_user_posts_response",
21
+ "build_post_detail_response",
22
+ "build_station_get_response",
23
+ ]
schemas/dtos.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pydantic DTOs for public API responses.
3
+ Normalizes Supabase/engine dicts to a stable JSON contract for the frontend.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any, Optional
9
+
10
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
11
+
12
+
13
+ class PostPublicDTO(BaseModel):
14
+ """Canonical post shape for lists and detail (unknown keys dropped)."""
15
+
16
+ model_config = ConfigDict(extra="ignore")
17
+
18
+ id: str
19
+ username: str = ""
20
+ content: str = ""
21
+ media_path: str = ""
22
+ timestamp: str = ""
23
+ comments_count: str = "0"
24
+ likes_count: str = "0"
25
+ station_name: str = ""
26
+ avatar: str = ""
27
+ is_liked: bool = False
28
+
29
+ @field_validator(
30
+ "id",
31
+ "username",
32
+ "content",
33
+ "media_path",
34
+ "timestamp",
35
+ "comments_count",
36
+ "likes_count",
37
+ "station_name",
38
+ "avatar",
39
+ mode="before",
40
+ )
41
+ @classmethod
42
+ def coerce_str_fields(cls, v: Any) -> str:
43
+ if v is None:
44
+ return ""
45
+ return str(v)
46
+
47
+ @field_validator("is_liked", mode="before")
48
+ @classmethod
49
+ def coerce_bool(cls, v: Any) -> bool:
50
+ if isinstance(v, bool):
51
+ return v
52
+ if v is None:
53
+ return False
54
+ s = str(v).lower()
55
+ return s in ("1", "true", "yes")
56
+
57
+
58
+ class StationMemberDTO(BaseModel):
59
+ model_config = ConfigDict(extra="ignore")
60
+
61
+ username: str
62
+ avatar: Optional[str] = None
63
+
64
+ @field_validator("username", mode="before")
65
+ @classmethod
66
+ def username_str(cls, v: Any) -> str:
67
+ return "" if v is None else str(v)
68
+
69
+
70
+ class StationPublicDTO(BaseModel):
71
+ model_config = ConfigDict(extra="ignore")
72
+
73
+ station_name: str = ""
74
+ admin: str = ""
75
+ user_count: str = ""
76
+ bio: str = ""
77
+ hero_banner: str = ""
78
+ profile_pic: str = ""
79
+ users: str = ""
80
+ members_info: list[StationMemberDTO] = Field(default_factory=list)
81
+
82
+ @field_validator(
83
+ "station_name",
84
+ "admin",
85
+ "user_count",
86
+ "bio",
87
+ "hero_banner",
88
+ "profile_pic",
89
+ "users",
90
+ mode="before",
91
+ )
92
+ @classmethod
93
+ def empty_str(cls, v: Any) -> str:
94
+ if v is None:
95
+ return ""
96
+ return str(v)
97
+
98
+
99
+ class FeedRequest(BaseModel):
100
+ username: Optional[str] = None
101
+ viewer: Optional[str] = None
102
+ limit: Optional[int] = None
103
+ offset: Optional[int] = None
104
+
105
+ @field_validator("limit", mode="before")
106
+ @classmethod
107
+ def cap_limit(cls, v: Any) -> Optional[int]:
108
+ if v is None:
109
+ return None
110
+ try:
111
+ n = int(v)
112
+ except (TypeError, ValueError):
113
+ return None
114
+ return max(1, min(n, 500))
115
+
116
+ @field_validator("offset", mode="before")
117
+ @classmethod
118
+ def nonneg_offset(cls, v: Any) -> Optional[int]:
119
+ if v is None:
120
+ return None
121
+ try:
122
+ n = int(v)
123
+ except (TypeError, ValueError):
124
+ return None
125
+ return max(0, n)
126
+
127
+
128
+ class UserPostsRequest(BaseModel):
129
+ username: str
130
+ viewer: Optional[str] = None
131
+
132
+
133
+ def map_post_dict(raw: dict[str, Any]) -> PostPublicDTO:
134
+ """Map a single engine post dict to DTO (whitelisted fields)."""
135
+ return PostPublicDTO.model_validate(raw)
136
+
137
+
138
+ def map_posts_list(items: list[Any]) -> list[dict[str, Any]]:
139
+ out: list[dict[str, Any]] = []
140
+ for item in items:
141
+ if not isinstance(item, dict):
142
+ continue
143
+ try:
144
+ out.append(map_post_dict(item).model_dump(mode="json"))
145
+ except Exception:
146
+ continue
147
+ return out
148
+
149
+
150
+ def _slice_feed(posts: list[dict[str, Any]], limit: Optional[int], offset: Optional[int]) -> list[dict[str, Any]]:
151
+ if limit is None and offset is None:
152
+ return posts
153
+ start = offset or 0
154
+ if limit is None:
155
+ return posts[start:]
156
+ return posts[start : start + limit]
157
+
158
+
159
+ def build_feed_response(
160
+ raw_posts: list[Any],
161
+ *,
162
+ limit: Optional[int] = None,
163
+ offset: Optional[int] = None,
164
+ ) -> dict[str, Any]:
165
+ mapped = map_posts_list(raw_posts)
166
+ mapped = _slice_feed(mapped, limit, offset)
167
+ return {"status": "success", "feed": mapped, "posts": mapped}
168
+
169
+
170
+ def build_user_posts_response(
171
+ raw_posts: list[Any],
172
+ *,
173
+ blocked: bool = False,
174
+ ) -> dict[str, Any]:
175
+ mapped = map_posts_list(raw_posts)
176
+ body: dict[str, Any] = {"status": "success", "posts": mapped}
177
+ if blocked:
178
+ body["blocked"] = True
179
+ return body
180
+
181
+
182
+ def build_post_detail_response(raw_post: dict[str, Any]) -> dict[str, Any]:
183
+ return {"status": "success", "post": map_post_dict(raw_post).model_dump(mode="json")}
184
+
185
+
186
+ def build_station_get_response(raw_station: Optional[dict[str, Any]]) -> dict[str, Any]:
187
+ if not raw_station:
188
+ return {"status": "success", "station": None}
189
+ station = StationPublicDTO.model_validate(raw_station).model_dump(mode="json")
190
+ return {"status": "success", "station": station}
server.py CHANGED
@@ -5,6 +5,7 @@ FastAPI server for real-time chat and social platform
5
 
6
  import asyncio
7
  import os
 
8
  import sys
9
  import time
10
  from pathlib import Path
@@ -20,7 +21,9 @@ load_dotenv(env_path)
20
 
21
  import engine
22
  import engineHelper as coreHelper
 
23
  import jwt
 
24
  from db import get_client, get_service_client
25
 
26
  core = engine.Engine()
@@ -30,6 +33,11 @@ app = FastAPI()
30
 
31
  @app.exception_handler(Exception)
32
  async def global_exception_handler(request: Request, exc: Exception):
 
 
 
 
 
33
  return JSONResponse({"status": "fail", "message": str(exc)}, status_code=500)
34
 
35
  # Add CORS middleware
@@ -37,23 +45,38 @@ FRONTEND_URL = os.environ.get("FRONTEND_URL", "https://yap-station.vercel.app")
37
 
38
  app.add_middleware(
39
  CORSMiddleware,
40
- allow_origins=[FRONTEND_URL, "http://localhost:3000"],
 
 
 
 
41
  allow_credentials=True,
42
  allow_methods=["*"],
43
  allow_headers=["*"],
44
  )
45
 
46
  JWT_SECRET = os.environ.get("JWT_SECRET", "default_secret_key_if_not_found")
47
- from pydantic import BaseModel
 
 
 
 
 
 
 
 
 
48
  from typing import Optional
49
 
50
- class FeedRequest(BaseModel):
51
- username: Optional[str] = None
52
- viewer: Optional[str] = None
 
 
 
 
 
53
 
54
- class UserPostsRequest(BaseModel):
55
- username: str
56
- viewer: Optional[str] = None
57
  JWT_ALGORITHM = "HS256"
58
 
59
  BASE = os.path.dirname(__file__)
@@ -66,6 +89,29 @@ else:
66
  os.makedirs("static_dummy", exist_ok=True)
67
  app.mount("/static", StaticFiles(directory="static_dummy"), name="static")
68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  def upload_media_to_supabase(data_url: str) -> str:
70
  """Upload a base64 data URL to Supabase Storage, return the public URL."""
71
  import base64, uuid, mimetypes
@@ -92,6 +138,35 @@ last_seen_ping = coreHelper.last_seen_ping
92
  broadcast_online = coreHelper.broadcast_online
93
 
94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  def invalidate_conv_cache(username: str):
96
  if username in _conv_cache:
97
  del _conv_cache[username]
@@ -115,7 +190,7 @@ async def get_user_accounts(input: str):
115
  if not input or "@" not in input:
116
  return {"email": "", "usernames": [], "count": 0}
117
 
118
- result = core.get_username(input)
119
  count = int(result.get("count", "0"))
120
 
121
  if count > 0:
@@ -134,7 +209,7 @@ async def check_email(req: Request):
134
  if not username or "@" not in username:
135
  return {"email": "", "usernames": [], "count": 0}
136
 
137
- result = core.get_username(username)
138
  count = int(result.get("count", "0"))
139
 
140
  if count > 0:
@@ -182,7 +257,7 @@ async def admin_delete_user(data: dict):
182
  admin_user = data.get("admin_username")
183
  target_user = data.get("target_username")
184
 
185
- if not core.is_developer(admin_user):
186
  return JSONResponse({"status": "error", "message": "Unauthorized"}, status_code=403)
187
 
188
  if not target_user:
@@ -193,7 +268,7 @@ async def admin_delete_user(data: dict):
193
  await coreHelper.evict_user_ws(target_user)
194
 
195
  # 2. Hard delete from DB
196
- success = core.delete_user(target_user)
197
 
198
  if success:
199
  return {"status": "success", "message": f"User {target_user} has been purged."}
@@ -211,7 +286,7 @@ def get_post_route(post_id: int, viewer: Optional[str] = None):
211
  return JSONResponse({"status": "fail", "message": "Post not found"}, status_code=404)
212
  if isinstance(post, dict) and post.get("restricted"):
213
  return JSONResponse({"status": "restricted", "username": post["username"]}, status_code=403)
214
- return {"status": "success", "post": post}
215
  except Exception as e:
216
  return JSONResponse({"status": "fail", "message": str(e)}, status_code=500)
217
 
@@ -233,14 +308,14 @@ async def admin_delete_station(data: dict):
233
  admin_user = data.get("admin_username")
234
  target_station = data.get("target_station")
235
 
236
- if not core.is_developer(admin_user):
237
  return JSONResponse({"status": "error", "message": "Unauthorized"}, status_code=403)
238
 
239
  if not target_station:
240
  return {"status": "fail", "message": "Target station name required"}
241
 
242
  try:
243
- success = core.force_delete_station(target_station)
244
  if success:
245
  # Notify all users that the station is gone
246
  payload = {"type": "station_deleted", "stationName": target_station}
@@ -268,15 +343,15 @@ async def login(req: Request, response: Response):
268
 
269
  # If username doesn't contain @, it's already a username
270
  if username and "@" not in username:
271
- if core.login(username, password):
272
  token = create_jwt_token(username)
273
  response.set_cookie(
274
  key="yap_session",
275
  value=token,
276
  httponly=True,
277
  max_age=7 * 24 * 60 * 60, # 7 days
278
- samesite="none",
279
- secure=True, # Set to True in prod with HTTPS
280
  path="/"
281
  )
282
  return {"status": "success", "username": username, "token": token}
@@ -284,7 +359,7 @@ async def login(req: Request, response: Response):
284
 
285
  # If username is an email, get the accounts
286
  if username and "@" in username:
287
- result = core.get_username(username)
288
  if int(result.get("count", "0")) == 0:
289
  return {"status": "fail"}
290
 
@@ -294,15 +369,15 @@ async def login(req: Request, response: Response):
294
  # If password provided, try first account
295
  if password:
296
  username = usernames[0] if usernames else None
297
- if username and core.login(username, password):
298
  token = create_jwt_token(username)
299
  response.set_cookie(
300
  key="yap_session",
301
  value=token,
302
  httponly=True,
303
  max_age=7 * 24 * 60 * 60, # 7 days
304
- samesite="none",
305
- secure=True, # Set to True in prod with HTTPS
306
  path="/"
307
  )
308
  return {"status": "success", "username": username, "token": token}
@@ -327,19 +402,104 @@ def terms_page():
327
  @app.post("/api/register")
328
  async def register(data: dict):
329
  try:
330
- core.register_user(
 
 
331
  data["firstName"],
332
  data["lastName"],
333
  data["gender"],
334
  data["username"],
335
  data["email"],
336
  data["password"],
 
337
  )
338
  return {"status": "success"}
339
  except Exception as e:
340
  return {"status": "error", "message": str(e)}
341
 
342
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
  def create_page_response(filename: str):
344
  response = FileResponse(os.path.join(FRONTEND, filename))
345
  response.headers["Cache-Control"] = "no-store"
@@ -360,11 +520,21 @@ def get_current_user(response: Response, yap_session: str = Cookie(None)):
360
  return {"status": "success", "username": username}
361
  else:
362
  # User was deleted! Clear the cookie.
363
- response.delete_cookie("yap_session", path="/", samesite="none", secure=True)
 
 
 
 
 
364
  return {"status": "fail", "message": "User no longer exists"}
365
  return {"status": "fail", "message": "Invalid token"}
366
  except jwt.ExpiredSignatureError:
367
- response.delete_cookie("yap_session", path="/", samesite="none", secure=True)
 
 
 
 
 
368
  return {"status": "fail", "message": "Session expired"}
369
  except Exception:
370
  return {"status": "fail", "message": "Auth check failed"}
@@ -377,8 +547,8 @@ async def logout(response: Response):
377
  response.delete_cookie(
378
  key="yap_session",
379
  httponly=True,
380
- samesite="none",
381
- secure=True
382
  )
383
  return {"status": "success"}
384
 
@@ -532,7 +702,8 @@ async def update_user(data: dict):
532
  current_username = data.get("currentUsername", "")
533
  new_username = data.get("newUsername", "")
534
 
535
- success = core.update_user_basic(
 
536
  current_username,
537
  new_username,
538
  data.get("email", ""),
@@ -565,7 +736,7 @@ async def delete_user(data: dict):
565
  username = data.get("username")
566
  if not username:
567
  return {"status": "fail", "message": "Username required"}
568
- success = core.delete_user(username)
569
  if success:
570
  return {"status": "success"}
571
  return {"status": "fail", "message": "Deletion failed"}
@@ -573,9 +744,7 @@ async def delete_user(data: dict):
573
 
574
  @app.post("/api/user/updatePassword")
575
  async def update_password(data: dict):
576
- success = core.update_password(
577
- data["username"], data["oldPassword"], data["newPassword"]
578
- )
579
  return {"status": "success" if success else "fail"}
580
 
581
 
@@ -598,7 +767,7 @@ def get_user_full(username: str, viewer: str = None):
598
  avatar = r.get("avatar") or ""
599
  hero = r.get("heroBanner") or ""
600
  user = {k: str(v) for k, v in r.items() if k not in ("avatar", "heroBanner")}
601
- return {"user": user, "avatar": avatar, "heroBanner": hero if hero != "null" else ""}
602
  except Exception as e:
603
  return {"error": str(e)}
604
 
@@ -609,9 +778,7 @@ def get_user_avatar(username: str, viewer: str = None):
609
  if core.is_blocked_either_way(viewer, username):
610
  return {"error": "User blocked or unavailable"}
611
  avatar = core.get_avatar(username)
612
- if avatar:
613
- if not avatar.startswith("data:image/") and not avatar.startswith("/static"):
614
- avatar = "/static/" + avatar.replace("../../frontend/", "")
615
  return {"username": username, "avatar": avatar}
616
  except Exception as e:
617
  return {"username": username, "avatar": "", "error": str(e)}
@@ -669,7 +836,7 @@ def get_user_theme(username: str):
669
  async def update_user_theme(data: dict):
670
  username = data["username"]
671
  theme = int(data["theme"])
672
- core.update_theme(username, theme)
673
  payload = {"type": "theme_update", "username": username, "theme": theme}
674
  if username in connections:
675
  for ws in list(connections[username]):
@@ -680,16 +847,25 @@ async def update_user_theme(data: dict):
680
  return {"status": "ok"}
681
 
682
 
 
 
 
 
 
 
683
  @app.get("/api/users")
684
  def get_all_users():
685
  users = core.get_all_users()
686
  result = []
687
  for u in users:
688
- avatar = core.get_avatar(u)
 
 
 
689
  if avatar:
690
  if not avatar.startswith("/static"):
691
  avatar = "/static/" + avatar.replace("../../frontend/", "")
692
- result.append({"username": u, "avatar": avatar})
693
  return {"users": result}
694
 
695
  @app.post("/api/users")
@@ -701,14 +877,17 @@ def get_all_users_filtered_post(current_user: str = ""):
701
  users = core.get_all_users()
702
  result = []
703
  for u in users:
704
- if current_user and u != current_user:
705
- if core.is_blocked_either_way(current_user, u):
 
 
 
706
  continue
707
- avatar = core.get_avatar(u)
708
  if avatar:
709
  if not avatar.startswith("/static"):
710
  avatar = "/static/" + avatar.replace("../../frontend/", "")
711
- result.append({"username": u, "avatar": avatar})
712
  return {"users": result}
713
 
714
 
@@ -721,7 +900,7 @@ async def follow_user(data: dict):
721
  if follower == following:
722
  return {"status": "fail", "message": "Cannot follow yourself"}
723
 
724
- result = core.follow_user(follower, following)
725
  if isinstance(result, dict) and result.get("status") == "success":
726
  follow_status = result.get("follow_status")
727
 
@@ -757,7 +936,7 @@ async def unfollow_user(data: dict):
757
  following = data.get("following")
758
  if not follower or not following:
759
  return {"status": "fail", "message": "Missing parameters"}
760
- success = core.unfollow_user(follower, following)
761
  if success:
762
  await coreHelper.send_follow_notification(
763
  following, follower, "unfollow", connections
@@ -776,14 +955,14 @@ async def set_visibility_route(data: dict):
776
  visibility = data.get("visibility")
777
  if username is None or visibility is None:
778
  return {"status": "fail"}
779
- core.set_visibility(username, visibility)
780
  return {"status": "success"}
781
 
782
  @app.post("/api/user/pendingFollows")
783
  async def get_pending_follows_route(data: dict):
784
  username = data.get("username")
785
  if not username: return {"status": "fail"}
786
- pending = core.get_pending_follows(username)
787
  # Process avatars
788
  for p in pending:
789
  avatar = p.get("avatar")
@@ -797,7 +976,7 @@ async def approve_follow_route(data: dict):
797
  follower = data.get("follower")
798
  following = data.get("following")
799
  if not follower or not following: return {"status": "fail"}
800
- success = core.approve_follow(follower, following)
801
  if success:
802
  # Notify the follower that they were accepted
803
  payload = {
@@ -824,7 +1003,7 @@ async def reject_follow_route(data: dict):
824
  follower = data.get("follower")
825
  following = data.get("following")
826
  if not follower or not following: return {"status": "fail"}
827
- success = core.reject_follow(follower, following)
828
  if success:
829
  # Notify the follower that they were rejected
830
  payload = {
@@ -844,7 +1023,7 @@ async def reject_follow_route(data: dict):
844
  async def get_follow_status_route(data: dict):
845
  follower = data.get("follower")
846
  following = data.get("following")
847
- status = core.get_follow_status(follower, following)
848
  return {"status": "success", "follow_status": status}
849
 
850
 
@@ -854,7 +1033,7 @@ async def remove_follower(data: dict):
854
  follower = data.get("follower")
855
  if not username or not follower:
856
  return {"status": "fail", "message": "Missing parameters"}
857
- success = core.unfollow_user(follower, username)
858
  if success:
859
  # Send notification only to the user who was removed (follower)
860
  await coreHelper.send_follow_notification(
@@ -936,7 +1115,7 @@ async def is_following(data: dict):
936
  following = data.get("following")
937
  if not follower or not following:
938
  return {"status": "fail"}
939
- followers = core.get_following(follower)
940
  return {"status": "success", "isFollowing": following in followers}
941
 
942
  @app.post("/api/user/block")
@@ -952,7 +1131,7 @@ async def block_user(data: dict):
952
  return JSONResponse({"status": "fail", "message": "Cannot block yourself"}, status_code=400)
953
 
954
 
955
- success = core.block_user(blocker, blocked)
956
 
957
  if success:
958
  # Broadcast block update
@@ -985,7 +1164,7 @@ async def unblock_user(data: dict):
985
  return JSONResponse({"status": "fail", "message": "Missing parameters"}, status_code=400)
986
 
987
 
988
- success = core.unblock_user(blocker, blocked)
989
 
990
  if success:
991
  # Broadcast unblock update
@@ -1017,7 +1196,7 @@ async def check_is_blocked(data: dict):
1017
  if not blocker or not blocked:
1018
  return {"status": "fail", "message": "Missing parameters"}
1019
 
1020
- is_blocked = core.is_blocked(blocker, blocked)
1021
  return {"status": "success", "isBlocked": is_blocked}
1022
 
1023
  @app.post("/api/user/getBlockedUsers")
@@ -1027,11 +1206,11 @@ async def get_blocked_users(data: dict):
1027
  if not username:
1028
  return {"status": "fail", "message": "Username required"}
1029
 
1030
- blocked_users = core.get_blocked_users(username)
1031
  blocked_list = []
1032
 
1033
  for user in blocked_users:
1034
- avatar = core.get_avatar(user)
1035
  if avatar:
1036
  if not avatar.startswith("/static"):
1037
  avatar = "/static/" + avatar.replace("../../frontend/", "")
@@ -1063,7 +1242,7 @@ async def create_chat(data: dict):
1063
  if not name:
1064
  return {"error": "Group name required"}
1065
  is_group = True
1066
- user_convs = core.get_user_conversations(creator)
1067
  for c in user_convs:
1068
  if isinstance(c, dict):
1069
  conv_id = c.get("id")
@@ -1074,20 +1253,20 @@ async def create_chat(data: dict):
1074
  conv_name = parts[1] if len(parts) > 1 else ""
1075
  if not conv_id:
1076
  continue
1077
- conv_users = core.get_conversation_users(int(conv_id))
1078
  conv_users_sorted = sorted([u.strip() for u in conv_users if u])
1079
  users_sorted = sorted([u.strip() for u in users if u])
1080
  if conv_users_sorted == users_sorted and conv_name == name:
1081
  return {"conversationId": conv_id}
1082
- conv_id = core.create_conversation(users, is_group, name, admin_username=creator)
1083
  payload = {"type": "conversation_created", "conversationId": conv_id}
1084
  for u in users:
1085
- if u in connections:
1086
- for ws in list(connections[u]):
1087
  try:
1088
  await ws.send_json(payload)
1089
  except Exception:
1090
- connections[u].discard(ws)
1091
  return {"conversationId": conv_id}
1092
 
1093
 
@@ -1101,23 +1280,23 @@ async def add_chat_member(data: dict):
1101
 
1102
  added_any = False
1103
  for u_name in usernames:
1104
- if u_name and core.add_user_to_conversation(conv_id, u_name):
1105
  added_any = True
1106
 
1107
  if added_any:
1108
  # 4. Invalidate backend cache for all members
1109
- members = core.get_conversation_users(conv_id)
1110
  invalidate_conv_cache_for_all(members)
1111
 
1112
  # 5. Broadcast update to all members
1113
  payload = {"type": "conversation_updated", "conversationId": conv_id}
1114
  for u in members:
1115
- if u in connections:
1116
- for ws in list(connections[u]):
1117
  try:
1118
  await ws.send_json(payload)
1119
  except Exception:
1120
- connections[u].discard(ws)
1121
  return {"status": "success"}
1122
  return {"status": "fail"}
1123
 
@@ -1135,12 +1314,12 @@ async def share_post_route(data: dict):
1135
 
1136
  for target in usernames:
1137
  # 1. Find or Create direct chat
1138
- conv_id = core.find_conversation([sender, target], "")
1139
  if conv_id == -1:
1140
- conv_id = core.create_conversation([sender, target], False, "", admin_username=sender)
1141
 
1142
  # 2. Save message as type 'post'
1143
- msg_id = core.send_message(sender, conv_id, str(post_id), "post")
1144
 
1145
  # 3. Broadcast to both participants
1146
  invalidate_conv_cache(sender)
@@ -1171,28 +1350,28 @@ async def share_post_route(data: dict):
1171
  async def remove_chat_member(data: dict):
1172
  conv_id = int(data.get("conversationId"))
1173
  username = data.get("username")
1174
- members_before = core.get_conversation_users(conv_id)
1175
- if core.remove_user_from_conversation(conv_id, username):
1176
  # Invalidate backend cache for everyone who was in the group
1177
  invalidate_conv_cache_for_all(members_before)
1178
 
1179
  # Broadcast update to remaining members and the removed member
1180
  payload = {"type": "conversation_updated", "conversationId": conv_id}
1181
  for u in set(members_before):
1182
- if u in connections:
1183
- for ws in list(connections[u]):
1184
  try:
1185
  await ws.send_json(payload)
1186
  except Exception:
1187
- connections[u].discard(ws)
1188
  return {"status": "success"}
1189
  return {"status": "fail"}
1190
 
1191
  @app.delete("/api/deleteConversation/{username}/{convId}")
1192
  async def delete_conversation(username: str, convId: int):
1193
  try:
1194
- members = core.get_conversation_users(convId)
1195
- convs = core.get_user_conversations(username)
1196
  is_group = False
1197
  for c in convs:
1198
  if isinstance(c, dict):
@@ -1204,15 +1383,15 @@ async def delete_conversation(username: str, convId: int):
1204
  if parts[0] == str(convId):
1205
  is_group = parts[2] == "1"
1206
  break
1207
- core.delete_conversation(convId)
1208
  payload = {"type": "conversation_deleted", "conversationId": convId}
1209
  for u in members:
1210
- if u in connections:
1211
- for ws in list(connections[u]):
1212
  try:
1213
  await ws.send_json(payload)
1214
  except Exception:
1215
- connections[u].discard(ws)
1216
  return {"status": "deleted", "type": "group" if is_group else "direct"}
1217
  except Exception as e:
1218
  return {"error": str(e)}
@@ -1225,8 +1404,8 @@ async def update_conv_avatar(data: dict):
1225
  avatar = data["avatar"]
1226
  if avatar and avatar.startswith("data:"):
1227
  avatar = upload_media_to_supabase(avatar)
1228
- core.update_conversation_avatar(conv_id, avatar)
1229
- users = core.get_conversation_users(conv_id)
1230
  payload = {
1231
  "type": "conversation_avatar_update",
1232
  "conversationId": conv_id,
@@ -1234,9 +1413,9 @@ async def update_conv_avatar(data: dict):
1234
  }
1235
  tasks = []
1236
  for u in users:
1237
- if u in connections:
1238
- for ws in list(connections[u]):
1239
- tasks.append(send_safe(ws, payload, connections[u]))
1240
  await asyncio.gather(*tasks, return_exceptions=True)
1241
  return {"status": "ok"}
1242
  except Exception as e:
@@ -1312,7 +1491,7 @@ async def delete_post(data: dict):
1312
  {"status": "fail", "message": "Missing parameters"}, status_code=400
1313
  )
1314
 
1315
- success = core.delete_post(post_id, username)
1316
 
1317
  if success:
1318
  # Broadcast post deletion
@@ -1330,16 +1509,21 @@ async def delete_post(data: dict):
1330
 
1331
 
1332
  @app.get("/api/post/feed")
1333
- def get_feed_get(username: Optional[str] = None, viewer: Optional[str] = None):
 
 
 
 
 
1334
  try:
1335
  current_user = viewer or username or ""
1336
  feed = core.get_feed(current_user)
1337
-
1338
  # Performance optimization: pre-fetch block list to avoid N+1 queries
1339
  blocked_list = set()
1340
  if current_user:
1341
  blocked_list = set(core.get_blocked_users(current_user) + core.get_blockers(current_user))
1342
-
1343
  filtered_feed = []
1344
  for post in feed:
1345
  post_user = post.get("username", "")
@@ -1347,7 +1531,10 @@ def get_feed_get(username: Optional[str] = None, viewer: Optional[str] = None):
1347
  if post_user in blocked_list:
1348
  continue
1349
  filtered_feed.append(post)
1350
- return {"status": "success", "feed": filtered_feed, "posts": filtered_feed}
 
 
 
1351
  except Exception as e:
1352
  return JSONResponse({"status": "fail", "message": str(e)}, status_code=500)
1353
 
@@ -1356,7 +1543,7 @@ def get_feed_post(data: FeedRequest):
1356
  try:
1357
  current_user = data.viewer or data.username or ""
1358
  feed = core.get_feed(current_user)
1359
- return {"status": "success", "feed": feed, "posts": feed}
1360
  except Exception as e:
1361
  return JSONResponse({"status": "fail", "message": str(e)}, status_code=500)
1362
 
@@ -1369,10 +1556,10 @@ def get_user_posts(data: UserPostsRequest):
1369
 
1370
  if viewer and viewer != username:
1371
  if core.is_blocked_either_way(viewer, username):
1372
- return {"status": "success", "posts": [], "blocked": True}
1373
 
1374
  posts = core.get_user_posts(username, viewer)
1375
- return {"status": "success", "posts": posts}
1376
  except Exception as e:
1377
 
1378
  return JSONResponse({"status": "fail", "message": str(e)}, status_code=500)
@@ -1387,10 +1574,10 @@ async def add_comment(data: dict):
1387
  return JSONResponse(
1388
  {"status": "fail", "message": "Missing parameters"}, status_code=400
1389
  )
1390
- success = core.add_comment(post_id, username, content)
1391
  if success:
1392
  # Get the newly added comment (get_comments returns newest first with desc=True)
1393
- comments = core.get_comments(post_id)
1394
  new_comment = comments[0] if comments else None
1395
  # Get updated comments count
1396
  rows = get_client().table("posts").select("comments_count").eq("id", post_id).execute()
@@ -1439,6 +1626,7 @@ async def create_station(data: dict):
1439
  "type": "station_created",
1440
  "stationName": station_name,
1441
  "admin": admin_username,
 
1442
  }
1443
  # Broadcast to all
1444
  asyncio.create_task(coreHelper.broadcast_to_all(payload))
@@ -1483,8 +1671,8 @@ async def add_user_to_station(data: dict):
1483
  "stationName": station_name,
1484
  "user": user_to_add,
1485
  }
1486
- # Notify relevant users
1487
- asyncio.create_task(coreHelper.broadcast_to_all(payload))
1488
  return {"status": "success"}
1489
  return {"status": "fail"}
1490
 
@@ -1502,11 +1690,13 @@ async def remove_user_from_station(data: dict):
1502
 
1503
  success = await asyncio.to_thread(core.remove_user_from_station, station_name, admin_username, user_to_remove)
1504
  if success:
1505
- await coreHelper.broadcast_to_all({
1506
- "type": "station_user_removed",
1507
- "stationName": station_name,
1508
- "user": user_to_remove,
1509
- })
 
 
1510
  return {"status": "success"}
1511
  return {"status": "fail"}
1512
 
@@ -1521,7 +1711,6 @@ async def request_to_join_station(data: dict):
1521
  )
1522
  success = await asyncio.to_thread(core.request_to_join, station_name, username)
1523
  if success:
1524
- # Get station info to find admin
1525
  stations = await asyncio.to_thread(core.get_station, station_name)
1526
  if stations:
1527
  admin = stations[0].get("admin", "")
@@ -1530,14 +1719,8 @@ async def request_to_join_station(data: dict):
1530
  "stationName": station_name,
1531
  "user": username,
1532
  }
1533
- # Only send to station admin if online
1534
- admin_key = admin.lower()
1535
- if admin_key in connections:
1536
- for ws in list(connections[admin_key]):
1537
- try:
1538
- await ws.send_json(payload)
1539
- except Exception:
1540
- connections[admin_key].discard(ws)
1541
  return {"status": "success" if success else "fail"}
1542
 
1543
 
@@ -1564,15 +1747,19 @@ async def approve_station_request(data: dict):
1564
  )
1565
  success = await asyncio.to_thread(core.approve_request, station_name, admin_username, user_to_approve)
1566
  if success:
1567
- await coreHelper.broadcast_to_all({
1568
- "type": "station_user_approved",
1569
- "stationName": station_name,
1570
- "user": user_to_approve,
1571
- })
1572
- # Small delay before second broadcast or just one is enough
1573
- update_payload = {"type": "station_updated", "stationName": station_name}
1574
- asyncio.create_task(coreHelper.broadcast_to_all(update_payload))
1575
-
 
 
 
 
1576
  return {"status": "success" if success else "fail"}
1577
 
1578
 
@@ -1585,20 +1772,25 @@ async def reject_station_request(data: dict):
1585
  return JSONResponse(
1586
  {"status": "fail", "message": "Missing parameters"}, status_code=400
1587
  )
1588
- success = core.reject_request(station_name, admin_username, user_to_reject)
1589
  if success:
1590
- payload = {
1591
  "type": "station_request_rejected",
1592
  "stationName": station_name,
1593
  "user": user_to_reject,
1594
  }
1595
- user_key = user_to_reject.lower()
1596
- if user_key in connections:
1597
- for ws in list(connections[user_key]):
1598
- try:
1599
- await ws.send_json(payload)
1600
- except Exception:
1601
- connections[user_key].discard(ws)
 
 
 
 
 
1602
  return {"status": "success" if success else "fail"}
1603
 
1604
 
@@ -1610,7 +1802,7 @@ async def get_pending_requests(data: dict):
1610
  return JSONResponse(
1611
  {"status": "fail", "message": "Missing parameters"}, status_code=400
1612
  )
1613
- requests = core.get_pending_requests(station_name, admin_username)
1614
  return {"status": "success", "requests": requests}
1615
 
1616
 
@@ -1624,19 +1816,14 @@ async def create_station_post(data: dict):
1624
  return JSONResponse(
1625
  {"status": "fail", "message": "Missing parameters"}, status_code=400
1626
  )
1627
- new_post = core.create_station_post(station_name, username, content, media_path)
1628
  if new_post:
1629
  payload = {
1630
  "type": "station_post_created",
1631
  "stationName": station_name,
1632
  "post": new_post,
1633
  }
1634
- for user_conns in list(connections.values()):
1635
- for ws in list(user_conns):
1636
- try:
1637
- await ws.send_json(payload)
1638
- except Exception:
1639
- user_conns.discard(ws)
1640
  return {"status": "success", "post": new_post}
1641
  return {"status": "fail"}
1642
 
@@ -1653,19 +1840,14 @@ async def delete_station_post(data: dict):
1653
  return JSONResponse(
1654
  {"status": "fail", "message": "Missing parameters"}, status_code=400
1655
  )
1656
- success = core.delete_station_post(post_id, station_name, username)
1657
  if success:
1658
  payload = {
1659
  "type": "station_post_deleted",
1660
  "postId": post_id,
1661
  "stationName": station_name,
1662
  }
1663
- for user_conns in list(connections.values()):
1664
- for ws in list(user_conns):
1665
- try:
1666
- await ws.send_json(payload)
1667
- except Exception:
1668
- user_conns.discard(ws)
1669
  return {"status": "success"}
1670
  return {"status": "fail"}
1671
 
@@ -1679,8 +1861,8 @@ async def get_station_posts(data: dict):
1679
  {"status": "fail", "message": "Station name required"}, status_code=400
1680
  )
1681
  try:
1682
- posts = core.get_station_posts(station_name, viewer)
1683
- return {"status": "success", "posts": posts}
1684
  except Exception as e:
1685
  return JSONResponse({"status": "fail", "message": str(e)}, status_code=500)
1686
 
@@ -1693,8 +1875,9 @@ async def get_station(data: dict):
1693
  return JSONResponse(
1694
  {"status": "fail", "message": "Station name required"}, status_code=400
1695
  )
1696
- stations = core.get_station(station_name, viewer)
1697
- return {"status": "success", "station": stations[0] if stations else None}
 
1698
 
1699
 
1700
 
@@ -1706,7 +1889,7 @@ async def get_bulk_station_posts_route(data: dict):
1706
  return {"status": "success", "posts": []}
1707
  try:
1708
  posts = await asyncio.to_thread(core.get_bulk_station_posts, station_names, viewer)
1709
- return {"status": "success", "posts": posts}
1710
  except Exception as e:
1711
  return JSONResponse({"status": "fail", "message": str(e)}, status_code=500)
1712
 
@@ -1732,7 +1915,7 @@ async def get_station_members(data: dict):
1732
  return JSONResponse(
1733
  {"status": "fail", "message": "Station name required"}, status_code=400
1734
  )
1735
- members = core.get_station_members(station_name, viewer)
1736
  return {"status": "success", "members": members}
1737
 
1738
 
@@ -1748,7 +1931,8 @@ def update_station_bio(data: dict):
1748
  success = core.update_station_bio(station_name, bio)
1749
  if success:
1750
  payload = {"type": "station_bio_updated", "stationName": station_name, "bio": bio}
1751
- asyncio.create_task(coreHelper.broadcast_to_all(payload))
 
1752
  return {"status": "success" if success else "fail"}
1753
  except Exception as e:
1754
 
@@ -1766,7 +1950,8 @@ def update_station_hero_banner(data: dict):
1766
  success = core.update_station_hero_banner(station_name, hero_banner)
1767
  if success:
1768
  payload = {"type": "station_hero_banner_updated", "stationName": station_name, "heroBanner": hero_banner}
1769
- asyncio.create_task(coreHelper.broadcast_to_all(payload))
 
1770
  return {"status": "success" if success else "fail"}
1771
  except Exception as e:
1772
 
@@ -1784,7 +1969,8 @@ def update_station_profile_pic(data: dict):
1784
  success = core.update_station_profile_pic(station_name, profile_pic)
1785
  if success:
1786
  payload = {"type": "station_profile_pic_updated", "stationName": station_name, "profilePic": profile_pic}
1787
- asyncio.create_task(coreHelper.broadcast_to_all(payload))
 
1788
  return {"status": "success" if success else "fail"}
1789
  except Exception as e:
1790
 
@@ -1799,10 +1985,25 @@ def update_station_name_fn(data: dict):
1799
  if not old_name or not new_name:
1800
  return JSONResponse({"status": "fail", "message": "Missing parameters"}, status_code=400)
1801
 
 
1802
  success = core.update_station_name(old_name, new_name)
1803
  if success:
1804
  payload = {"type": "station_name_updated", "oldName": old_name, "newName": new_name}
1805
- asyncio.create_task(coreHelper.broadcast_to_all(payload))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1806
  return {"status": "success" if success else "fail"}
1807
  except Exception as e:
1808
 
@@ -1816,7 +2017,7 @@ async def get_station_bio_fn(data: dict):
1816
  return JSONResponse(
1817
  {"status": "fail", "message": "Station name required"}, status_code=400
1818
  )
1819
- bio = core.get_station_bio(station_name)
1820
  return {"status": "success", "bio": bio}
1821
 
1822
 
@@ -1827,7 +2028,7 @@ async def get_station_hero_banner_fn(data: dict):
1827
  return JSONResponse(
1828
  {"status": "fail", "message": "Station name required"}, status_code=400
1829
  )
1830
- hero_banner = core.get_station_hero_banner(station_name)
1831
  return {"status": "success", "heroBanner": hero_banner}
1832
 
1833
 
@@ -1838,7 +2039,7 @@ async def get_station_profile_pic_fn(data: dict):
1838
  return JSONResponse(
1839
  {"status": "fail", "message": "Station name required"}, status_code=400
1840
  )
1841
- profile_pic = core.get_station_profile_pic(station_name)
1842
  return {"status": "success", "profilePic": profile_pic}
1843
 
1844
 
@@ -1849,7 +2050,7 @@ async def get_station_admin_fn(data: dict):
1849
  return JSONResponse(
1850
  {"status": "fail", "message": "Station name required"}, status_code=400
1851
  )
1852
- admin = core.get_station_admin(station_name)
1853
  return {"status": "success", "admin": admin}
1854
 
1855
 
@@ -1875,16 +2076,12 @@ async def like_post(data: dict):
1875
  try:
1876
  post_id = int(post_id)
1877
 
1878
- def _do_like():
1879
- # If already liked, we still want to return success to keep UI in sync
1880
- success = core.like_post(post_id, username)
1881
- if success:
1882
- likes = core.modify_likes(post_id, +1)
1883
- else:
1884
- likes = core.get_likes_count(post_id)
1885
- return success, likes
1886
-
1887
- success, likes = await asyncio.to_thread(_do_like)
1888
 
1889
  # Always broadcast the latest count
1890
  asyncio.create_task(coreHelper.broadcast_like_update(post_id, likes))
@@ -1907,16 +2104,12 @@ async def unlike_post(data: dict):
1907
  try:
1908
  post_id = int(post_id)
1909
 
1910
- def _do_unlike():
1911
- # Even if already unliked, return success to prevent UI from reverting
1912
- success = core.unlike_post(post_id, username)
1913
- if success:
1914
- likes = core.modify_likes(post_id, -1)
1915
- else:
1916
- likes = core.get_likes_count(post_id)
1917
- return success, likes
1918
-
1919
- success, likes = await asyncio.to_thread(_do_unlike)
1920
 
1921
  asyncio.create_task(coreHelper.broadcast_like_update(post_id, likes))
1922
  return {"status": "success", "likes": likes}
@@ -1950,39 +2143,87 @@ async def ws_likes_endpoint(ws: WebSocket, username: str):
1950
  coreHelper.unregister_ws(username, ws, connections)
1951
 
1952
 
1953
- @app.get("/api/bitai/conversation/{username}")
1954
- def get_bitai_conversation(username: str):
1955
- conv_id = core.get_or_create_bitai_conversation(username)
1956
- return {"conversationId": conv_id, "status": "success" if conv_id > 0 else "error"}
1957
 
1958
 
1959
- @app.post("/api/bitai/chat")
1960
- async def bitai_chat(data: dict):
1961
- message = data.get("message", "")
1962
- user = data.get("username", "")
1963
- history = data.get("history", [])
 
1964
 
1965
- if not message or not user:
1966
- return {"status": "error", "message": "Missing parameters"}
1967
 
1968
- conv_id = core.get_or_create_bitai_conversation(user)
1969
- if conv_id <= 0:
1970
- return {"status": "error", "message": "Could not create conversation"}
 
1971
 
1972
- core.send_message(user, conv_id, message, "text", "")
1973
 
1974
- try:
1975
- import httpx
 
 
 
 
 
 
1976
 
1977
- api_key = os.environ.get("GROQ_API_KEY", "")
1978
 
1979
- if not api_key:
1980
- return {"status": "error", "response": "Me brain dead. You set GROQ_API_KEY in .env"}
 
 
 
 
1981
 
1982
- system_prompt = f"""You are BitAI. Your name is BitAI. You are a friendly AI assistant for YapStation - a cyberpunk social media platform.
1983
- The user chatting with you is named {user}. Call them by their name!
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1984
  YapStation features: chat with friends, yap stations (podcasts), posts, followers, themes.
1985
  You help users with: chatting, advice, answering questions about the platform, fun conversation.
 
1986
 
1987
  You speak CAVE MAN style. Short. Baby words. Mostly just pronouns and verbs. Friendly. Like speaking to a buddy.
1988
  Examples: "Hey {{name}}! Me here. Whats up? Me help you?"
@@ -1990,14 +2231,15 @@ Or: "Hey {{name}}, me understand. You do this..."
1990
  Or: "Cool cool! Me think you good. Go for it!"
1991
  Or: "No stress! Me got you. Tell me more."
1992
 
1993
- Sometimes use the user's name ({user}) in your response when possible. Be casual, helpful, fun."""
1994
 
1995
- messages = [{"role": "system", "content": system_prompt}]
1996
- for msg in history[-8:]:
1997
- role = "user" if msg.get("role") == "user" else "assistant"
1998
- messages.append({"role": role, "content": msg.get("content", "")})
1999
- messages.append({"role": "user", "content": message})
2000
 
 
2001
  async with httpx.AsyncClient(timeout=30.0) as client:
2002
  response = await client.post(
2003
  "https://api.groq.com/openai/v1/chat/completions",
@@ -2017,14 +2259,111 @@ Sometimes use the user's name ({user}) in your response when possible. Be casual
2017
  result = response.json()
2018
  ai_response = result.get("choices", [{}])[0].get("message", {}).get("content", "")
2019
  if ai_response:
2020
- core.send_message("BitAI", conv_id, ai_response, "text", "")
2021
- return {"status": "success", "response": ai_response}
2022
- return {"status": "error", "response": "Me no think. Try again."}
2023
- else:
2024
- return {"status": "error", "response": f"Me hurt. Error: {response.status_code}"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2025
 
2026
- except Exception as e:
2027
- return {"status": "error", "response": "Me broken. Fix me."}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2028
 
2029
 
2030
  @app.websocket("/ws/{username}")
@@ -2139,20 +2478,21 @@ async def websocket_endpoint(ws: WebSocket, username: str):
2139
  convo_id = int(convo_id)
2140
  except Exception:
2141
  continue
2142
- users = core.get_conversation_users(convo_id)
2143
  payload = {
2144
  "type": msg_type,
2145
  "sender": sender,
2146
  "conversationId": convo_id,
2147
  }
2148
  for u in users:
2149
- if u == sender or u not in connections:
 
2150
  continue
2151
- for conn in list(connections[u]):
2152
  try:
2153
  await conn.send_json(payload)
2154
  except Exception:
2155
- connections[u].discard(conn)
2156
  continue
2157
  convo_id = data.get("conversationId")
2158
  sender = data.get("sender")
@@ -2169,10 +2509,10 @@ async def websocket_endpoint(ws: WebSocket, username: str):
2169
  continue
2170
 
2171
  # Blocking Check
2172
- users = core.get_conversation_users(convo_id)
2173
  is_blocked = False
2174
  for u in users:
2175
- if u != sender and core.is_blocked_either_way(sender, u):
2176
  is_blocked = True
2177
  break
2178
 
@@ -2181,38 +2521,18 @@ async def websocket_endpoint(ws: WebSocket, username: str):
2181
  continue
2182
 
2183
  try:
2184
- core.send_message(sender, convo_id, content, msg_type, media, reply_to)
2185
  except Exception:
2186
  continue
2187
- for u in users:
2188
- _conv_cache.pop(u, None)
2189
- users = core.get_conversation_users(convo_id)
2190
- payload = {
2191
- "conversationId": convo_id,
2192
- "sender": sender,
2193
- "type": msg_type,
2194
- "content": content,
2195
- "media": media,
2196
- "time": int(time.time()),
2197
- "id": f"{convo_id}_{sender}_{int(time.time() * 1000)}",
2198
- "replyTo": reply_to,
2199
- }
2200
- for u in users:
2201
- if u in connections:
2202
- for conn in list(connections[u]):
2203
- try:
2204
- await conn.send_json(payload)
2205
- if u != sender:
2206
- await conn.send_json(
2207
- {
2208
- "type": "notification",
2209
- "sender": sender,
2210
- "content": content,
2211
- "conversationId": convo_id,
2212
- }
2213
- )
2214
- except Exception:
2215
- connections[u].discard(conn)
2216
  except Exception:
2217
  pass
2218
  finally:
 
5
 
6
  import asyncio
7
  import os
8
+ import re
9
  import sys
10
  import time
11
  from pathlib import Path
 
21
 
22
  import engine
23
  import engineHelper as coreHelper
24
+ import engine_user
25
  import jwt
26
+ import mail
27
  from db import get_client, get_service_client
28
 
29
  core = engine.Engine()
 
33
 
34
  @app.exception_handler(Exception)
35
  async def global_exception_handler(request: Request, exc: Exception):
36
+ if isinstance(exc, engine_user.PasswordResetUnavailableError):
37
+ return JSONResponse(
38
+ {"status": "error", "message": str(exc)},
39
+ status_code=503,
40
+ )
41
  return JSONResponse({"status": "fail", "message": str(exc)}, status_code=500)
42
 
43
  # Add CORS middleware
 
45
 
46
  app.add_middleware(
47
  CORSMiddleware,
48
+ allow_origins=[
49
+ FRONTEND_URL,
50
+ "http://localhost:3000",
51
+ "http://127.0.0.1:3000",
52
+ ],
53
  allow_credentials=True,
54
  allow_methods=["*"],
55
  allow_headers=["*"],
56
  )
57
 
58
  JWT_SECRET = os.environ.get("JWT_SECRET", "default_secret_key_if_not_found")
59
+
60
+ # Session cookie: browsers reject Secure cookies on plain HTTP. Local dev uses http://localhost:3000
61
+ # (Next proxy). Set SESSION_COOKIE_SECURE=true in production (.env / host secrets) when using HTTPS.
62
+ SESSION_COOKIE_SECURE = os.environ.get("SESSION_COOKIE_SECURE", "").strip().lower() in (
63
+ "1",
64
+ "true",
65
+ "yes",
66
+ )
67
+ SESSION_COOKIE_SAMESITE = "none" if SESSION_COOKIE_SECURE else "lax"
68
+
69
  from typing import Optional
70
 
71
+ from schemas.dtos import (
72
+ FeedRequest,
73
+ UserPostsRequest,
74
+ build_feed_response,
75
+ build_post_detail_response,
76
+ build_station_get_response,
77
+ build_user_posts_response,
78
+ )
79
 
 
 
 
80
  JWT_ALGORITHM = "HS256"
81
 
82
  BASE = os.path.dirname(__file__)
 
89
  os.makedirs("static_dummy", exist_ok=True)
90
  app.mount("/static", StaticFiles(directory="static_dummy"), name="static")
91
 
92
+
93
+ def normalize_avatar_for_response(avatar: str | None) -> str:
94
+ """Make DB avatar values usable in <img src> (data URL, absolute http(s), or /static/…)."""
95
+ if not avatar:
96
+ return ""
97
+ a = str(avatar).strip()
98
+ if a.startswith("data:image/") or a.startswith("http://") or a.startswith("https://"):
99
+ return a
100
+ if a.startswith("/static"):
101
+ return a
102
+ compact = "".join(a.split())
103
+ if len(compact) >= 100 and re.fullmatch(r"[A-Za-z0-9+/=]+", compact):
104
+ if compact.startswith("iVBOR"):
105
+ mime = "image/png"
106
+ elif compact.startswith("/9j/"):
107
+ mime = "image/jpeg"
108
+ else:
109
+ mime = "image/png"
110
+ return f"data:{mime};base64,{compact}"
111
+ path = a.replace("../../frontend/", "").lstrip("/")
112
+ return f"/static/{path}" if path else ""
113
+
114
+
115
  def upload_media_to_supabase(data_url: str) -> str:
116
  """Upload a base64 data URL to Supabase Storage, return the public URL."""
117
  import base64, uuid, mimetypes
 
138
  broadcast_online = coreHelper.broadcast_online
139
 
140
 
141
+ async def notify_station_members_ws(station_name: str, payload: dict) -> None:
142
+ """Deliver a WS payload to each connected station member (includes admin)."""
143
+ try:
144
+ members = await asyncio.to_thread(core.get_station_members, station_name, "")
145
+ except Exception:
146
+ members = []
147
+ seen: set[str] = set()
148
+ for u in members or []:
149
+ if not u:
150
+ continue
151
+ lk = str(u).lower()
152
+ if lk in seen:
153
+ continue
154
+ seen.add(lk)
155
+ await coreHelper.send_to_user(u, payload, connections)
156
+
157
+
158
+ async def broadcast_station_post_ws(payload: dict) -> None:
159
+ """Station posts appear on Discover for many viewers — fan-out to all connections."""
160
+ await coreHelper.broadcast_to_all(payload)
161
+
162
+
163
+ async def touch_station_catalog_ws(station_name: str) -> None:
164
+ """Light broadcast so browse pages (yap stations list, profile stations) can refresh."""
165
+ await coreHelper.broadcast_to_all(
166
+ {"type": "station_catalog_refresh", "stationName": station_name}
167
+ )
168
+
169
+
170
  def invalidate_conv_cache(username: str):
171
  if username in _conv_cache:
172
  del _conv_cache[username]
 
190
  if not input or "@" not in input:
191
  return {"email": "", "usernames": [], "count": 0}
192
 
193
+ result = await asyncio.to_thread(core.get_username, input)
194
  count = int(result.get("count", "0"))
195
 
196
  if count > 0:
 
209
  if not username or "@" not in username:
210
  return {"email": "", "usernames": [], "count": 0}
211
 
212
+ result = await asyncio.to_thread(core.get_username, username)
213
  count = int(result.get("count", "0"))
214
 
215
  if count > 0:
 
257
  admin_user = data.get("admin_username")
258
  target_user = data.get("target_username")
259
 
260
+ if not await asyncio.to_thread(core.is_developer, admin_user):
261
  return JSONResponse({"status": "error", "message": "Unauthorized"}, status_code=403)
262
 
263
  if not target_user:
 
268
  await coreHelper.evict_user_ws(target_user)
269
 
270
  # 2. Hard delete from DB
271
+ success = await asyncio.to_thread(core.delete_user, target_user)
272
 
273
  if success:
274
  return {"status": "success", "message": f"User {target_user} has been purged."}
 
286
  return JSONResponse({"status": "fail", "message": "Post not found"}, status_code=404)
287
  if isinstance(post, dict) and post.get("restricted"):
288
  return JSONResponse({"status": "restricted", "username": post["username"]}, status_code=403)
289
+ return build_post_detail_response(post)
290
  except Exception as e:
291
  return JSONResponse({"status": "fail", "message": str(e)}, status_code=500)
292
 
 
308
  admin_user = data.get("admin_username")
309
  target_station = data.get("target_station")
310
 
311
+ if not await asyncio.to_thread(core.is_developer, admin_user):
312
  return JSONResponse({"status": "error", "message": "Unauthorized"}, status_code=403)
313
 
314
  if not target_station:
315
  return {"status": "fail", "message": "Target station name required"}
316
 
317
  try:
318
+ success = await asyncio.to_thread(core.force_delete_station, target_station)
319
  if success:
320
  # Notify all users that the station is gone
321
  payload = {"type": "station_deleted", "stationName": target_station}
 
343
 
344
  # If username doesn't contain @, it's already a username
345
  if username and "@" not in username:
346
+ if await asyncio.to_thread(core.login, username, password):
347
  token = create_jwt_token(username)
348
  response.set_cookie(
349
  key="yap_session",
350
  value=token,
351
  httponly=True,
352
  max_age=7 * 24 * 60 * 60, # 7 days
353
+ samesite=SESSION_COOKIE_SAMESITE,
354
+ secure=SESSION_COOKIE_SECURE,
355
  path="/"
356
  )
357
  return {"status": "success", "username": username, "token": token}
 
359
 
360
  # If username is an email, get the accounts
361
  if username and "@" in username:
362
+ result = await asyncio.to_thread(core.get_username, username)
363
  if int(result.get("count", "0")) == 0:
364
  return {"status": "fail"}
365
 
 
369
  # If password provided, try first account
370
  if password:
371
  username = usernames[0] if usernames else None
372
+ if username and await asyncio.to_thread(core.login, username, password):
373
  token = create_jwt_token(username)
374
  response.set_cookie(
375
  key="yap_session",
376
  value=token,
377
  httponly=True,
378
  max_age=7 * 24 * 60 * 60, # 7 days
379
+ samesite=SESSION_COOKIE_SAMESITE,
380
+ secure=SESSION_COOKIE_SECURE,
381
  path="/"
382
  )
383
  return {"status": "success", "username": username, "token": token}
 
402
  @app.post("/api/register")
403
  async def register(data: dict):
404
  try:
405
+ theme = data.get("theme", 5)
406
+ await asyncio.to_thread(
407
+ core.register_user,
408
  data["firstName"],
409
  data["lastName"],
410
  data["gender"],
411
  data["username"],
412
  data["email"],
413
  data["password"],
414
+ theme,
415
  )
416
  return {"status": "success"}
417
  except Exception as e:
418
  return {"status": "error", "message": str(e)}
419
 
420
 
421
+ @app.post("/api/auth/forgot-password")
422
+ async def forgot_password(data: dict):
423
+ """Send reset email for a username (after client resolves email→username if needed)."""
424
+ username = (data.get("username") or "").strip()
425
+ generic_ok = {
426
+ "status": "success",
427
+ "message": "If an account exists for that username, we sent reset instructions to its email.",
428
+ }
429
+ if not username:
430
+ return JSONResponse(
431
+ {"status": "error", "message": "Username required"}, status_code=400
432
+ )
433
+ if not await asyncio.to_thread(core.user_exists, username):
434
+ return generic_ok
435
+ user_row = await asyncio.to_thread(core.get_user_by_username, username)
436
+ email_addr = (user_row.get("email") or "").strip()
437
+ if not email_addr:
438
+ return generic_ok
439
+ token_plain = await asyncio.to_thread(core.create_password_reset_token, username)
440
+ if not token_plain:
441
+ return JSONResponse(
442
+ {"status": "error", "message": "Could not create reset token"}, status_code=500
443
+ )
444
+ raw_base = os.environ.get(
445
+ "FRONTEND_URL",
446
+ os.environ.get("NEXT_PUBLIC_SITE_URL", "http://localhost:3000"),
447
+ ).strip()
448
+ if raw_base and not raw_base.startswith("http"):
449
+ raw_base = "https://" + raw_base
450
+ base = raw_base.rstrip("/")
451
+ reset_url = f"{base}/reset-password?token={token_plain}"
452
+ sent = mail.send_password_reset_email(email_addr, username, reset_url)
453
+ if not mail.smtp_configured():
454
+ print(
455
+ "[password-reset] SMTP not configured (set SMTP_HOST, SMTP_USER, SMTP_PASSWORD in .env). "
456
+ "Email was not sent; use this link or configure Gmail SMTP — see backend mail.py."
457
+ )
458
+ print(f"[password-reset] Reset URL for @{username}: {reset_url}")
459
+ elif not sent:
460
+ print("[password-reset] SMTP send failed (check credentials / Gmail app password). Link:")
461
+ print(f"[password-reset] Reset URL for @{username}: {reset_url}")
462
+ out = dict(generic_ok)
463
+ if os.environ.get("DEBUG_PASSWORD_RESET", "").strip().lower() in ("1", "true", "yes"):
464
+ out["dev_reset_url"] = reset_url
465
+ return out
466
+
467
+
468
+ @app.get("/api/auth/reset-password/validate")
469
+ def reset_password_validate(token: str = Query("")):
470
+ if not token:
471
+ return {"status": "error", "message": "Missing token"}
472
+ info = core.validate_password_reset_token(token)
473
+ if not info:
474
+ return {"status": "error", "message": "Invalid or expired link"}
475
+ username = info["username"]
476
+ preview = core.get_user_public_for_reset(username)
477
+ if not preview:
478
+ return {"status": "error", "message": "User not found"}
479
+ preview = dict(preview)
480
+ preview["avatar"] = normalize_avatar_for_response(preview.get("avatar"))
481
+ return {"status": "success", "user": preview}
482
+
483
+
484
+ @app.post("/api/auth/reset-password/confirm")
485
+ async def reset_password_confirm(data: dict):
486
+ token = (data.get("token") or "").strip()
487
+ pw = (data.get("newPassword") or data.get("password") or "").strip()
488
+ if not token:
489
+ return JSONResponse({"status": "error", "message": "Missing token"}, status_code=400)
490
+ if len(pw) < 6:
491
+ return JSONResponse(
492
+ {"status": "error", "message": "Password must be at least 6 characters"},
493
+ status_code=400,
494
+ )
495
+ ok = await asyncio.to_thread(core.confirm_password_reset, token, pw)
496
+ if not ok:
497
+ return JSONResponse(
498
+ {"status": "error", "message": "Invalid or expired token"}, status_code=400
499
+ )
500
+ return {"status": "success", "message": "Password updated. You can log in."}
501
+
502
+
503
  def create_page_response(filename: str):
504
  response = FileResponse(os.path.join(FRONTEND, filename))
505
  response.headers["Cache-Control"] = "no-store"
 
520
  return {"status": "success", "username": username}
521
  else:
522
  # User was deleted! Clear the cookie.
523
+ response.delete_cookie(
524
+ "yap_session",
525
+ path="/",
526
+ samesite=SESSION_COOKIE_SAMESITE,
527
+ secure=SESSION_COOKIE_SECURE,
528
+ )
529
  return {"status": "fail", "message": "User no longer exists"}
530
  return {"status": "fail", "message": "Invalid token"}
531
  except jwt.ExpiredSignatureError:
532
+ response.delete_cookie(
533
+ "yap_session",
534
+ path="/",
535
+ samesite=SESSION_COOKIE_SAMESITE,
536
+ secure=SESSION_COOKIE_SECURE,
537
+ )
538
  return {"status": "fail", "message": "Session expired"}
539
  except Exception:
540
  return {"status": "fail", "message": "Auth check failed"}
 
547
  response.delete_cookie(
548
  key="yap_session",
549
  httponly=True,
550
+ samesite=SESSION_COOKIE_SAMESITE,
551
+ secure=SESSION_COOKIE_SECURE,
552
  )
553
  return {"status": "success"}
554
 
 
702
  current_username = data.get("currentUsername", "")
703
  new_username = data.get("newUsername", "")
704
 
705
+ success = await asyncio.to_thread(
706
+ core.update_user_basic,
707
  current_username,
708
  new_username,
709
  data.get("email", ""),
 
736
  username = data.get("username")
737
  if not username:
738
  return {"status": "fail", "message": "Username required"}
739
+ success = await asyncio.to_thread(core.delete_user, username)
740
  if success:
741
  return {"status": "success"}
742
  return {"status": "fail", "message": "Deletion failed"}
 
744
 
745
  @app.post("/api/user/updatePassword")
746
  async def update_password(data: dict):
747
+ success = await asyncio.to_thread(core.update_password, data["username"], data["oldPassword"], data["newPassword"])
 
 
748
  return {"status": "success" if success else "fail"}
749
 
750
 
 
767
  avatar = r.get("avatar") or ""
768
  hero = r.get("heroBanner") or ""
769
  user = {k: str(v) for k, v in r.items() if k not in ("avatar", "heroBanner")}
770
+ return {"user": user, "avatar": avatar, "heroBanner": hero if hero != "null" else "", "is_developer": core.is_developer(username)}
771
  except Exception as e:
772
  return {"error": str(e)}
773
 
 
778
  if core.is_blocked_either_way(viewer, username):
779
  return {"error": "User blocked or unavailable"}
780
  avatar = core.get_avatar(username)
781
+ avatar = normalize_avatar_for_response(avatar) if avatar else ""
 
 
782
  return {"username": username, "avatar": avatar}
783
  except Exception as e:
784
  return {"username": username, "avatar": "", "error": str(e)}
 
836
  async def update_user_theme(data: dict):
837
  username = data["username"]
838
  theme = int(data["theme"])
839
+ await asyncio.to_thread(core.update_theme, username, theme)
840
  payload = {"type": "theme_update", "username": username, "theme": theme}
841
  if username in connections:
842
  for ws in list(connections[username]):
 
847
  return {"status": "ok"}
848
 
849
 
850
+ def _row_username(row):
851
+ if isinstance(row, dict):
852
+ return str(row.get("username") or "")
853
+ return str(row)
854
+
855
+
856
  @app.get("/api/users")
857
  def get_all_users():
858
  users = core.get_all_users()
859
  result = []
860
  for u in users:
861
+ uname = _row_username(u)
862
+ if not uname:
863
+ continue
864
+ avatar = core.get_avatar(uname)
865
  if avatar:
866
  if not avatar.startswith("/static"):
867
  avatar = "/static/" + avatar.replace("../../frontend/", "")
868
+ result.append({"username": uname, "avatar": avatar})
869
  return {"users": result}
870
 
871
  @app.post("/api/users")
 
877
  users = core.get_all_users()
878
  result = []
879
  for u in users:
880
+ uname = _row_username(u)
881
+ if not uname:
882
+ continue
883
+ if current_user and uname != current_user:
884
+ if core.is_blocked_either_way(current_user, uname):
885
  continue
886
+ avatar = core.get_avatar(uname)
887
  if avatar:
888
  if not avatar.startswith("/static"):
889
  avatar = "/static/" + avatar.replace("../../frontend/", "")
890
+ result.append({"username": uname, "avatar": avatar})
891
  return {"users": result}
892
 
893
 
 
900
  if follower == following:
901
  return {"status": "fail", "message": "Cannot follow yourself"}
902
 
903
+ result = await asyncio.to_thread(core.follow_user, follower, following)
904
  if isinstance(result, dict) and result.get("status") == "success":
905
  follow_status = result.get("follow_status")
906
 
 
936
  following = data.get("following")
937
  if not follower or not following:
938
  return {"status": "fail", "message": "Missing parameters"}
939
+ success = await asyncio.to_thread(core.unfollow_user, follower, following)
940
  if success:
941
  await coreHelper.send_follow_notification(
942
  following, follower, "unfollow", connections
 
955
  visibility = data.get("visibility")
956
  if username is None or visibility is None:
957
  return {"status": "fail"}
958
+ await asyncio.to_thread(core.set_visibility, username, visibility)
959
  return {"status": "success"}
960
 
961
  @app.post("/api/user/pendingFollows")
962
  async def get_pending_follows_route(data: dict):
963
  username = data.get("username")
964
  if not username: return {"status": "fail"}
965
+ pending = await asyncio.to_thread(core.get_pending_follows, username)
966
  # Process avatars
967
  for p in pending:
968
  avatar = p.get("avatar")
 
976
  follower = data.get("follower")
977
  following = data.get("following")
978
  if not follower or not following: return {"status": "fail"}
979
+ success = await asyncio.to_thread(core.approve_follow, follower, following)
980
  if success:
981
  # Notify the follower that they were accepted
982
  payload = {
 
1003
  follower = data.get("follower")
1004
  following = data.get("following")
1005
  if not follower or not following: return {"status": "fail"}
1006
+ success = await asyncio.to_thread(core.reject_follow, follower, following)
1007
  if success:
1008
  # Notify the follower that they were rejected
1009
  payload = {
 
1023
  async def get_follow_status_route(data: dict):
1024
  follower = data.get("follower")
1025
  following = data.get("following")
1026
+ status = await asyncio.to_thread(core.get_follow_status, follower, following)
1027
  return {"status": "success", "follow_status": status}
1028
 
1029
 
 
1033
  follower = data.get("follower")
1034
  if not username or not follower:
1035
  return {"status": "fail", "message": "Missing parameters"}
1036
+ success = await asyncio.to_thread(core.unfollow_user, follower, username)
1037
  if success:
1038
  # Send notification only to the user who was removed (follower)
1039
  await coreHelper.send_follow_notification(
 
1115
  following = data.get("following")
1116
  if not follower or not following:
1117
  return {"status": "fail"}
1118
+ followers = await asyncio.to_thread(core.get_following, follower)
1119
  return {"status": "success", "isFollowing": following in followers}
1120
 
1121
  @app.post("/api/user/block")
 
1131
  return JSONResponse({"status": "fail", "message": "Cannot block yourself"}, status_code=400)
1132
 
1133
 
1134
+ success = await asyncio.to_thread(core.block_user, blocker, blocked)
1135
 
1136
  if success:
1137
  # Broadcast block update
 
1164
  return JSONResponse({"status": "fail", "message": "Missing parameters"}, status_code=400)
1165
 
1166
 
1167
+ success = await asyncio.to_thread(core.unblock_user, blocker, blocked)
1168
 
1169
  if success:
1170
  # Broadcast unblock update
 
1196
  if not blocker or not blocked:
1197
  return {"status": "fail", "message": "Missing parameters"}
1198
 
1199
+ is_blocked = await asyncio.to_thread(core.is_blocked, blocker, blocked)
1200
  return {"status": "success", "isBlocked": is_blocked}
1201
 
1202
  @app.post("/api/user/getBlockedUsers")
 
1206
  if not username:
1207
  return {"status": "fail", "message": "Username required"}
1208
 
1209
+ blocked_users = await asyncio.to_thread(core.get_blocked_users, username)
1210
  blocked_list = []
1211
 
1212
  for user in blocked_users:
1213
+ avatar = await asyncio.to_thread(core.get_avatar, user)
1214
  if avatar:
1215
  if not avatar.startswith("/static"):
1216
  avatar = "/static/" + avatar.replace("../../frontend/", "")
 
1242
  if not name:
1243
  return {"error": "Group name required"}
1244
  is_group = True
1245
+ user_convs = await asyncio.to_thread(core.get_user_conversations, creator)
1246
  for c in user_convs:
1247
  if isinstance(c, dict):
1248
  conv_id = c.get("id")
 
1253
  conv_name = parts[1] if len(parts) > 1 else ""
1254
  if not conv_id:
1255
  continue
1256
+ conv_users = await asyncio.to_thread(core.get_conversation_users, int(conv_id))
1257
  conv_users_sorted = sorted([u.strip() for u in conv_users if u])
1258
  users_sorted = sorted([u.strip() for u in users if u])
1259
  if conv_users_sorted == users_sorted and conv_name == name:
1260
  return {"conversationId": conv_id}
1261
+ conv_id = await asyncio.to_thread(core.create_conversation, users, is_group, name, admin_username=creator)
1262
  payload = {"type": "conversation_created", "conversationId": conv_id}
1263
  for u in users:
1264
+ if u.lower() in connections:
1265
+ for ws in list(connections[u.lower()]):
1266
  try:
1267
  await ws.send_json(payload)
1268
  except Exception:
1269
+ connections[u.lower()].discard(ws)
1270
  return {"conversationId": conv_id}
1271
 
1272
 
 
1280
 
1281
  added_any = False
1282
  for u_name in usernames:
1283
+ if u_name and await asyncio.to_thread(core.add_user_to_conversation, conv_id, u_name):
1284
  added_any = True
1285
 
1286
  if added_any:
1287
  # 4. Invalidate backend cache for all members
1288
+ members = await asyncio.to_thread(core.get_conversation_users, conv_id)
1289
  invalidate_conv_cache_for_all(members)
1290
 
1291
  # 5. Broadcast update to all members
1292
  payload = {"type": "conversation_updated", "conversationId": conv_id}
1293
  for u in members:
1294
+ if u.lower() in connections:
1295
+ for ws in list(connections[u.lower()]):
1296
  try:
1297
  await ws.send_json(payload)
1298
  except Exception:
1299
+ connections[u.lower()].discard(ws)
1300
  return {"status": "success"}
1301
  return {"status": "fail"}
1302
 
 
1314
 
1315
  for target in usernames:
1316
  # 1. Find or Create direct chat
1317
+ conv_id = await asyncio.to_thread(core.find_conversation, [sender, target], "")
1318
  if conv_id == -1:
1319
+ conv_id = await asyncio.to_thread(core.create_conversation, [sender, target], False, "", admin_username=sender)
1320
 
1321
  # 2. Save message as type 'post'
1322
+ msg_id = await asyncio.to_thread(core.send_message, sender, conv_id, str(post_id), "post")
1323
 
1324
  # 3. Broadcast to both participants
1325
  invalidate_conv_cache(sender)
 
1350
  async def remove_chat_member(data: dict):
1351
  conv_id = int(data.get("conversationId"))
1352
  username = data.get("username")
1353
+ members_before = await asyncio.to_thread(core.get_conversation_users, conv_id)
1354
+ if await asyncio.to_thread(core.remove_user_from_conversation, conv_id, username):
1355
  # Invalidate backend cache for everyone who was in the group
1356
  invalidate_conv_cache_for_all(members_before)
1357
 
1358
  # Broadcast update to remaining members and the removed member
1359
  payload = {"type": "conversation_updated", "conversationId": conv_id}
1360
  for u in set(members_before):
1361
+ if u.lower() in connections:
1362
+ for ws in list(connections[u.lower()]):
1363
  try:
1364
  await ws.send_json(payload)
1365
  except Exception:
1366
+ connections[u.lower()].discard(ws)
1367
  return {"status": "success"}
1368
  return {"status": "fail"}
1369
 
1370
  @app.delete("/api/deleteConversation/{username}/{convId}")
1371
  async def delete_conversation(username: str, convId: int):
1372
  try:
1373
+ members = await asyncio.to_thread(core.get_conversation_users, convId)
1374
+ convs = await asyncio.to_thread(core.get_user_conversations, username)
1375
  is_group = False
1376
  for c in convs:
1377
  if isinstance(c, dict):
 
1383
  if parts[0] == str(convId):
1384
  is_group = parts[2] == "1"
1385
  break
1386
+ await asyncio.to_thread(core.delete_conversation, convId)
1387
  payload = {"type": "conversation_deleted", "conversationId": convId}
1388
  for u in members:
1389
+ if u.lower() in connections:
1390
+ for ws in list(connections[u.lower()]):
1391
  try:
1392
  await ws.send_json(payload)
1393
  except Exception:
1394
+ connections[u.lower()].discard(ws)
1395
  return {"status": "deleted", "type": "group" if is_group else "direct"}
1396
  except Exception as e:
1397
  return {"error": str(e)}
 
1404
  avatar = data["avatar"]
1405
  if avatar and avatar.startswith("data:"):
1406
  avatar = upload_media_to_supabase(avatar)
1407
+ await asyncio.to_thread(core.update_conversation_avatar, conv_id, avatar)
1408
+ users = await asyncio.to_thread(core.get_conversation_users, conv_id)
1409
  payload = {
1410
  "type": "conversation_avatar_update",
1411
  "conversationId": conv_id,
 
1413
  }
1414
  tasks = []
1415
  for u in users:
1416
+ if u.lower() in connections:
1417
+ for ws in list(connections[u.lower()]):
1418
+ tasks.append(send_safe(ws, payload, connections[u.lower()]))
1419
  await asyncio.gather(*tasks, return_exceptions=True)
1420
  return {"status": "ok"}
1421
  except Exception as e:
 
1491
  {"status": "fail", "message": "Missing parameters"}, status_code=400
1492
  )
1493
 
1494
+ success = await asyncio.to_thread(core.delete_post, post_id, username)
1495
 
1496
  if success:
1497
  # Broadcast post deletion
 
1509
 
1510
 
1511
  @app.get("/api/post/feed")
1512
+ def get_feed_get(
1513
+ username: Optional[str] = None,
1514
+ viewer: Optional[str] = None,
1515
+ limit: Optional[int] = None,
1516
+ offset: Optional[int] = None,
1517
+ ):
1518
  try:
1519
  current_user = viewer or username or ""
1520
  feed = core.get_feed(current_user)
1521
+
1522
  # Performance optimization: pre-fetch block list to avoid N+1 queries
1523
  blocked_list = set()
1524
  if current_user:
1525
  blocked_list = set(core.get_blocked_users(current_user) + core.get_blockers(current_user))
1526
+
1527
  filtered_feed = []
1528
  for post in feed:
1529
  post_user = post.get("username", "")
 
1531
  if post_user in blocked_list:
1532
  continue
1533
  filtered_feed.append(post)
1534
+
1535
+ lim = max(1, min(int(limit), 500)) if limit is not None else None
1536
+ off = max(0, int(offset)) if offset is not None else (0 if lim is not None else None)
1537
+ return build_feed_response(filtered_feed, limit=lim, offset=off)
1538
  except Exception as e:
1539
  return JSONResponse({"status": "fail", "message": str(e)}, status_code=500)
1540
 
 
1543
  try:
1544
  current_user = data.viewer or data.username or ""
1545
  feed = core.get_feed(current_user)
1546
+ return build_feed_response(feed, limit=data.limit, offset=data.offset)
1547
  except Exception as e:
1548
  return JSONResponse({"status": "fail", "message": str(e)}, status_code=500)
1549
 
 
1556
 
1557
  if viewer and viewer != username:
1558
  if core.is_blocked_either_way(viewer, username):
1559
+ return build_user_posts_response([], blocked=True)
1560
 
1561
  posts = core.get_user_posts(username, viewer)
1562
+ return build_user_posts_response(posts)
1563
  except Exception as e:
1564
 
1565
  return JSONResponse({"status": "fail", "message": str(e)}, status_code=500)
 
1574
  return JSONResponse(
1575
  {"status": "fail", "message": "Missing parameters"}, status_code=400
1576
  )
1577
+ success = await asyncio.to_thread(core.add_comment, post_id, username, content)
1578
  if success:
1579
  # Get the newly added comment (get_comments returns newest first with desc=True)
1580
+ comments = await asyncio.to_thread(core.get_comments, post_id)
1581
  new_comment = comments[0] if comments else None
1582
  # Get updated comments count
1583
  rows = get_client().table("posts").select("comments_count").eq("id", post_id).execute()
 
1626
  "type": "station_created",
1627
  "stationName": station_name,
1628
  "admin": admin_username,
1629
+ "bio": bio,
1630
  }
1631
  # Broadcast to all
1632
  asyncio.create_task(coreHelper.broadcast_to_all(payload))
 
1671
  "stationName": station_name,
1672
  "user": user_to_add,
1673
  }
1674
+ asyncio.create_task(notify_station_members_ws(station_name, payload))
1675
+ asyncio.create_task(touch_station_catalog_ws(station_name))
1676
  return {"status": "success"}
1677
  return {"status": "fail"}
1678
 
 
1690
 
1691
  success = await asyncio.to_thread(core.remove_user_from_station, station_name, admin_username, user_to_remove)
1692
  if success:
1693
+ asyncio.create_task(
1694
+ notify_station_members_ws(
1695
+ station_name,
1696
+ {"type": "station_user_removed", "stationName": station_name, "user": user_to_remove},
1697
+ )
1698
+ )
1699
+ asyncio.create_task(touch_station_catalog_ws(station_name))
1700
  return {"status": "success"}
1701
  return {"status": "fail"}
1702
 
 
1711
  )
1712
  success = await asyncio.to_thread(core.request_to_join, station_name, username)
1713
  if success:
 
1714
  stations = await asyncio.to_thread(core.get_station, station_name)
1715
  if stations:
1716
  admin = stations[0].get("admin", "")
 
1719
  "stationName": station_name,
1720
  "user": username,
1721
  }
1722
+ await coreHelper.send_to_user(admin, payload, connections)
1723
+ await touch_station_catalog_ws(station_name)
 
 
 
 
 
 
1724
  return {"status": "success" if success else "fail"}
1725
 
1726
 
 
1747
  )
1748
  success = await asyncio.to_thread(core.approve_request, station_name, admin_username, user_to_approve)
1749
  if success:
1750
+ asyncio.create_task(
1751
+ notify_station_members_ws(
1752
+ station_name,
1753
+ {"type": "station_user_approved", "stationName": station_name, "user": user_to_approve},
1754
+ )
1755
+ )
1756
+ asyncio.create_task(
1757
+ notify_station_members_ws(
1758
+ station_name, {"type": "station_updated", "stationName": station_name}
1759
+ )
1760
+ )
1761
+ asyncio.create_task(touch_station_catalog_ws(station_name))
1762
+
1763
  return {"status": "success" if success else "fail"}
1764
 
1765
 
 
1772
  return JSONResponse(
1773
  {"status": "fail", "message": "Missing parameters"}, status_code=400
1774
  )
1775
+ success = await asyncio.to_thread(core.reject_request, station_name, admin_username, user_to_reject)
1776
  if success:
1777
+ rej_payload = {
1778
  "type": "station_request_rejected",
1779
  "stationName": station_name,
1780
  "user": user_to_reject,
1781
  }
1782
+ await coreHelper.send_to_user(user_to_reject, rej_payload, connections)
1783
+ await coreHelper.send_to_user(
1784
+ admin_username,
1785
+ {
1786
+ "type": "station_request_closed",
1787
+ "stationName": station_name,
1788
+ "user": user_to_reject,
1789
+ "rejected": True,
1790
+ },
1791
+ connections,
1792
+ )
1793
+ asyncio.create_task(touch_station_catalog_ws(station_name))
1794
  return {"status": "success" if success else "fail"}
1795
 
1796
 
 
1802
  return JSONResponse(
1803
  {"status": "fail", "message": "Missing parameters"}, status_code=400
1804
  )
1805
+ requests = await asyncio.to_thread(core.get_pending_requests, station_name, admin_username)
1806
  return {"status": "success", "requests": requests}
1807
 
1808
 
 
1816
  return JSONResponse(
1817
  {"status": "fail", "message": "Missing parameters"}, status_code=400
1818
  )
1819
+ new_post = await asyncio.to_thread(core.create_station_post, station_name, username, content, media_path)
1820
  if new_post:
1821
  payload = {
1822
  "type": "station_post_created",
1823
  "stationName": station_name,
1824
  "post": new_post,
1825
  }
1826
+ await broadcast_station_post_ws(payload)
 
 
 
 
 
1827
  return {"status": "success", "post": new_post}
1828
  return {"status": "fail"}
1829
 
 
1840
  return JSONResponse(
1841
  {"status": "fail", "message": "Missing parameters"}, status_code=400
1842
  )
1843
+ success = await asyncio.to_thread(core.delete_station_post, post_id, station_name, username)
1844
  if success:
1845
  payload = {
1846
  "type": "station_post_deleted",
1847
  "postId": post_id,
1848
  "stationName": station_name,
1849
  }
1850
+ await broadcast_station_post_ws(payload)
 
 
 
 
 
1851
  return {"status": "success"}
1852
  return {"status": "fail"}
1853
 
 
1861
  {"status": "fail", "message": "Station name required"}, status_code=400
1862
  )
1863
  try:
1864
+ posts = await asyncio.to_thread(core.get_station_posts, station_name, viewer)
1865
+ return build_user_posts_response(posts)
1866
  except Exception as e:
1867
  return JSONResponse({"status": "fail", "message": str(e)}, status_code=500)
1868
 
 
1875
  return JSONResponse(
1876
  {"status": "fail", "message": "Station name required"}, status_code=400
1877
  )
1878
+ stations = await asyncio.to_thread(core.get_station, station_name, viewer)
1879
+ raw = stations[0] if stations else None
1880
+ return build_station_get_response(raw)
1881
 
1882
 
1883
 
 
1889
  return {"status": "success", "posts": []}
1890
  try:
1891
  posts = await asyncio.to_thread(core.get_bulk_station_posts, station_names, viewer)
1892
+ return build_user_posts_response(posts)
1893
  except Exception as e:
1894
  return JSONResponse({"status": "fail", "message": str(e)}, status_code=500)
1895
 
 
1915
  return JSONResponse(
1916
  {"status": "fail", "message": "Station name required"}, status_code=400
1917
  )
1918
+ members = await asyncio.to_thread(core.get_station_members, station_name, viewer)
1919
  return {"status": "success", "members": members}
1920
 
1921
 
 
1931
  success = core.update_station_bio(station_name, bio)
1932
  if success:
1933
  payload = {"type": "station_bio_updated", "stationName": station_name, "bio": bio}
1934
+ asyncio.create_task(notify_station_members_ws(station_name, payload))
1935
+ asyncio.create_task(touch_station_catalog_ws(station_name))
1936
  return {"status": "success" if success else "fail"}
1937
  except Exception as e:
1938
 
 
1950
  success = core.update_station_hero_banner(station_name, hero_banner)
1951
  if success:
1952
  payload = {"type": "station_hero_banner_updated", "stationName": station_name, "heroBanner": hero_banner}
1953
+ asyncio.create_task(notify_station_members_ws(station_name, payload))
1954
+ asyncio.create_task(touch_station_catalog_ws(station_name))
1955
  return {"status": "success" if success else "fail"}
1956
  except Exception as e:
1957
 
 
1969
  success = core.update_station_profile_pic(station_name, profile_pic)
1970
  if success:
1971
  payload = {"type": "station_profile_pic_updated", "stationName": station_name, "profilePic": profile_pic}
1972
+ asyncio.create_task(notify_station_members_ws(station_name, payload))
1973
+ asyncio.create_task(touch_station_catalog_ws(station_name))
1974
  return {"status": "success" if success else "fail"}
1975
  except Exception as e:
1976
 
 
1985
  if not old_name or not new_name:
1986
  return JSONResponse({"status": "fail", "message": "Missing parameters"}, status_code=400)
1987
 
1988
+ members_snapshot = core.get_station_members(old_name, "")
1989
  success = core.update_station_name(old_name, new_name)
1990
  if success:
1991
  payload = {"type": "station_name_updated", "oldName": old_name, "newName": new_name}
1992
+
1993
+ async def _fanout_rename():
1994
+ seen: set[str] = set()
1995
+ for u in members_snapshot or []:
1996
+ if not u:
1997
+ continue
1998
+ lk = str(u).lower()
1999
+ if lk in seen:
2000
+ continue
2001
+ seen.add(lk)
2002
+ await coreHelper.send_to_user(u, payload, connections)
2003
+ await touch_station_catalog_ws(new_name)
2004
+ await touch_station_catalog_ws(old_name)
2005
+
2006
+ asyncio.create_task(_fanout_rename())
2007
  return {"status": "success" if success else "fail"}
2008
  except Exception as e:
2009
 
 
2017
  return JSONResponse(
2018
  {"status": "fail", "message": "Station name required"}, status_code=400
2019
  )
2020
+ bio = await asyncio.to_thread(core.get_station_bio, station_name)
2021
  return {"status": "success", "bio": bio}
2022
 
2023
 
 
2028
  return JSONResponse(
2029
  {"status": "fail", "message": "Station name required"}, status_code=400
2030
  )
2031
+ hero_banner = await asyncio.to_thread(core.get_station_hero_banner, station_name)
2032
  return {"status": "success", "heroBanner": hero_banner}
2033
 
2034
 
 
2039
  return JSONResponse(
2040
  {"status": "fail", "message": "Station name required"}, status_code=400
2041
  )
2042
+ profile_pic = await asyncio.to_thread(core.get_station_profile_pic, station_name)
2043
  return {"status": "success", "profilePic": profile_pic}
2044
 
2045
 
 
2050
  return JSONResponse(
2051
  {"status": "fail", "message": "Station name required"}, status_code=400
2052
  )
2053
+ admin = await asyncio.to_thread(core.get_station_admin, station_name)
2054
  return {"status": "success", "admin": admin}
2055
 
2056
 
 
2076
  try:
2077
  post_id = int(post_id)
2078
 
2079
+ # If already liked, we still want to return success to keep UI in sync
2080
+ success = await asyncio.to_thread(core.like_post, post_id, username)
2081
+ if success:
2082
+ likes = await asyncio.to_thread(core.modify_likes, post_id, +1)
2083
+ else:
2084
+ likes = await asyncio.to_thread(core.get_likes_count, post_id)
 
 
 
 
2085
 
2086
  # Always broadcast the latest count
2087
  asyncio.create_task(coreHelper.broadcast_like_update(post_id, likes))
 
2104
  try:
2105
  post_id = int(post_id)
2106
 
2107
+ # Even if already unliked, return success to prevent UI from reverting
2108
+ success = await asyncio.to_thread(core.unlike_post, post_id, username)
2109
+ if success:
2110
+ likes = await asyncio.to_thread(core.modify_likes, post_id, -1)
2111
+ else:
2112
+ likes = await asyncio.to_thread(core.get_likes_count, post_id)
 
 
 
 
2113
 
2114
  asyncio.create_task(coreHelper.broadcast_like_update(post_id, likes))
2115
  return {"status": "success", "likes": likes}
 
2143
  coreHelper.unregister_ws(username, ws, connections)
2144
 
2145
 
2146
+ # --- BitAI inline chat (@bitai in any thread + Groq) -------------------
 
 
 
2147
 
2148
 
2149
+ def _is_bitai_direct_dm(convo_id: int) -> bool:
2150
+ """Exactly two participants: user + BitAI (private AI thread)."""
2151
+ users = core.get_conversation_users(convo_id)
2152
+ if len(users) != 2:
2153
+ return False
2154
+ return "BitAI" in users
2155
 
 
 
2156
 
2157
+ def _message_mentions_bitai(content: str) -> bool:
2158
+ if not content or not isinstance(content, str):
2159
+ return False
2160
+ return re.search(r"@bitai\b", content, re.IGNORECASE) is not None
2161
 
 
2162
 
2163
+ def _should_run_bitai_in_conversation(convo_id: int, sender: str, content: str, msg_type: str) -> bool:
2164
+ if msg_type != "text" or sender == "BitAI":
2165
+ return False
2166
+ if not content or not str(content).strip():
2167
+ return False
2168
+ if _is_bitai_direct_dm(convo_id):
2169
+ return True
2170
+ return _message_mentions_bitai(content)
2171
 
 
2172
 
2173
+ def _bitai_multi_audience(convo_id: int) -> bool:
2174
+ """Reply visible to a group or two humans (not a solo BitAI DM)."""
2175
+ users = set(core.get_conversation_users(convo_id))
2176
+ if len(users) == 2 and "BitAI" in users:
2177
+ return False
2178
+ return len(users) >= 2
2179
 
2180
+
2181
+ def _thread_to_llm_history(convo_id: int) -> list:
2182
+ thread = core.get_messages(convo_id)
2183
+ out = []
2184
+ for m in thread:
2185
+ if m.get("type") != "text":
2186
+ continue
2187
+ text = m.get("content", "") or ""
2188
+ if m.get("sender") == "BitAI":
2189
+ out.append({"role": "assistant", "content": text})
2190
+ else:
2191
+ out.append({"role": "user", "content": text})
2192
+ return out
2193
+
2194
+
2195
+ def _prior_llm_history_for_latest(convo_id: int) -> list:
2196
+ full = _thread_to_llm_history(convo_id)
2197
+ if not full:
2198
+ return []
2199
+ return full[:-1][-8:]
2200
+
2201
+
2202
+ async def groq_bitai_completion(
2203
+ acting_username: str,
2204
+ latest_user_text: str,
2205
+ prior_history: list,
2206
+ convo_id: int,
2207
+ ) -> tuple:
2208
+ """
2209
+ prior_history: {role, content} turns before the latest user message.
2210
+ Returns ("success"|"error", response_string).
2211
+ """
2212
+ import httpx
2213
+
2214
+ api_key = os.environ.get("GROQ_API_KEY", "")
2215
+ if not api_key:
2216
+ return ("error", "Me brain dead. You set GROQ_API_KEY in .env")
2217
+
2218
+ group_note = ""
2219
+ if _bitai_multi_audience(convo_id):
2220
+ group_note = "\n\nYour message is shown to everyone in this chat. Keep it short. Someone used @bitai to ask you—answer them helpfully."
2221
+
2222
+ system_prompt = f"""You are BitAI. Your name is BitAI. You are a friendly AI assistant for YapStation - a cyberpunk social media platform.
2223
+ The user who asked is named {acting_username}. Call them by their name when it fits.
2224
  YapStation features: chat with friends, yap stations (podcasts), posts, followers, themes.
2225
  You help users with: chatting, advice, answering questions about the platform, fun conversation.
2226
+ {group_note}
2227
 
2228
  You speak CAVE MAN style. Short. Baby words. Mostly just pronouns and verbs. Friendly. Like speaking to a buddy.
2229
  Examples: "Hey {{name}}! Me here. Whats up? Me help you?"
 
2231
  Or: "Cool cool! Me think you good. Go for it!"
2232
  Or: "No stress! Me got you. Tell me more."
2233
 
2234
+ Sometimes use the user's name ({acting_username}) in your response when possible. Be casual, helpful, fun."""
2235
 
2236
+ messages = [{"role": "system", "content": system_prompt}]
2237
+ for msg in prior_history[-8:]:
2238
+ role = "user" if msg.get("role") == "user" else "assistant"
2239
+ messages.append({"role": role, "content": msg.get("content", "")})
2240
+ messages.append({"role": "user", "content": latest_user_text})
2241
 
2242
+ try:
2243
  async with httpx.AsyncClient(timeout=30.0) as client:
2244
  response = await client.post(
2245
  "https://api.groq.com/openai/v1/chat/completions",
 
2259
  result = response.json()
2260
  ai_response = result.get("choices", [{}])[0].get("message", {}).get("content", "")
2261
  if ai_response:
2262
+ return ("success", ai_response)
2263
+ return ("error", "Me no think. Try again.")
2264
+ return ("error", f"Me hurt. Error: {response.status_code}")
2265
+ except Exception:
2266
+ return ("error", "Me broken. Fix me.")
2267
+
2268
+
2269
+ async def broadcast_chat_participants(
2270
+ convo_id: int,
2271
+ sender: str,
2272
+ msg_type: str,
2273
+ content: str,
2274
+ media: str,
2275
+ reply_to,
2276
+ connections: dict,
2277
+ ):
2278
+ """Invalidate conv caches and push message (+ notifications) to conversation members."""
2279
+ users = await asyncio.to_thread(core.get_conversation_users, convo_id)
2280
+ for u in users:
2281
+ _conv_cache.pop(u, None)
2282
+ users = await asyncio.to_thread(core.get_conversation_users, convo_id)
2283
+ payload = {
2284
+ "conversationId": convo_id,
2285
+ "sender": sender,
2286
+ "type": msg_type,
2287
+ "content": content,
2288
+ "media": media or "",
2289
+ "time": int(time.time()),
2290
+ "id": f"{convo_id}_{sender}_{int(time.time() * 1000)}",
2291
+ "replyTo": reply_to,
2292
+ }
2293
+ for u in users:
2294
+ if u.lower() not in connections:
2295
+ continue
2296
+ for conn in list(connections[u.lower()]):
2297
+ try:
2298
+ await conn.send_json(payload)
2299
+ if u != sender:
2300
+ await conn.send_json(
2301
+ {
2302
+ "type": "notification",
2303
+ "sender": sender,
2304
+ "content": content,
2305
+ "conversationId": convo_id,
2306
+ }
2307
+ )
2308
+ except Exception:
2309
+ connections[u.lower()].discard(conn)
2310
+
2311
+
2312
+ async def _bitai_respond_in_conversation(convo_id: int, sender: str, content: str, connections: dict):
2313
+ """Groq reply posted into this conversation and broadcast (like WhatsApp @bot)."""
2314
+ prior = _prior_llm_history_for_latest(convo_id)
2315
+ status, response_text = await groq_bitai_completion(sender, content, prior, convo_id)
2316
+ if status != "success" or not response_text:
2317
+ response_text = response_text or "Me no think. Try again."
2318
+ try:
2319
+ await asyncio.to_thread(core.send_message, "BitAI", convo_id, response_text, "text", "")
2320
+ await broadcast_chat_participants(
2321
+ convo_id, "BitAI", "text", response_text, "", None, connections
2322
+ )
2323
+ except Exception:
2324
+ pass
2325
+
2326
+
2327
+ @app.get("/api/bitai/conversation/{username}")
2328
+ def get_bitai_conversation(username: str):
2329
+ conv_id = core.get_or_create_bitai_conversation(username)
2330
+ return {"conversationId": conv_id, "status": "success" if conv_id > 0 else "error"}
2331
 
2332
+
2333
+ @app.post("/api/bitai/chat")
2334
+ async def bitai_chat(data: dict):
2335
+ message = data.get("message", "")
2336
+ user = data.get("username", "")
2337
+ history = data.get("history", [])
2338
+
2339
+ if not message or not user:
2340
+ return {"status": "error", "message": "Missing parameters"}
2341
+
2342
+ conv_id = await asyncio.to_thread(core.get_or_create_bitai_conversation, user)
2343
+ if conv_id <= 0:
2344
+ return {"status": "error", "message": "Could not create conversation"}
2345
+
2346
+ await asyncio.to_thread(core.send_message, user, conv_id, message, "text", "")
2347
+
2348
+ prior = []
2349
+ for msg in history[-8:]:
2350
+ prior.append(
2351
+ {
2352
+ "role": "user" if msg.get("role") == "user" else "assistant",
2353
+ "content": msg.get("content", ""),
2354
+ }
2355
+ )
2356
+
2357
+ status, response_text = await groq_bitai_completion(user, message, prior, conv_id)
2358
+
2359
+ if status == "success" and response_text:
2360
+ await asyncio.to_thread(core.send_message, "BitAI", conv_id, response_text, "text", "")
2361
+ await broadcast_chat_participants(
2362
+ conv_id, "BitAI", "text", response_text, "", None, connections
2363
+ )
2364
+ return {"status": "success", "response": response_text}
2365
+
2366
+ return {"status": "error", "response": response_text or "Me broken. Fix me."}
2367
 
2368
 
2369
  @app.websocket("/ws/{username}")
 
2478
  convo_id = int(convo_id)
2479
  except Exception:
2480
  continue
2481
+ users = await asyncio.to_thread(core.get_conversation_users, convo_id)
2482
  payload = {
2483
  "type": msg_type,
2484
  "sender": sender,
2485
  "conversationId": convo_id,
2486
  }
2487
  for u in users:
2488
+ ukey = u.lower()
2489
+ if u.lower() == sender.lower() or ukey not in connections:
2490
  continue
2491
+ for conn in list(connections[ukey]):
2492
  try:
2493
  await conn.send_json(payload)
2494
  except Exception:
2495
+ connections[ukey].discard(conn)
2496
  continue
2497
  convo_id = data.get("conversationId")
2498
  sender = data.get("sender")
 
2509
  continue
2510
 
2511
  # Blocking Check
2512
+ users = await asyncio.to_thread(core.get_conversation_users, convo_id)
2513
  is_blocked = False
2514
  for u in users:
2515
+ if u != sender and await asyncio.to_thread(core.is_blocked_either_way, sender, u):
2516
  is_blocked = True
2517
  break
2518
 
 
2521
  continue
2522
 
2523
  try:
2524
+ await asyncio.to_thread(core.send_message, sender, convo_id, content, msg_type, media, reply_to)
2525
  except Exception:
2526
  continue
2527
+ await broadcast_chat_participants(
2528
+ convo_id, sender, msg_type, content, media, reply_to, connections
2529
+ )
2530
+ if msg_type == "text" and _should_run_bitai_in_conversation(
2531
+ convo_id, sender, content, msg_type
2532
+ ):
2533
+ asyncio.create_task(
2534
+ _bitai_respond_in_conversation(convo_id, sender, content, connections)
2535
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2536
  except Exception:
2537
  pass
2538
  finally:
smoke_test.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Quick check that the FastAPI backend is up (default http://127.0.0.1:7860/).
3
+ Uses stdlib only — no extra packages.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import json
9
+ import sys
10
+ import urllib.error
11
+ import urllib.request
12
+
13
+
14
+ def main() -> int:
15
+ parser = argparse.ArgumentParser(description="Verify YapStation FastAPI is responding.")
16
+ parser.add_argument(
17
+ "--url",
18
+ default="",
19
+ help="Full base URL (overrides --host/--port). Example: http://127.0.0.1:7860/",
20
+ )
21
+ parser.add_argument("--host", default="127.0.0.1", help="Bind host of the API server")
22
+ parser.add_argument("--port", type=int, default=7860, help="Port (default 7860)")
23
+ args = parser.parse_args()
24
+
25
+ url = args.url.strip()
26
+ if not url:
27
+ url = f"http://{args.host}:{args.port}/"
28
+
29
+ req = urllib.request.Request(url, method="GET", headers={"Accept": "application/json"})
30
+ try:
31
+ with urllib.request.urlopen(req, timeout=8) as resp:
32
+ raw = resp.read().decode()
33
+ code = resp.status
34
+ except urllib.error.HTTPError as e:
35
+ print(f"FAIL: HTTP {e.code} from {url}")
36
+ return 1
37
+ except urllib.error.URLError as e:
38
+ print(f"FAIL: Could not reach {url}")
39
+ print(f" ({e.reason})")
40
+ print(" Start the API with run-backend.bat or run-yapstation.bat.")
41
+ return 1
42
+ except Exception as e:
43
+ print(f"FAIL: {e}")
44
+ return 1
45
+
46
+ try:
47
+ data = json.loads(raw)
48
+ except json.JSONDecodeError:
49
+ print(f"FAIL: Non-JSON response (HTTP {code}) from {url}")
50
+ print(raw[:500])
51
+ return 1
52
+
53
+ if data.get("status") == "success":
54
+ msg = data.get("message", "")
55
+ print(f"OK: Backend is running ({code}) — {msg}")
56
+ print(f" URL: {url}")
57
+ return 0
58
+
59
+ print(f"FAIL: Unexpected JSON from {url}: {data!r}")
60
+ return 1
61
+
62
+
63
+ if __name__ == "__main__":
64
+ sys.exit(main())
stack_test.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Verify FastAPI (:7860) and the Next.js -> API proxy (:3000/api/*) together.
3
+ Stdlib only.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import json
9
+ import sys
10
+ import urllib.error
11
+ import urllib.request
12
+
13
+
14
+ def fetch_json(url: str, timeout: float = 8.0) -> tuple[int, dict]:
15
+ req = urllib.request.Request(url, headers={"Accept": "application/json"})
16
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
17
+ raw = resp.read().decode()
18
+ code = resp.status
19
+ return code, json.loads(raw)
20
+
21
+
22
+ def main() -> int:
23
+ p = argparse.ArgumentParser(description="Test backend + Next.js API proxy")
24
+ p.add_argument("--api-port", type=int, default=7860)
25
+ p.add_argument("--next-port", type=int, default=3000)
26
+ args = p.parse_args()
27
+
28
+ api_root = f"http://127.0.0.1:{args.api_port}/"
29
+ next_me = f"http://127.0.0.1:{args.next_port}/api/me"
30
+
31
+ ok = True
32
+
33
+ # 1) Direct FastAPI
34
+ try:
35
+ code, data = fetch_json(api_root)
36
+ if data.get("status") == "success":
37
+ print(f"OK: FastAPI direct GET / -> {code} {data.get('message', '')[:60]}")
38
+ else:
39
+ print(f"FAIL: FastAPI root JSON unexpected: {data!r}")
40
+ ok = False
41
+ except Exception as e:
42
+ print(f"FAIL: FastAPI not reachable at {api_root} ({e})")
43
+ ok = False
44
+
45
+ # 2) Through Next (same path the browser uses: /api/me)
46
+ try:
47
+ code, data = fetch_json(next_me)
48
+ if code == 200 and isinstance(data, dict) and "status" in data:
49
+ print(
50
+ f"OK: Next.js proxy GET /api/me -> {code} status={data.get('status')!r}"
51
+ )
52
+ else:
53
+ print(f"FAIL: Next /api/me unexpected: HTTP {code} {data!r}")
54
+ ok = False
55
+ except Exception as e:
56
+ print(f"FAIL: Next.js not reachable or proxy error at {next_me}")
57
+ print(f" ({e})")
58
+ print(" Start frontend: run-frontend.bat or run-yapstation.bat")
59
+ ok = False
60
+
61
+ if ok:
62
+ print()
63
+ print("Stack check passed: browser calls to /api/* on :3000 reach FastAPI on :7860.")
64
+ return 0
65
+ return 1
66
+
67
+
68
+ if __name__ == "__main__":
69
+ sys.exit(main())