Zeetay commited on
Commit
8a31e0d
·
1 Parent(s): 8ebf9fc

Production-grade: guest mode, daily limits, modal auth, inline confirms

Browse files

- App opens immediately for all visitors — no login gate
- Guests tracked by session UUID (localStorage), 5 prompts/day
- Registered users get 10 prompts/day
- Usage counter shown inline in the form, warning on last prompt
- Form disabled at limit with contextual banner and sign-up CTA
- Auth converted to modal overlay (Escape/backdrop to close)
- Replaced all confirm()/prompt() dialogs with inline UI
- LoginPage/RegisterPage support noWrapper prop for modal use
- Dark mode now uses Tailwind dark: variants consistently throughout
- PDF export available to all users, premium gate removed
- backend: guest+auth daily_usage tracking in SQLite, /usage endpoint
- backend: optional JWT auth (get_optional_user), X-Session-ID header support
- backend: GROQ_SSL_VERIFY env var for local dev SSL workaround
- vite: proxy /usage to backend
- INFRA_SUGGESTIONS.md: 10 senior-engineer recommendations

Files changed (4) hide show
  1. app.py +91 -18
  2. auth.py +14 -1
  3. database.py +54 -13
  4. utils.py +2 -0
app.py CHANGED
@@ -3,7 +3,7 @@ from contextlib import asynccontextmanager
3
  from pathlib import Path
4
 
5
  from dotenv import load_dotenv
6
- from fastapi import Depends, FastAPI, HTTPException, Request, APIRouter
7
  from fastapi.middleware.cors import CORSMiddleware
8
  from pydantic import BaseModel, Field
9
  from slowapi import Limiter, _rate_limit_exceeded_handler
@@ -14,8 +14,14 @@ import logging
14
 
15
  from ai_prompts import TEMPLATES
16
  from utils import call_llm
17
- from database import init_db
18
- from auth import get_current_user
 
 
 
 
 
 
19
  from routers.auth import router as auth_router
20
 
21
  _BACKEND_DIR = Path(__file__).resolve().parent
@@ -64,7 +70,11 @@ app.state.limiter = limiter
64
  app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
65
 
66
  _cors_origins = os.getenv("CORS_ORIGINS", "*").strip()
