Spaces:
Running
Running
fix: improve security, session handling, and UI responsiveness for topic management and document rendering
Browse files- materials/routes.py +13 -16
- quiz_generator/routes.py +4 -0
- rag/rag.py +1 -1
- rag/routes.py +13 -1
- store.py +43 -33
- summary_generator/routes.py +2 -0
materials/routes.py
CHANGED
|
@@ -36,7 +36,7 @@ def _validate_pdf_upload(file: UploadFile) -> None:
|
|
| 36 |
if size is not None and size > MAX_SIZE_BYTES:
|
| 37 |
raise HTTPException(400, "File too large")
|
| 38 |
if size is None:
|
| 39 |
-
raise HTTPException(400, "
|
| 40 |
|
| 41 |
class URLInput(BaseModel):
|
| 42 |
url: str
|
|
@@ -247,13 +247,18 @@ async def create_topic(
|
|
| 247 |
body: TopicRequest,
|
| 248 |
user_id: str = Depends(get_current_user_id)
|
| 249 |
):
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
update_material_status(mat["id"], "ready", "Topic ready")
|
| 258 |
return {"material_id": mat["id"], "title": mat["title"]}
|
| 259 |
|
|
@@ -267,13 +272,6 @@ def search_materials(
|
|
| 267 |
body: SearchRequest,
|
| 268 |
user_id: str = Depends(get_current_user_id),
|
| 269 |
):
|
| 270 |
-
# Debug logs
|
| 271 |
-
headers = {k.lower(): v for k, v in request.headers.items()}
|
| 272 |
-
logger.info(f"[search] user_id={user_id}")
|
| 273 |
-
logger.info(f"[search] x-auth-token present: {'x-auth-token' in headers}")
|
| 274 |
-
logger.info(f"[search] authorization present: {'authorization' in headers}")
|
| 275 |
-
logger.info(f"[search] query={body.q}")
|
| 276 |
-
|
| 277 |
supabase = get_supabase()
|
| 278 |
if not supabase:
|
| 279 |
return {"results": []}
|
|
@@ -283,7 +281,6 @@ def search_materials(
|
|
| 283 |
{"p_query": body.q, "p_user_id": user_id}
|
| 284 |
).execute()
|
| 285 |
|
| 286 |
-
logger.info(f"[search] RPC returned {len(result.data)} results: {result.data}")
|
| 287 |
return {"results": result.data}
|
| 288 |
|
| 289 |
@router.delete("/{material_id}")
|
|
|
|
| 36 |
if size is not None and size > MAX_SIZE_BYTES:
|
| 37 |
raise HTTPException(400, "File too large")
|
| 38 |
if size is None:
|
| 39 |
+
raise HTTPException(400, "Could not determine file size")
|
| 40 |
|
| 41 |
class URLInput(BaseModel):
|
| 42 |
url: str
|
|
|
|
| 247 |
body: TopicRequest,
|
| 248 |
user_id: str = Depends(get_current_user_id)
|
| 249 |
):
|
| 250 |
+
# Rely on the DB-level UNIQUE constraint on (user_id, title)
|
| 251 |
+
try:
|
| 252 |
+
mat = create_material(
|
| 253 |
+
user_id=user_id,
|
| 254 |
+
title=body.topic.strip(),
|
| 255 |
+
source_type="topic"
|
| 256 |
+
)
|
| 257 |
+
except APIError as e:
|
| 258 |
+
# Supabase raises APIError with code "23505" on unique-constraint violations
|
| 259 |
+
if "23505" in str(e) or "duplicate" in str(e).lower() or "unique" in str(e).lower():
|
| 260 |
+
raise HTTPException(409, "You already have a material with this title")
|
| 261 |
+
raise HTTPException(500, f"Failed to create topic: {e}")
|
| 262 |
update_material_status(mat["id"], "ready", "Topic ready")
|
| 263 |
return {"material_id": mat["id"], "title": mat["title"]}
|
| 264 |
|
|
|
|
| 272 |
body: SearchRequest,
|
| 273 |
user_id: str = Depends(get_current_user_id),
|
| 274 |
):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
supabase = get_supabase()
|
| 276 |
if not supabase:
|
| 277 |
return {"results": []}
|
|
|
|
| 281 |
{"p_query": body.q, "p_user_id": user_id}
|
| 282 |
).execute()
|
| 283 |
|
|
|
|
| 284 |
return {"results": result.data}
|
| 285 |
|
| 286 |
@router.delete("/{material_id}")
|
quiz_generator/routes.py
CHANGED
|
@@ -142,4 +142,8 @@ async def load_results(
|
|
| 142 |
quiz_id: str,
|
| 143 |
user_id: str = Depends(get_current_user_id),
|
| 144 |
):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
return get_quiz_results(quiz_id)
|
|
|
|
| 142 |
quiz_id: str,
|
| 143 |
user_id: str = Depends(get_current_user_id),
|
| 144 |
):
|
| 145 |
+
# Verify the quiz belongs to the requesting user before returning results
|
| 146 |
+
quizzes = get_quizzes(user_id=user_id)
|
| 147 |
+
if not any(q.get("id") == quiz_id for q in quizzes):
|
| 148 |
+
raise HTTPException(403, "Access denied")
|
| 149 |
return get_quiz_results(quiz_id)
|
rag/rag.py
CHANGED
|
@@ -77,7 +77,7 @@ async def store_embeddings_async(material_id: str, chunk_ids: list[str], chunks:
|
|
| 77 |
await embedding_queue.put(job)
|
| 78 |
await job.done.wait()
|
| 79 |
|
| 80 |
-
entry = job_store
|
| 81 |
if entry["status"] == "error":
|
| 82 |
raise RuntimeError(f"Embedding failed: {entry['error']}")
|
| 83 |
|
|
|
|
| 77 |
await embedding_queue.put(job)
|
| 78 |
await job.done.wait()
|
| 79 |
|
| 80 |
+
entry = job_store.pop(job.job_id)
|
| 81 |
if entry["status"] == "error":
|
| 82 |
raise RuntimeError(f"Embedding failed: {entry['error']}")
|
| 83 |
|
rag/routes.py
CHANGED
|
@@ -155,8 +155,11 @@ async def create_session(
|
|
| 155 |
@router.get("/sessions/{session_id}/messages")
|
| 156 |
async def get_messages(
|
| 157 |
session_id: str,
|
| 158 |
-
|
| 159 |
):
|
|
|
|
|
|
|
|
|
|
| 160 |
messages = get_session_messages(session_id)
|
| 161 |
return messages
|
| 162 |
|
|
@@ -167,6 +170,9 @@ async def rename_session(
|
|
| 167 |
body: RenameSessionRequest,
|
| 168 |
user_id: str = Depends(get_current_user_id),
|
| 169 |
):
|
|
|
|
|
|
|
|
|
|
| 170 |
rename_chat_session(session_id, body.title)
|
| 171 |
return {"status": "ok"}
|
| 172 |
|
|
@@ -176,6 +182,9 @@ async def delete_session(
|
|
| 176 |
session_id: str,
|
| 177 |
user_id: str = Depends(get_current_user_id),
|
| 178 |
):
|
|
|
|
|
|
|
|
|
|
| 179 |
delete_chat_session(session_id)
|
| 180 |
return {"status": "ok"}
|
| 181 |
|
|
@@ -192,6 +201,9 @@ async def extract_title(
|
|
| 192 |
):
|
| 193 |
try:
|
| 194 |
session = get_chat_session(session_id)
|
|
|
|
|
|
|
|
|
|
| 195 |
material_title = None
|
| 196 |
if session and session.get("material_id"):
|
| 197 |
mat = get_material(session["material_id"])
|
|
|
|
| 155 |
@router.get("/sessions/{session_id}/messages")
|
| 156 |
async def get_messages(
|
| 157 |
session_id: str,
|
| 158 |
+
user_id: str = Depends(get_current_user_id),
|
| 159 |
):
|
| 160 |
+
session = get_chat_session(session_id)
|
| 161 |
+
if not session or session.get("user_id") != user_id:
|
| 162 |
+
raise HTTPException(403, "Access denied")
|
| 163 |
messages = get_session_messages(session_id)
|
| 164 |
return messages
|
| 165 |
|
|
|
|
| 170 |
body: RenameSessionRequest,
|
| 171 |
user_id: str = Depends(get_current_user_id),
|
| 172 |
):
|
| 173 |
+
session = get_chat_session(session_id)
|
| 174 |
+
if not session or session.get("user_id") != user_id:
|
| 175 |
+
raise HTTPException(403, "Access denied")
|
| 176 |
rename_chat_session(session_id, body.title)
|
| 177 |
return {"status": "ok"}
|
| 178 |
|
|
|
|
| 182 |
session_id: str,
|
| 183 |
user_id: str = Depends(get_current_user_id),
|
| 184 |
):
|
| 185 |
+
session = get_chat_session(session_id)
|
| 186 |
+
if not session or session.get("user_id") != user_id:
|
| 187 |
+
raise HTTPException(403, "Access denied")
|
| 188 |
delete_chat_session(session_id)
|
| 189 |
return {"status": "ok"}
|
| 190 |
|
|
|
|
| 201 |
):
|
| 202 |
try:
|
| 203 |
session = get_chat_session(session_id)
|
| 204 |
+
if not session or session.get("user_id") != user_id:
|
| 205 |
+
raise HTTPException(403, "Access denied")
|
| 206 |
+
|
| 207 |
material_title = None
|
| 208 |
if session and session.get("material_id"):
|
| 209 |
mat = get_material(session["material_id"])
|
store.py
CHANGED
|
@@ -320,11 +320,18 @@ def save_summary(material_id: str, user_id: str, summary: str,
|
|
| 320 |
"time_taken": time_taken,
|
| 321 |
"model_name": model_name,
|
| 322 |
}
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
|
|
|
|
|
|
| 326 |
else:
|
| 327 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
|
| 329 |
|
| 330 |
def get_summary(material_id: str) -> Optional[dict]:
|
|
@@ -365,14 +372,13 @@ def save_quiz(user_id: str, material_id: Optional[str], source_type: str,
|
|
| 365 |
|
| 366 |
|
| 367 |
def get_quizzes(material_id: Optional[str] = None, user_id: Optional[str] = None) -> list[dict]:
|
| 368 |
-
|
| 369 |
-
result = tbl.select("*").execute()
|
| 370 |
-
records = result.data
|
| 371 |
-
if material_id:
|
| 372 |
-
records = [r for r in records if r.get("material_id") == material_id]
|
| 373 |
if user_id:
|
| 374 |
-
|
| 375 |
-
|
|
|
|
|
|
|
|
|
|
| 376 |
|
| 377 |
|
| 378 |
# ββ Users (maps to Supabase `profiles` table) βββββββββ
|
|
@@ -638,57 +644,61 @@ def get_or_create_memory(memory_id: Optional[str] = None, seed_messages: list[di
|
|
| 638 |
def check_and_increment_daily_limit(user_id: str, email: Optional[str] = None, limit: int = 10) -> bool:
|
| 639 |
"""
|
| 640 |
Returns True if request is allowed, False if limit exceeded.
|
| 641 |
-
Excludes Admin Emails from Limits
|
|
|
|
|
|
|
|
|
|
|
|
|
| 642 |
"""
|
| 643 |
-
#
|
| 644 |
-
if email in ADMIN_EMAILS:
|
| 645 |
return True
|
| 646 |
|
| 647 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 648 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 649 |
try:
|
| 650 |
-
# Get current profile
|
| 651 |
result = _robust_execute(
|
| 652 |
_table_supabase("profiles")
|
| 653 |
.select("daily_requests, last_request_date")
|
| 654 |
.eq("id", user_id)
|
| 655 |
-
|
| 656 |
)
|
| 657 |
-
|
| 658 |
-
# If no profile or data, allow (or we could create one, but usually it exists)
|
| 659 |
if not result.data:
|
| 660 |
return True
|
| 661 |
-
|
| 662 |
-
# Handle both single object or list response from maybe_single/execute
|
| 663 |
profile = result.data[0] if result.data else None
|
| 664 |
if not profile:
|
| 665 |
return True
|
| 666 |
|
| 667 |
last_date = profile.get("last_request_date")
|
| 668 |
count = profile.get("daily_requests", 0) or 0
|
| 669 |
-
|
| 670 |
-
# Reset count if it's a new day
|
| 671 |
if last_date != today:
|
| 672 |
count = 0
|
| 673 |
-
|
| 674 |
-
# Check limit
|
| 675 |
if count >= limit:
|
| 676 |
return False
|
| 677 |
|
| 678 |
-
# Increment
|
| 679 |
_robust_execute(
|
| 680 |
_table_supabase("profiles")
|
| 681 |
-
.update({
|
| 682 |
-
"daily_requests": count + 1,
|
| 683 |
-
"last_request_date": today,
|
| 684 |
-
})
|
| 685 |
.eq("id", user_id)
|
| 686 |
)
|
| 687 |
return True
|
| 688 |
except Exception as e:
|
| 689 |
-
|
| 690 |
-
import logging
|
| 691 |
-
logging.getLogger(__name__).error(f"Rate limit check failed: {e}")
|
| 692 |
return True
|
| 693 |
|
| 694 |
|
|
|
|
| 320 |
"time_taken": time_taken,
|
| 321 |
"model_name": model_name,
|
| 322 |
}
|
| 323 |
+
# Single atomic upsert β eliminates the race between selectβinsert/update
|
| 324 |
+
# under concurrent summarization requests (both see no row β both insert β 500).
|
| 325 |
+
client = _db()
|
| 326 |
+
if client is not None:
|
| 327 |
+
_robust_execute(client.table("summaries").upsert(data, on_conflict="material_id"))
|
| 328 |
else:
|
| 329 |
+
# Offline / dev fallback: manual check-then-write (no concurrency risk in dev)
|
| 330 |
+
existing = _FakeTable("summaries").select("*").eq("material_id", material_id).execute()
|
| 331 |
+
if existing.data:
|
| 332 |
+
_FakeTable("summaries").update(data).eq("material_id", material_id).execute()
|
| 333 |
+
else:
|
| 334 |
+
_FakeTable("summaries").insert(data).execute()
|
| 335 |
|
| 336 |
|
| 337 |
def get_summary(material_id: str) -> Optional[dict]:
|
|
|
|
| 372 |
|
| 373 |
|
| 374 |
def get_quizzes(material_id: Optional[str] = None, user_id: Optional[str] = None) -> list[dict]:
|
| 375 |
+
query = _table_supabase("quizzes").select("*")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
if user_id:
|
| 377 |
+
query = query.eq("user_id", user_id)
|
| 378 |
+
if material_id:
|
| 379 |
+
query = query.eq("material_id", material_id)
|
| 380 |
+
result = _robust_execute(query)
|
| 381 |
+
return result.data
|
| 382 |
|
| 383 |
|
| 384 |
# ββ Users (maps to Supabase `profiles` table) βββββββββ
|
|
|
|
| 644 |
def check_and_increment_daily_limit(user_id: str, email: Optional[str] = None, limit: int = 10) -> bool:
|
| 645 |
"""
|
| 646 |
Returns True if request is allowed, False if limit exceeded.
|
| 647 |
+
Excludes Admin Emails from Limits.
|
| 648 |
+
|
| 649 |
+
Uses a single atomic SQL UPDATE via Supabase RPC to eliminate the
|
| 650 |
+
check-then-increment race condition (two concurrent reads both see
|
| 651 |
+
count=9, both pass, both increment β 11th request gets through).
|
| 652 |
"""
|
| 653 |
+
# Guard: never apply limit to admin emails
|
| 654 |
+
if email and email in ADMIN_EMAILS:
|
| 655 |
return True
|
| 656 |
|
| 657 |
+
client = _db()
|
| 658 |
+
if client is not None:
|
| 659 |
+
try:
|
| 660 |
+
result = client.rpc(
|
| 661 |
+
"increment_daily_limit",
|
| 662 |
+
{"p_user_id": user_id, "p_limit": limit},
|
| 663 |
+
).execute()
|
| 664 |
+
if not result.data:
|
| 665 |
+
return False
|
| 666 |
+
return True
|
| 667 |
+
except Exception as e:
|
| 668 |
+
logger.warning(f"Atomic rate-limit RPC failed, falling back to two-query path: {e}")
|
| 669 |
+
# Fall through to the two-query fallback below
|
| 670 |
|
| 671 |
+
# ββ Offline / dev fallback (also used when RPC call above raises) ββββββββββ
|
| 672 |
+
# This path has a theoretical race condition but is acceptable for single-
|
| 673 |
+
# worker dev environments where the RPC is not available.
|
| 674 |
+
today = date.today().isoformat()
|
| 675 |
try:
|
|
|
|
| 676 |
result = _robust_execute(
|
| 677 |
_table_supabase("profiles")
|
| 678 |
.select("daily_requests, last_request_date")
|
| 679 |
.eq("id", user_id)
|
|
|
|
| 680 |
)
|
|
|
|
|
|
|
| 681 |
if not result.data:
|
| 682 |
return True
|
|
|
|
|
|
|
| 683 |
profile = result.data[0] if result.data else None
|
| 684 |
if not profile:
|
| 685 |
return True
|
| 686 |
|
| 687 |
last_date = profile.get("last_request_date")
|
| 688 |
count = profile.get("daily_requests", 0) or 0
|
|
|
|
|
|
|
| 689 |
if last_date != today:
|
| 690 |
count = 0
|
|
|
|
|
|
|
| 691 |
if count >= limit:
|
| 692 |
return False
|
| 693 |
|
|
|
|
| 694 |
_robust_execute(
|
| 695 |
_table_supabase("profiles")
|
| 696 |
+
.update({"daily_requests": count + 1, "last_request_date": today})
|
|
|
|
|
|
|
|
|
|
| 697 |
.eq("id", user_id)
|
| 698 |
)
|
| 699 |
return True
|
| 700 |
except Exception as e:
|
| 701 |
+
logger.error(f"Rate limit check failed: {e}")
|
|
|
|
|
|
|
| 702 |
return True
|
| 703 |
|
| 704 |
|
summary_generator/routes.py
CHANGED
|
@@ -49,6 +49,8 @@ async def generate_summary(
|
|
| 49 |
if not chunks_list:
|
| 50 |
raise HTTPException(400, "No text chunks found in this material")
|
| 51 |
combined = "\n".join(c["content"] for c in chunks_list)
|
|
|
|
|
|
|
| 52 |
summary = await loop.run_in_executor(None, summarizer, combined)
|
| 53 |
|
| 54 |
elapsed = time.time() - start
|
|
|
|
| 49 |
if not chunks_list:
|
| 50 |
raise HTTPException(400, "No text chunks found in this material")
|
| 51 |
combined = "\n".join(c["content"] for c in chunks_list)
|
| 52 |
+
if len(combined) > 80000:
|
| 53 |
+
combined = combined[:80000]
|
| 54 |
summary = await loop.run_in_executor(None, summarizer, combined)
|
| 55 |
|
| 56 |
elapsed = time.time() - start
|