Hamdy005 commited on
Commit
7511b05
Β·
1 Parent(s): c480aa4

fix: improve security, session handling, and UI responsiveness for topic management and document rendering

Browse files
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, "File too large")
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
- if is_title_taken(body.topic, user_id=user_id):
251
- raise HTTPException(409, "You already have a material with this title")
252
- mat = create_material(
253
- user_id=user_id,
254
- title=body.topic.strip(),
255
- source_type="topic"
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[job.job_id]
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
- current_user=Depends(get_current_user),
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
- existing = _table_supabase("summaries").select("*").eq("material_id", material_id).execute()
324
- if existing.data:
325
- _table_supabase("summaries").update(data).eq("material_id", material_id).execute()
 
 
326
  else:
327
- _table_supabase("summaries").insert(data).execute()
 
 
 
 
 
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
- tbl = _table_supabase("quizzes")
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
- records = [r for r in records if r.get("user_id") == user_id]
375
- return records
 
 
 
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
- # Exclude specific email from rate limiting
644
- if email in ADMIN_EMAILS:
645
  return True
646
 
647
- today = date.today().isoformat()
 
 
 
 
 
 
 
 
 
 
 
 
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
- # If DB fails, we default to allowing the request to not break the app
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