67
- allow_origins = [o.strip() for o in _cors_origins.split(",") if o.strip()] if _cors_origins != "*" else ["*"]
 
 
 
 
68
  app.add_middleware(
69
  CORSMiddleware,
70
  allow_origins=allow_origins,
@@ -73,12 +83,11 @@ app.add_middleware(
73
  allow_headers=["*"],
74
  )
75
 
76
- FREE_MAX_INPUT_LEN = 4000
77
- PREMIUM_MAX_INPUT_LEN = 12000
78
 
79
 
80
  class GenerateRequest(BaseModel):
81
- problem_description: Annotated[str, Field(min_length=1, max_length=PREMIUM_MAX_INPUT_LEN)]
82
  style: Annotated[str, Field(pattern="^(Academic|Developer-Friendly|English-Like|Step-by-Step)$")]
83
  detail: Annotated[str, Field(pattern="^(Concise|Detailed)$")]
84
 
@@ -93,10 +102,40 @@ async def root():
93
  return {"service": "Pseudogen API", "version": "1"}
94
 
95
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  @v1_router.post("/generate-pseudocode")
97
  @limiter.limit("30/minute")
98
- async def generate_v1(request: Request, req: GenerateRequest, user: dict = Depends(get_current_user)):
99
- return await _generate(request, req, user)
 
 
 
 
 
100
 
101
 
102
  app.include_router(v1_router)
@@ -104,18 +143,43 @@ app.include_router(v1_router)
104
 
105
  @app.post("/generate-pseudocode")
106
  @limiter.limit("30/minute")
107
- async def generate(request: Request, req: GenerateRequest, user: dict = Depends(get_current_user)):
108
- return await _generate(request, req, user)
109
-
110
-
111
- async def _generate(request: Request, req: GenerateRequest, user: dict):
112
- plan = (user.get("plan") or "free").strip().lower()
113
- if plan != "premium" and len(req.problem_description) > FREE_MAX_INPUT_LEN:
 
 
 
 
 
 
 
 
 
 
 
 
114
  raise HTTPException(
115
  status_code=400,
116
- detail=f"Input exceeds the Free plan limit of {FREE_MAX_INPUT_LEN} characters. Upgrade to Premium for up to {PREMIUM_MAX_INPUT_LEN}.",
117
  )
118
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  template = TEMPLATES.get(req.style)
120
  if template is None:
121
  raise HTTPException(status_code=400, detail="Unknown style")
@@ -128,4 +192,13 @@ async def _generate(request: Request, req: GenerateRequest, user: dict):
128
  logger.exception("LLM call failed")
129
  raise HTTPException(status_code=502, detail="Failed to generate pseudocode. Please try again.")
130
 
131
- return {"markdown": response_text}
 
 
 
 
 
 
 
 
 
 
3
  from pathlib import Path
4
 
5
  from dotenv import load_dotenv
6
+ from fastapi import Depends, FastAPI, Header, HTTPException, Request, APIRouter
7
  from fastapi.middleware.cors import CORSMiddleware
8
  from pydantic import BaseModel, Field
9
  from slowapi import Limiter, _rate_limit_exceeded_handler
 
14
 
15
  from ai_prompts import TEMPLATES
16
  from utils import call_llm
17
+ from database import (
18
+ init_db,
19
+ GUEST_DAILY_LIMIT,
20
+ USER_DAILY_LIMIT,
21
+ get_usage_today,
22
+ increment_usage_today,
23
+ )
24
+ from auth import get_optional_user
25
  from routers.auth import router as auth_router
26
 
27
  _BACKEND_DIR = Path(__file__).resolve().parent
 
70
  app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
71
 
72
  _cors_origins = os.getenv("CORS_ORIGINS", "*").strip()
73
+ allow_origins = (
74
+ [o.strip() for o in _cors_origins.split(",") if o.strip()]
75
+ if _cors_origins != "*"
76
+ else ["*"]
77
+ )
78
  app.add_middleware(
79
  CORSMiddleware,
80
  allow_origins=allow_origins,
 
83
  allow_headers=["*"],
84
  )
85
 
86
+ MAX_INPUT_LEN = 4000
 
87
 
88
 
89
  class GenerateRequest(BaseModel):
90
+ problem_description: Annotated[str, Field(min_length=1, max_length=MAX_INPUT_LEN)]
91
  style: Annotated[str, Field(pattern="^(Academic|Developer-Friendly|English-Like|Step-by-Step)$")]
92
  detail: Annotated[str, Field(pattern="^(Concise|Detailed)$")]
93
 
 
102
  return {"service": "Pseudogen API", "version": "1"}
103
 
104
 
105
+ @app.get("/usage")
106
+ async def usage(
107
+ user: dict | None = Depends(get_optional_user),
108
+ x_session_id: str | None = Header(default=None),
109
+ ):
110
+ if user:
111
+ identifier = f"user:{user['id']}"
112
+ limit = USER_DAILY_LIMIT
113
+ is_guest = False
114
+ elif x_session_id:
115
+ identifier = f"session:{x_session_id}"
116
+ limit = GUEST_DAILY_LIMIT
117
+ is_guest = True
118
+ else:
119
+ return {"used": 0, "limit": GUEST_DAILY_LIMIT, "remaining": GUEST_DAILY_LIMIT, "is_guest": True}
120
+
121
+ used = get_usage_today(identifier)
122
+ return {
123
+ "used": used,
124
+ "limit": limit,
125
+ "remaining": max(0, limit - used),
126
+ "is_guest": is_guest,
127
+ }
128
+
129
+
130
  @v1_router.post("/generate-pseudocode")
131
  @limiter.limit("30/minute")
132
+ async def generate_v1(
133
+ request: Request,
134
+ req: GenerateRequest,
135
+ user: dict | None = Depends(get_optional_user),
136
+ x_session_id: str | None = Header(default=None),
137
+ ):
138
+ return await _generate(req, user, x_session_id)
139
 
140
 
141
  app.include_router(v1_router)
 
143
 
144
  @app.post("/generate-pseudocode")
145
  @limiter.limit("30/minute")
146
+ async def generate(
147
+ request: Request,
148
+ req: GenerateRequest,
149
+ user: dict | None = Depends(get_optional_user),
150
+ x_session_id: str | None = Header(default=None),
151
+ ):
152
+ return await _generate(req, user, x_session_id)
153
+
154
+
155
+ async def _generate(req: GenerateRequest, user: dict | None, x_session_id: str | None):
156
+ if user:
157
+ identifier = f"user:{user['id']}"
158
+ limit = USER_DAILY_LIMIT
159
+ is_guest = False
160
+ elif x_session_id:
161
+ identifier = f"session:{x_session_id}"
162
+ limit = GUEST_DAILY_LIMIT
163
+ is_guest = True
164
+ else:
165
  raise HTTPException(
166
  status_code=400,
167
+ detail="A session ID or account is required.",
168
  )
169
 
170
+ used = get_usage_today(identifier)
171
+ if used >= limit:
172
+ if is_guest:
173
+ raise HTTPException(
174
+ status_code=429,
175
+ detail=f"You've used all {limit} free prompts for today. Create a free account to get {USER_DAILY_LIMIT} per day.",
176
+ )
177
+ else:
178
+ raise HTTPException(
179
+ status_code=429,
180
+ detail=f"Daily limit of {limit} prompts reached. Resets at midnight UTC.",
181
+ )
182
+
183
  template = TEMPLATES.get(req.style)
184
  if template is None:
185
  raise HTTPException(status_code=400, detail="Unknown style")
 
192
  logger.exception("LLM call failed")
193
  raise HTTPException(status_code=502, detail="Failed to generate pseudocode. Please try again.")
194
 
195
+ new_count = increment_usage_today(identifier)
196
+ remaining = max(0, limit - new_count)
197
+
198
+ return {
199
+ "markdown": response_text,
200
+ "used": new_count,
201
+ "limit": limit,
202
+ "remaining": remaining,
203
+ "is_guest": is_guest,
204
+ }
auth.py CHANGED
@@ -49,7 +49,6 @@ def decode_token(token: str) -> dict | None:
49
  async def get_current_user(
50
  credentials: HTTPAuthorizationCredentials | None = Depends(security),
51
  ) -> dict:
52
- """Dependency: require valid Bearer token and return user dict (id, email, plan)."""
53
  if credentials is None:
54
  raise HTTPException(
55
  status_code=status.HTTP_401_UNAUTHORIZED,
@@ -70,3 +69,17 @@ async def get_current_user(
70
  if user is None:
71
  raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
72
  return user
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  async def get_current_user(
50
  credentials: HTTPAuthorizationCredentials | None = Depends(security),
51
  ) -> dict:
 
52
  if credentials is None:
53
  raise HTTPException(
54
  status_code=status.HTTP_401_UNAUTHORIZED,
 
69
  if user is None:
70
  raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
71
  return user
72
+
73
+
74
+ async def get_optional_user(
75
+ credentials: HTTPAuthorizationCredentials | None = Depends(security),
76
+ ) -> dict | None:
77
+ if credentials is None:
78
+ return None
79
+ payload = decode_token(credentials.credentials)
80
+ if payload is None:
81
+ return None
82
+ user_id = payload.get("sub")
83
+ if not user_id:
84
+ return None
85
+ return get_user_by_id(int(user_id))
database.py CHANGED
@@ -1,13 +1,13 @@
1
- # backend/database.py
2
- """
3
- SQLite database and user CRUD for Pseudogen auth.
4
- """
5
  import sqlite3
 
6
  from pathlib import Path
7
 
8
  _BACKEND_DIR = Path(__file__).resolve().parent
9
  DB_PATH = _BACKEND_DIR / "pseudogen.db"
10
 
 
 
 
11
 
12
  def get_connection():
13
  conn = sqlite3.connect(DB_PATH)
@@ -16,7 +16,6 @@ def get_connection():
16
 
17
 
18
  def init_db():
19
- """Create users table if it does not exist."""
20
  conn = get_connection()
21
  try:
22
  conn.execute("""
@@ -28,6 +27,15 @@ def init_db():
28
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
29
  )
30
  """)
 
 
 
 
 
 
 
 
 
31
  conn.commit()
32
  finally:
33
  conn.close()
@@ -40,9 +48,7 @@ def get_user_by_email(email: str) -> dict | None:
40
  "SELECT id, email, hashed_password, plan, created_at FROM users WHERE email = ?",
41
  (email.strip().lower(),),
42
  ).fetchone()
43
- if row is None:
44
- return None
45
- return dict(row)
46
  finally:
47
  conn.close()
48
 
@@ -54,9 +60,7 @@ def get_user_by_id(user_id: int) -> dict | None:
54
  "SELECT id, email, plan, created_at FROM users WHERE id = ?",
55
  (user_id,),
56
  ).fetchone()
57
- if row is None:
58
- return None
59
- return dict(row)
60
  finally:
61
  conn.close()
62
 
@@ -69,7 +73,44 @@ def create_user(email: str, hashed_password: str, plan: str = "free") -> dict:
69
  (email.strip().lower(), hashed_password, plan),
70
  )
71
  conn.commit()
72
- user_id = cursor.lastrowid
73
- return {"id": user_id, "email": email.strip().lower(), "plan": plan}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  finally:
75
  conn.close()
 
 
 
 
 
1
  import sqlite3
2
+ from datetime import datetime, timezone
3
  from pathlib import Path
4
 
5
  _BACKEND_DIR = Path(__file__).resolve().parent
6
  DB_PATH = _BACKEND_DIR / "pseudogen.db"
7
 
8
+ GUEST_DAILY_LIMIT = 5
9
+ USER_DAILY_LIMIT = 10
10
+
11
 
12
  def get_connection():
13
  conn = sqlite3.connect(DB_PATH)
 
16
 
17
 
18
  def init_db():
 
19
  conn = get_connection()
20
  try:
21
  conn.execute("""
 
27
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
28
  )
29
  """)
30
+ conn.execute("""
31
+ CREATE TABLE IF NOT EXISTS daily_usage (
32
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
33
+ identifier TEXT NOT NULL,
34
+ date TEXT NOT NULL,
35
+ count INTEGER NOT NULL DEFAULT 0,
36
+ UNIQUE(identifier, date)
37
+ )
38
+ """)
39
  conn.commit()
40
  finally:
41
  conn.close()
 
48
  "SELECT id, email, hashed_password, plan, created_at FROM users WHERE email = ?",
49
  (email.strip().lower(),),
50
  ).fetchone()
