Spaces:
Sleeping
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
|
@@ -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
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
app.add_middleware(
|
| 69 |
CORSMiddleware,
|
| 70 |
allow_origins=allow_origins,
|
|
@@ -73,12 +83,11 @@ app.add_middleware(
|
|
| 73 |
allow_headers=["*"],
|
| 74 |
)
|
| 75 |
|
| 76 |
-
|
| 77 |
-
PREMIUM_MAX_INPUT_LEN = 12000
|
| 78 |
|
| 79 |
|
| 80 |
class GenerateRequest(BaseModel):
|
| 81 |
-
problem_description: Annotated[str, Field(min_length=1, max_length=
|
| 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(
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
raise HTTPException(
|
| 115 |
status_code=400,
|
| 116 |
-
detail=
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
}
|
|
@@ -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))
|
|
@@ -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
|
| 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
|
| 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 |
-
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|
|
@@ -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()
|