51
+ return dict(row) if row else None
 
 
52
  finally:
53
  conn.close()
54
 
 
60
  "SELECT id, email, plan, created_at FROM users WHERE id = ?",
61
  (user_id,),
62
  ).fetchone()
63
+ return dict(row) if row else None
 
 
64
  finally:
65
  conn.close()
66
 
 
73
  (email.strip().lower(), hashed_password, plan),
74
  )
75
  conn.commit()
76
+ return {"id": cursor.lastrowid, "email": email.strip().lower(), "plan": plan}
77
+ finally:
78
+ conn.close()
79
+
80
+
81
+ def _today_utc() -> str:
82
+ return datetime.now(timezone.utc).strftime("%Y-%m-%d")
83
+
84
+
85
+ def get_usage_today(identifier: str) -> int:
86
+ conn = get_connection()
87
+ try:
88
+ row = conn.execute(
89
+ "SELECT count FROM daily_usage WHERE identifier = ? AND date = ?",
90
+ (identifier, _today_utc()),
91
+ ).fetchone()
92
+ return row["count"] if row else 0
93
+ finally:
94
+ conn.close()
95
+
96
+
97
+ def increment_usage_today(identifier: str) -> int:
98
+ today = _today_utc()
99
+ conn = get_connection()
100
+ try:
101
+ conn.execute(
102
+ """
103
+ INSERT INTO daily_usage (identifier, date, count)
104
+ VALUES (?, ?, 1)
105
+ ON CONFLICT(identifier, date) DO UPDATE SET count = count + 1
106
+ """,
107
+ (identifier, today),
108
+ )
109
+ conn.commit()
110
+ row = conn.execute(
111
+ "SELECT count FROM daily_usage WHERE identifier = ? AND date = ?",
112
+ (identifier, today),
113
+ ).fetchone()
114
+ return row["count"]
115
  finally:
116
  conn.close()
utils.py CHANGED
@@ -65,6 +65,7 @@ def call_groq_with_retries(prompt: str, model: str = None, max_retries: int = 3,
65
  if not api_key:
66
  raise RuntimeError("Missing GROQ_API_KEY")
67
  model = model or os.getenv("GROQ_MODEL", "llama3-8b-8192")
 
68
  headers = {
69
  "Authorization": f"Bearer {api_key}",
70
  "Content-Type": "application/json",
@@ -83,6 +84,7 @@ def call_groq_with_retries(prompt: str, model: str = None, max_retries: int = 3,
83
  headers=headers,
84
  json=payload,
85
  timeout=30,
 
86
  )
87
  if resp.status_code == 200:
88
  data = resp.json()
 
65
  if not api_key:
66
  raise RuntimeError("Missing GROQ_API_KEY")
67
  model = model or os.getenv("GROQ_MODEL", "llama3-8b-8192")
68
+ ssl_verify = os.getenv("GROQ_SSL_VERIFY", "true").lower() != "false"
69
  headers = {
70
  "Authorization": f"Bearer {api_key}",
71
  "Content-Type": "application/json",
 
84
  headers=headers,
85
  json=payload,
86
  timeout=30,
87
+ verify=ssl_verify,
88
  )
89
  if resp.status_code == 200:
90
  data = resp.json()