From 1ce3eec7cb849b85f516a17128f5d317c539b288 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 7 Sep 2026 15:04:00 +0200 Subject: [PATCH] feat: authorize tutor context and protect uploaded media Tutor questions require owned selected attempts; similarity context filters eligibility before ranking. Uploads move to a permission-aware boundary with reference ACLs, canonical legacy aliases, pre-mutation attachment checks and card-aware moderator rules. Nginx stops caching media and supplies native byte ranges. Verified 37 deployed-image backend tests, 69 frontend tests/build, real pgvector/Nginx/browser checks, and two independent reviews. --- backend/app/main.py | 5 +- backend/app/routers/questions.py | 42 +-- backend/app/routers/quizzes.py | 2 + backend/app/routers/teach.py | 39 +- backend/app/routers/uploads.py | 48 +++ backend/app/utils/quiz_access.py | 27 ++ backend/app/utils/upload_access.py | 193 ++++++++++ backend/tests/test_related_privacy.py | 415 +++++++++++++++++++++ docs/quiz-revamp-progress.md | 12 + docs/quiz-revamp/privacy-logout.png | Bin 0 -> 9857 bytes docs/quiz-revamp/privacy-owner.png | Bin 0 -> 11661 bytes docs/quiz-revamp/privacy-peer.png | Bin 0 -> 8745 bytes docs/quiz-revamp/privacy-player.png | Bin 0 -> 86314 bytes docs/quiz-revamp/privacy-review.md | 11 + frontend/nginx.conf | 10 +- frontend/src/api/client.js | 38 +- frontend/src/api/client.test.js | 77 ++++ frontend/src/components/TeachChat.jsx | 7 +- frontend/src/components/TeachChat.test.jsx | 25 ++ frontend/src/context/AuthContext.jsx | 9 +- frontend/src/context/AuthContext.test.jsx | 38 ++ frontend/src/pages/QuizEditPage.jsx | 7 +- frontend/src/pages/QuizPage.jsx | 9 +- frontend/src/pages/QuizPage.test.jsx | 20 +- frontend/src/pages/ResultsPage.jsx | 5 +- frontend/src/utils/token.js | 10 + frontend/src/utils/uploads.js | 21 ++ frontend/src/utils/uploads.test.js | 24 ++ 28 files changed, 1002 insertions(+), 92 deletions(-) create mode 100644 backend/app/routers/uploads.py create mode 100644 backend/app/utils/upload_access.py create mode 100644 backend/tests/test_related_privacy.py create mode 100644 docs/quiz-revamp/privacy-logout.png create mode 100644 docs/quiz-revamp/privacy-owner.png create mode 100644 docs/quiz-revamp/privacy-peer.png create mode 100644 docs/quiz-revamp/privacy-player.png create mode 100644 docs/quiz-revamp/privacy-review.md create mode 100644 frontend/src/api/client.test.js create mode 100644 frontend/src/components/TeachChat.test.jsx create mode 100644 frontend/src/context/AuthContext.test.jsx create mode 100644 frontend/src/utils/token.js create mode 100644 frontend/src/utils/uploads.js create mode 100644 frontend/src/utils/uploads.test.js diff --git a/backend/app/main.py b/backend/app/main.py index 994e964..2572c02 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3,7 +3,6 @@ from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from fastapi.staticfiles import StaticFiles from app.config import settings from app.logging_config import setup_logging @@ -12,7 +11,7 @@ from app.logging_config import setup_logging setup_logging(settings.LOG_LEVEL) from app.database import engine, Base, SessionLocal from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses, mobile, mynote -from app.routers import study_tools +from app.routers import study_tools, uploads from app.utils.auth import get_password_hash from app.utils.scheduler import start_scheduler, stop_scheduler @@ -614,7 +613,7 @@ from app.middleware.request_logging import RequestLoggingMiddleware app.add_middleware(RequestLoggingMiddleware) # Serve uploaded images as static files -app.mount("/uploads", StaticFiles(directory=settings.UPLOAD_DIR), name="uploads") +app.include_router(uploads.router) app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(documents.router, prefix="/api/documents", tags=["documents"]) diff --git a/backend/app/routers/questions.py b/backend/app/routers/questions.py index e91221e..20c9e9e 100644 --- a/backend/app/routers/questions.py +++ b/backend/app/routers/questions.py @@ -14,7 +14,9 @@ from pydantic import BaseModel from sqlalchemy import cast, String, or_, text as sa_text, func from sqlalchemy.orm import Session +from app.config import settings from app.database import get_db +from app.utils.upload_access import validate_image_attachments, stored_upload_path from app.models.question import Question from app.models.question_category import QuestionCategory from app.models.quiz import Quiz @@ -83,7 +85,8 @@ def edit_question( is_mod = current_user.role in ("admin", "moderator") if question.user_id != current_user.id and not is_mod: raise HTTPException(status_code=403, detail="Not authorized to edit this question") - for field, value in data.model_dump(exclude_unset=True).items(): + values = validate_image_attachments(db, current_user, data.model_dump(exclude_unset=True)) + for field, value in values.items(): setattr(question, field, value) db.commit() db.refresh(question) @@ -344,10 +347,6 @@ def _strip_html(text: str) -> str: return clean -UPLOAD_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "..", "uploads", "questions") -os.makedirs(UPLOAD_DIR, exist_ok=True) - - class ManualQuestionCreate(BaseModel): question_text: str question_type: str = "mcq" @@ -376,6 +375,7 @@ def create_question_manually( if data.question_type == "mcq" and data.correct_answer not in data.options: raise HTTPException(status_code=400, detail="Correct answer must be one of the options") + images = validate_image_attachments(db, current_user, data.model_dump()) question = Question( question_text=q_text, question_type=data.question_type, @@ -383,8 +383,8 @@ def create_question_manually( correct_answer=data.correct_answer.strip(), explanation=data.explanation, question_category_id=data.question_category_id, - image_path=data.image_path, - explanation_image_path=data.explanation_image_path, + image_path=images["image_path"], + explanation_image_path=images["explanation_image_path"], user_id=current_user.id, is_shared=1, ) @@ -422,11 +422,13 @@ def upload_question_image( ext = file.filename.rsplit(".", 1)[-1].lower() if file.filename and "." in file.filename else "png" if ext not in ("png", "jpg", "jpeg", "gif", "webp", "svg"): raise HTTPException(status_code=400, detail="Unsupported image format") - filename = f"{uuid.uuid4().hex[:12]}.{ext}" - filepath = os.path.join(UPLOAD_DIR, filename) + filename = f"{uuid.uuid4().hex}.{ext}" + directory = os.path.join(settings.UPLOAD_DIR, "questions", str(current_user.id)) + os.makedirs(directory, exist_ok=True) + filepath = os.path.join(directory, filename) with open(filepath, "wb") as f: f.write(file.file.read()) - rel_path = f"questions/{filename}" + rel_path = f"questions/{current_user.id}/{filename}" return {"image_path": rel_path, "url": f"/uploads/{rel_path}"} @@ -436,19 +438,13 @@ def list_question_images( current_user: User = Depends(get_current_user), ): """List all unique question images for the image bank browser.""" - rows = db.execute(sa_text(""" - SELECT path FROM ( - SELECT image_path AS path FROM questions - WHERE image_path IS NOT NULL AND image_path <> '' - UNION - SELECT explanation_image_path AS path FROM questions - WHERE explanation_image_path IS NOT NULL AND explanation_image_path <> '' - ) AS image_paths - ORDER BY path - LIMIT 200 - """)).fetchall() - paths = [row.path for row in rows] - return [{"image_path": path, "url": f"/uploads/{path}"} for path in paths] + stems = bank_query(db, current_user).with_entities(Question.image_path.label("path")).filter( + Question.image_path.isnot(None), Question.image_path != "") + explanations = bank_query(db, current_user).with_entities(Question.explanation_image_path.label("path")).filter( + Question.explanation_image_path.isnot(None), Question.explanation_image_path != "") + paths = sorted({stored_upload_path(row.path) or row.path + for row in stems.union(explanations).order_by("path").limit(200).all()}) + return [{"image_path": path, "url": path if re.match(r"^https?://|^/uploads/", path, re.I) else f"/uploads/{path}"} for path in paths] @router.get("/builder/count") diff --git a/backend/app/routers/quizzes.py b/backend/app/routers/quizzes.py index f5f9f92..c59b693 100644 --- a/backend/app/routers/quizzes.py +++ b/backend/app/routers/quizzes.py @@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import cast, String, or_, and_, func from sqlalchemy.orm import Session +from app.utils.upload_access import validate_image_attachments from app.database import get_db from app.models.quiz import Quiz from app.models.question import Question as QuestionModel @@ -452,6 +453,7 @@ def update_question( raise HTTPException(status_code=404, detail="Question not found") allowed = {"question_text", "options", "correct_answer", "explanation", "question_type", "image_path", "explanation_image_path"} + data = validate_image_attachments(db, current_user, data) for key, value in data.items(): if key in allowed: setattr(question, key, value) diff --git a/backend/app/routers/teach.py b/backend/app/routers/teach.py index c837076..d089b4c 100644 --- a/backend/app/routers/teach.py +++ b/backend/app/routers/teach.py @@ -9,6 +9,8 @@ from app.database import get_db from app.models.question import Question from app.models.ai_model_config import AIModelConfig from app.models.user import User +from app.utils.quiz_access import require_question_access +from app.services.quiz_builder import bank_question_predicate from app.utils.auth import get_current_user, check_rate_limit router = APIRouter() @@ -30,6 +32,7 @@ class ChatRequest(BaseModel): model_config = {"protected_namespaces": ()} question_id: int + attempt_id: int | None = None messages: list[ChatMessage] model_id: int | None = None # AIModelConfig.id — if None, use default @@ -58,26 +61,16 @@ def _get_teach_model(db: Session, model_config_id: int | None = None): return (m.model_id, m.api_key or None) if m else None -def _find_similar_questions(db: Session, question: Question, limit: int = 4) -> list[Question]: - """Return up to `limit` questions with similar embeddings, excluding the current one.""" +def _find_similar_questions(db: Session, question: Question, user: User, limit: int = 4) -> list[Question]: + """Filter eligible context in SQL before similarity ranking and LIMIT.""" if question.embedding is None: return [] try: - from sqlalchemy import text as sa_text - emb = question.embedding - # Validate all values are finite floats before using in SQL - emb_literal = "[" + ",".join(str(float(x)) for x in emb) + "]" - rows = db.execute(sa_text(""" - SELECT id, 1 - (embedding <=> CAST(:vec AS vector)) AS sim - FROM questions - WHERE embedding IS NOT NULL AND id != :qid - ORDER BY embedding <=> CAST(:vec AS vector) - LIMIT :lim - """), {"vec": emb_literal, "qid": int(question.id), "lim": int(limit)}).fetchall() - ids = [r.id for r in rows if float(r.sim) >= 0.35] - if not ids: - return [] - return db.query(Question).filter(Question.id.in_(ids)).all() + distance = Question.embedding.cosine_distance(question.embedding) + return db.query(Question).filter( + bank_question_predicate(user), Question.id != question.id, + Question.embedding.isnot(None), distance <= 0.65, + ).order_by(distance).limit(limit).all() except Exception: return [] @@ -85,8 +78,7 @@ def _find_similar_questions(db: Session, question: Question, limit: int = 4) -> def _build_system_prompt(question: Question, similar: list[Question]) -> str: opts = "" if question.options: - letters = "ABCDE" - opts = "\n".join(f" {letters[i]}) {opt}" for i, opt in enumerate(question.options)) + opts = "\n".join(f" {i}) {opt}" for i, opt in enumerate(question.options, 1)) prompt = ( "You are a medical education tutor. A student is studying the question below.\n" @@ -146,6 +138,9 @@ async def chat( current_user: User = Depends(get_current_user), ): """Send a message to the teach AI with full question context.""" + question = db.query(Question).filter(Question.id == req.question_id).first() + require_question_access(db, question, current_user, req.attempt_id, review=True) + # Daily AI coach quota. Admins, moderators, and unthrottled users are exempt. quota_day, quota_ttl = _daily_teach_limit() check_rate_limit( @@ -163,11 +158,7 @@ async def chat( ) model_id, api_key = model_info - question = db.query(Question).filter(Question.id == req.question_id).first() - if not question: - raise HTTPException(status_code=404, detail="Question not found") - - similar = _find_similar_questions(db, question) + similar = _find_similar_questions(db, question, current_user) system_prompt = _build_system_prompt(question, similar) messages = [{"role": "system", "content": system_prompt}] diff --git a/backend/app/routers/uploads.py b/backend/app/routers/uploads.py new file mode 100644 index 0000000..309637b --- /dev/null +++ b/backend/app/routers/uploads.py @@ -0,0 +1,48 @@ +"""Native browser media authentication only; API authentication stays bearer-only.""" +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import FileResponse +from sqlalchemy.orm import Session + +from app.database import get_db +from app.utils.auth import get_current_user +from app.utils.upload_access import ( + LEGACY_LMS_PREFIXES, local_upload_path, upload_file, references, + document_for_file, can_read_upload, card_deck_ids, +) + +router = APIRouter() + + +@router.api_route("/uploads/{path:path}", methods=["GET", "HEAD"]) +def read_upload(path: str, request: Request, attempt_id: int | None = None, db: Session = Depends(get_db)): + headers = {"Cache-Control": "private, no-store", "Vary": "Cookie, Authorization", "X-Content-Type-Options": "nosniff"} + try: + return _read_upload(path, request, attempt_id, db, headers) + except HTTPException as exc: + exc.headers = {**(exc.headers or {}), **headers} + raise + + +def _read_upload(path, request, attempt_id, db, headers): + try: + path = local_upload_path(path) + target = upload_file(path) + except HTTPException: + raise HTTPException(404, "File not found") + questions = references(db, path) + cards = card_deck_ids(db, path) + # Default private also prevents orphaned question/card files becoming anonymous. + protected = (not path.startswith(LEGACY_LMS_PREFIXES) or document_for_file(db, path) + or questions or cards) + if protected: + authorization = request.headers.get("authorization", "") + token = authorization[7:] if authorization.lower().startswith("bearer ") else request.cookies.get("pedshub_media", "") + user = get_current_user(token, db) + if not can_read_upload(db, path, user, questions, cards, attempt_id): + raise HTTPException(404, "File not found") + # Known legacy LMS directories retain their policy; this is not a whole-LMS audit. + if not target.is_file(): + raise HTTPException(404, "File not found") + if target.suffix.lower() == ".svg": + headers["Content-Security-Policy"] = "sandbox; default-src 'none'; style-src 'unsafe-inline'" + return FileResponse(target, headers=headers) diff --git a/backend/app/utils/quiz_access.py b/backend/app/utils/quiz_access.py index 6f87f37..0f506fb 100644 --- a/backend/app/utils/quiz_access.py +++ b/backend/app/utils/quiz_access.py @@ -5,6 +5,8 @@ Course access is deliberately separate: publication/sharing never grants enrollm from fastapi import HTTPException from sqlalchemy import or_, select +from app.models.attempt import QuizAttempt +from app.utils.quiz_questions import question_in_quiz from app.models.course import Course, CourseEnrollment from app.models.question import Question from app.models.quiz import Quiz @@ -64,3 +66,28 @@ def set_quiz_shared(db, quiz, user, shared): quiz.is_published = 0 db.commit() return {"id": quiz.id, "is_shared": quiz.is_shared, "is_published": quiz.is_published} + + +def require_question_access(db, question, user, attempt_id=None, review=False): + """Authorize tutor/answer content or a stem, never trusting a course sharing flag.""" + if question is None: + raise HTTPException(404, "Question not found") + if attempt_id is not None: + attempt = db.query(QuizAttempt).filter_by(id=attempt_id, user_id=user.id).first() + if (not attempt or not question_in_quiz(db, attempt.quiz_id, question.id) + or (attempt.selected_question_ids is not None and question.id not in attempt.selected_question_ids)): + raise HTTPException(403, "Question is not available in this attempt") + require_quiz_access(db, attempt.quiz, user, review=review) + if review and attempt.mode != "study" and attempt.completed_at is None: + raise HTTPException(403, "Complete the attempt before reviewing") + return + if user.is_moderator: + return + if db.query(Question.id).filter(Question.id == question.id, bank_question_predicate(user)).first(): + return + quiz = db.get(Quiz, question.source_quiz_id) if question.source_quiz_id else None + if quiz and quiz.course_id is not None: + require_quiz_access(db, quiz, user, review=review) + if not review or quiz.user_id == user.id: + return + raise HTTPException(403, "Question is private or requires an authorized study/review attempt") diff --git a/backend/app/utils/upload_access.py b/backend/app/utils/upload_access.py new file mode 100644 index 0000000..40e0127 --- /dev/null +++ b/backend/app/utils/upload_access.py @@ -0,0 +1,193 @@ +"""Local upload paths and current-reference ACLs; no external URL fetching.""" +from pathlib import Path +import posixpath +import re +import socket +from urllib.parse import unquote, urlsplit + +from fastapi import HTTPException +from sqlalchemy import or_ +from sqlalchemy.orm import load_only + +from app.config import settings +from app.models.flashcard import Flashcard, FlashcardDeck +from app.models.pdf_document import PDFDocument +from app.models.question import Question +from app.services.quiz_builder import bank_question_predicate +from app.utils.quiz_access import require_question_access + +# These are the existing non-question upload producers in courses.py/certificate_service.py. +# They retain their legacy policy; arbitrary unknown/orphan files do not become public. +LEGACY_LMS_PREFIXES = ('course_files/', 'course_thumbnails/', 'scorm/', 'certificates/') + + +def local_upload_path(value): + if not isinstance(value, str): + raise HTTPException(400, "Invalid image path") + path = value.removeprefix("/uploads/") + if (not path or any(c in path for c in "\\%?#:") or any(ord(c) < 32 for c in path) + or path.endswith(' ') or any(part in ("", ".", "..") for part in path.split("/"))): + raise HTTPException(400, "Unsafe upload path") + return path + + +def upload_file(path): + """Reject even in-root symlink aliases before any ACL lookup.""" + try: + root = Path(settings.UPLOAD_DIR).resolve() + target = root + for part in path.split("/"): + target = target / part + if target.is_symlink(): + raise HTTPException(404, "File not found") + if not target.resolve().is_relative_to(root): + raise HTTPException(404, "File not found") + return target + except (OSError, ValueError): + raise HTTPException(404, "File not found") + + +def _authority(url): + host = unquote(url.hostname or '').rstrip('.').encode('idna').decode('ascii').lower() + try: + host = socket.inet_ntoa(socket.inet_aton(host)) # Numeric IPv4 spellings, no DNS/network. + except OSError: + pass + port = url.port + return host, None if port in (80, 443) else port + + +def stored_upload_path(value): + """Interpret old image strings like the browser, without rewriting stored content.""" + if not isinstance(value, str) or not value: + return None + # Match the existing image URL helper's prefix behavior before URL normalization. + raw = value if re.match(r'^(https?:)?//', value, re.I) or value.startswith('/uploads/') else '/uploads/' + value + raw = raw.replace('\\', '/').replace('\t', '').replace('\r', '').replace('\n', '') + raw = raw.strip(''.join(map(chr, range(33)))) + if raw.startswith('//'): + raw = '//' + raw.lstrip('/') + try: + url = urlsplit(raw) + if url.netloc: + if url.scheme and url.scheme.lower() not in ('http', 'https'): + return None + if _authority(url) != _authority(urlsplit(settings.APP_URL)): + return None + elif url.scheme: + return None + path = posixpath.normpath(unquote(url.path)) + if not path.startswith('/uploads/'): + return None + return local_upload_path(path) + except (ValueError, UnicodeError, HTTPException): + return None + + +def references(db, path): + # ponytail: scan reference metadata, not question bodies. Add indexed canonical + # media keys if library size makes this hot; do not cache stale access grants. + rows = db.query(Question.id, Question.image_path, Question.explanation_image_path).filter( + or_(Question.image_path.isnot(None), Question.explanation_image_path.isnot(None)), + ).all() + ids = [qid for qid, stem, explanation in rows + if stored_upload_path(stem) == path or stored_upload_path(explanation) == path] + if not ids: + return [] + return db.query(Question).options(load_only( + Question.id, Question.source_quiz_id, Question.image_path, Question.explanation_image_path, + )).filter(Question.id.in_(ids)).all() + + +def card_deck_ids(db, path): + return {deck_id for deck_id, image in db.query(Flashcard.deck_id, Flashcard.image_path).filter( + Flashcard.image_path.isnot(None), + ).all() if stored_upload_path(image) == path} + + +def document_for_file(db, path): + for document_id, filename in db.query(PDFDocument.id, PDFDocument.filename).all(): + if stored_upload_path(filename) == path: + return db.get(PDFDocument, document_id) + return None + + +def owns_source(db, path, user, cards): + if user.is_admin: + return True + if path.startswith(f"questions/{user.id}/"): + return True + # Recovery of legacy question drafts is not ownership of private card-only files. + if user.is_moderator and not cards and path.startswith('questions/'): + return True + match = re.fullmatch(r"images/doc_(\d+)/.+", path) + if match and not cards: # Card references stay on card ACL even inside extraction dirs. + document = db.get(PDFDocument, int(match[1])) + return document is not None and (document.user_id == user.id or user.is_moderator) + return False + + +def card_access(db, cards, user): + return bool(cards) and db.query(FlashcardDeck.id).filter( + FlashcardDeck.id.in_(cards), FlashcardDeck.deleted_at.is_(None), + or_(FlashcardDeck.user_id == user.id, FlashcardDeck.is_shared == 1, user.is_admin), + ).first() is not None + + +def can_read_upload(db, path, user, questions, cards, attempt_id=None): + # Sharing a question must never share its original source PDF. + document = document_for_file(db, path) + if document: + return user.is_moderator or document.user_id == user.id + if owns_source(db, path, user, cards) or card_access(db, cards, user): + return True + for question in questions: + try: + require_question_access(db, question, user, attempt_id, + review=stored_upload_path(question.image_path) != path) + return True + except HTTPException: + pass + return False + + +def validate_image_attachments(db, user, values): + """Validate every supplied image before callers mutate any ORM state.""" + result = dict(values) + for field in ("image_path", "explanation_image_path"): + value = result.get(field) + if value is None or value == "": + continue + if not isinstance(value, str): + raise HTTPException(400, "Invalid image path") + if re.match(r"^https?://", value, re.I): + try: + url = urlsplit(value) + if (not url.hostname or url.username or url.password or '\\' in value + or '%' in url.netloc or any(ord(c) < 32 for c in value)): + raise ValueError() + authority = _authority(url) # Also validates ports/hostname encoding. + except (ValueError, UnicodeError): + raise HTTPException(400, "Invalid image URL") + if authority != _authority(urlsplit(settings.APP_URL)) or not url.path.startswith('/uploads/'): + continue # External links are never fetched or granted local-file access. + if url.query or url.fragment: + raise HTTPException(400, "Use the original upload URL without query parameters or fragments") + value = url.path + path = local_upload_path(value) + upload_file(path) + document = document_for_file(db, path) + if document: + allowed = user.is_moderator or document.user_id == user.id + else: + cards = card_deck_ids(db, path) + allowed = owns_source(db, path, user, cards) or card_access(db, cards, user) + if not allowed: + ids = [question.id for question in references(db, path)] + allowed = bool(ids) if user.is_moderator else db.query(Question.id).filter( + Question.id.in_(ids), bank_question_predicate(user), + ).first() is not None + if not allowed: + raise HTTPException(403, "Image is not available for attachment") + result[field] = path + return result diff --git a/backend/tests/test_related_privacy.py b/backend/tests/test_related_privacy.py new file mode 100644 index 0000000..5eef266 --- /dev/null +++ b/backend/tests/test_related_privacy.py @@ -0,0 +1,415 @@ +"""Real routes, disposable files/SQLite, real JWTs; no network/AI calls.""" +from datetime import datetime +from pathlib import Path +from tempfile import TemporaryDirectory +from types import SimpleNamespace +import unittest +from unittest.mock import AsyncMock, Mock, patch + +from sqlalchemy.dialects import postgresql + +import test_quiz_builder as builder +from app.config import settings +from app.models.attempt import QuizAttempt +from app.models.course import CourseEnrollment +from app.models.email_verification import EmailVerification +from app.models.flashcard import Flashcard, FlashcardDeck +from app.models.pdf_document import PDFDocument +from app.models.question import Question +from app.models.quiz import Quiz +from app.routers import teach, uploads, flashcards +from app.utils.auth import create_access_token, get_current_user + + +class PrivacyTests(unittest.TestCase): + def setUp(self): + builder.BuilderTests.setUp(self) + self.tmp = TemporaryDirectory() + self.settings_patch = patch.object(settings, 'UPLOAD_DIR', self.tmp.name) + self.settings_patch.start() + self.client.app.include_router(teach.router, prefix='/teach') + self.client.app.include_router(uploads.router) + self.client.app.include_router(flashcards.router, prefix='/flashcards') + del self.client.app.dependency_overrides[get_current_user] + for q in self.db.query(Question): + q.image_path = f'questions/stem-{q.id}.png' + q.explanation_image_path = f'questions/answer-{q.id}.png' + self.file(q.image_path) + self.file(q.explanation_image_path) + self.db.commit() + self.login(self.owner) + self.quota = patch.object(teach, 'check_rate_limit').start() + self.model = patch.object(teach, '_get_teach_model', return_value=('synthetic', None)).start() + self.find_similar = teach._find_similar_questions + self.similar = patch.object(teach, '_find_similar_questions', return_value=[]).start() + self.ai = patch('litellm.acompletion', new_callable=AsyncMock).start() + self.ai.return_value = SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content='Tutor reply\n> Follow up'))]) + self.embedding = patch('app.services.embedding_service.embed_question').start() + self.proxy = patch('app.services.ai_service._proxy_model', return_value='synthetic').start() + + def tearDown(self): + patch.stopall() + self.tmp.cleanup() + builder.BuilderTests.tearDown(self) + + def login(self, user, cookie=False): + self.client.headers.pop('authorization', None) + self.client.cookies.clear() + token = create_access_token({'sub': user.email}) + if cookie: + self.client.cookies.set('pedshub_media', token, path='/uploads') + else: + self.client.headers['Authorization'] = f'Bearer {token}' + + def file(self, path, data=b'synthetic-image'): + target = Path(self.tmp.name) / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + return target + + def chat(self, qid, aid=None): + return self.client.post('/teach/chat', json={'question_id': qid, 'attempt_id': aid, 'messages': [{'role': 'user', 'content': 'Explain'}]}) + + def attempt(self, mode='study', completed=False, ids=None, user=1, quiz=2): + a = QuizAttempt(user_id=user, quiz_id=quiz, mode=mode, selected_question_ids=ids, + completed_at=datetime.utcnow() if completed else None) + self.db.add(a) + self.db.commit() + return a + + def enroll(self): + self.db.add(CourseEnrollment(course_id=1, user_id=1)) + self.db.get(Quiz, 2).allow_review = 1 + self.db.commit() + + def test_tutor_denial_precedes_all_external_boundaries(self): + for qid in (4, 5, 999): + self.assertIn(self.chat(qid).status_code, (403, 404)) + for boundary in (self.quota, self.model, self.similar, self.ai): + boundary.assert_not_called() + for qid in (1, 3): + res = self.chat(qid) + self.assertEqual(res.status_code, 200, res.text) + self.assertEqual(self.similar.call_args.args[2].id, 1) + self.login(self.mod) + self.assertEqual(self.chat(4).status_code, 200) + + def test_tutor_course_attempt_modes_pool_and_review(self): + self.enroll() + self.assertEqual(self.chat(5).status_code, 403) + for mode in ('exam', None): + a = self.attempt(mode=mode, ids=[5]) + self.assertEqual(self.chat(5, a.id).status_code, 403) + for a in (self.attempt(ids=[]), self.attempt(ids=[1]), self.attempt(user=2, ids=[5]), self.attempt(quiz=1, ids=[5])): + self.assertEqual(self.chat(5, a.id).status_code, 403) + self.assertEqual(self.chat(5, 999).status_code, 403) + self.ai.assert_not_called() + for a in (self.attempt(ids=[5]), self.attempt(mode='exam', completed=True, ids=[5])): + self.assertEqual(self.chat(5, a.id).status_code, 200) + self.db.get(Quiz, 2).allow_review = 0 + self.db.commit() + self.assertEqual(self.chat(5, a.id).status_code, 403) + self.db.query(CourseEnrollment).delete() + self.db.commit() + self.assertEqual(self.chat(5, a.id).status_code, 403) + + def test_similarity_sql_filters_before_limit_and_full_prompt(self): + q = self.db.get(Question, 1) + q.options = [f'Option {i}' for i in range(8)] + q.question_text = 'Long stem ' * 500 + prompt = teach._build_system_prompt(q, []) + self.assertIn(q.question_text, prompt) + self.assertIn('8) Option 7', prompt) + self.assertIn(q.explanation, prompt) + # Compile actual eligibility/ranking SQL; mock only terminal execution. + q.embedding = [0.1] * Question.__table__.c.embedding.type.dim + captured = [] + with patch('sqlalchemy.orm.Query.all', autospec=True, side_effect=lambda query: captured.append(query) or []): + self.assertEqual(self.find_similar(self.db, q, self.owner), []) + sql = str(captured[-1].statement.compile(dialect=postgresql.dialect())) + self.assertIn('questions.user_id', sql) + self.assertIn('questions.is_shared', sql) + self.assertIn('course_id IS NOT NULL', sql) + self.assertIn('<=>', sql) + self.assertLess(sql.index('WHERE'), sql.index('ORDER BY')) + self.assertLess(sql.index('ORDER BY'), sql.index('LIMIT')) + with patch('sqlalchemy.orm.Query.all', side_effect=RuntimeError('Unavailable embeddings')): + self.assertEqual(self.find_similar(self.db, q, self.owner), []) + q.embedding = None + self.assertEqual(self.find_similar(self.db, q, self.owner), []) + self.db.rollback() + + def test_native_cookie_bearer_revocation_and_api_exclusion(self): + self.client.headers.clear() + self.assertEqual(self.client.get('/uploads/questions/stem-4.png').status_code, 401) + self.login(self.owner, cookie=True) + for path in ('stem-1', 'stem-3', 'answer-1', 'answer-3'): + res = self.client.get(f'/uploads/questions/{path}.png') + self.assertEqual(res.status_code, 200, res.text) + self.assertEqual(res.headers['cache-control'], 'private, no-store') + self.assertEqual(res.headers['vary'], 'Cookie, Authorization') + self.assertEqual(self.client.get('/uploads/questions/stem-4.png').status_code, 404) + self.assertEqual(self.client.patch('/questions/3/share?shared=1').status_code, 401) + self.db.get(Question, 1).is_shared = 0 + self.db.commit() + self.assertEqual(self.client.get('/uploads/questions/stem-1.png').status_code, 404) + self.login(self.owner) + self.assertEqual(self.client.get('/uploads/questions/stem-3.png').status_code, 200) + self.db.add(EmailVerification(user_id=1, token='synthetic', expires_at=datetime(2099, 1, 1))) + self.db.commit() + self.assertEqual(self.client.get('/uploads/questions/stem-3.png').status_code, 403) + self.assertEqual(self.chat(3).status_code, 403) + + def test_course_stems_answers_and_independent_references(self): + self.assertEqual(self.client.get('/uploads/questions/stem-5.png').status_code, 404) + self.enroll() + self.assertEqual(self.client.get('/uploads/questions/stem-5.png').status_code, 200) + self.assertEqual(self.client.get('/uploads/questions/answer-5.png').status_code, 404) + a = self.attempt(mode='exam', ids=[5]) + url = f'/uploads/questions/answer-5.png?attempt_id={a.id}' + self.assertEqual(self.client.get(url).status_code, 404) + a.mode = 'study' + self.db.commit() + self.assertEqual(self.client.get(url).status_code, 200) + a.selected_question_ids = [] + self.db.commit() + self.assertEqual(self.client.get(url).status_code, 404) + a.selected_question_ids = [5] + self.db.get(Quiz, 2).allow_review = 0 + self.db.commit() + self.assertEqual(self.client.get(url).status_code, 404) + self.db.get(Question, 1).image_path = '/uploads/questions/answer-5.png' + self.db.commit() + self.assertEqual(self.client.get('/uploads/questions/answer-5.png').status_code, 200) + + def test_extraction_original_pdf_unattached_and_cards(self): + self.db.add_all([PDFDocument(id=1, user_id=2, filename='private.pdf', original_filename='source.pdf'), + PDFDocument(id=2, user_id=1, filename='own.pdf', original_filename='own.pdf')]) + for path in ('private.pdf', 'own.pdf', 'images/doc_1/shared.png', 'images/doc_1/adjacent.png', 'images/doc_2/own.png', 'questions/1/draft.png', 'questions/2/draft.png', 'questions/legacy.png'): + self.file(path) + self.db.get(Question, 1).image_path = 'images/doc_1/shared.png' + self.db.get(Question, 2).image_path = 'private.pdf' + self.db.commit() + for path, code in [('private.pdf', 404), ('own.pdf', 200), ('images/doc_1/shared.png', 200), ('images/doc_1/adjacent.png', 404), ('images/doc_2/own.png', 200), ('questions/1/draft.png', 200), ('questions/2/draft.png', 404), ('questions/legacy.png', 404)]: + self.assertEqual(self.client.get('/uploads/' + path).status_code, code, path) + deck = FlashcardDeck(user_id=2, title='Cards', is_shared=0) + self.db.add(deck) + self.db.flush() + self.db.add(Flashcard(deck_id=deck.id, front='front', back='back', image_path='questions/2/draft.png')) + self.db.commit() + self.assertEqual(self.client.get('/uploads/questions/2/draft.png').status_code, 404) + deck.is_shared = 1 + self.db.commit() + self.assertEqual(self.client.get('/uploads/questions/2/draft.png').status_code, 200) + deck.deleted_at = datetime.utcnow() + self.db.commit() + self.assertEqual(self.client.get('/uploads/questions/2/draft.png').status_code, 404) + self.login(self.mod) + self.assertEqual(self.client.get('/uploads/questions/legacy.png').status_code, 200) + + def test_attachment_forgery_atomic_and_chooser(self): + payload = {'question_text': 'Created', 'options': ['yes', 'no'], 'correct_answer': 'yes'} + before = self.db.query(Question).count() + self.enroll() + for field in ('image_path', 'explanation_image_path'): + for path in ('questions/stem-4.png', '/uploads/questions/answer-4.png', 'questions/stem-5.png', 'questions/2/draft.png', '../escape', '/uploads/questions/../escape', '//other/asset'): + res = self.client.post('/questions/create', json={**payload, field: path}) + self.assertIn(res.status_code, (400, 403), res.text) + self.assertEqual(self.db.query(Question).count(), before) + res = self.client.patch('/questions/3', json={'question_text': 'Forged', field: path}) + self.assertIn(res.status_code, (400, 403), res.text) + self.assertEqual(self.db.get(Question, 3).question_text, 'Question 3') + res = self.client.post('/questions/create', json={**payload, 'image_path': '/uploads/questions/stem-1.png'}) + self.assertEqual(res.status_code, 200, res.text) + self.assertEqual(res.json()['image_path'], 'questions/stem-1.png') + paths = [p['image_path'] for p in self.client.get('/questions/images').json()] + self.assertEqual(paths, sorted(set(paths))) + self.assertIn('questions/answer-3.png', paths) + self.assertNotIn('questions/stem-4.png', paths) + self.assertNotIn('questions/stem-5.png', paths) + self.login(self.mod) + for field in ('image_path', 'explanation_image_path'): + for value in ([], {}, 17, '../escape'): + res = self.client.patch('/quizzes/1/questions/1', json={'question_text': 'Forged', field: value}) + self.assertEqual(res.status_code, 400, res.text) + self.assertEqual(self.db.get(Question, 1).question_text, 'Question 1') + self.embedding.assert_called_once() + + def test_same_site_urls_share_path_authorization_and_legacy_references(self): + with patch.object(settings, 'APP_URL', 'https://app.example.com'): + payload = {'question_text': 'Absolute image', 'options': ['yes', 'no'], 'correct_answer': 'yes'} + count = self.db.query(Question).count() + for url in ('https://app.example.com/uploads/questions/stem-4.png', + 'http://app.example.com/uploads/questions/answer-4.png'): + response = self.client.post('/questions/create', json={**payload, 'image_path': url}) + self.assertEqual(response.status_code, 403, response.text) + self.assertEqual(self.db.query(Question).count(), count) + response = self.client.patch('/questions/3', json={'question_text': 'Forged', 'explanation_image_path': url}) + self.assertEqual(response.status_code, 403, response.text) + self.assertEqual(self.db.get(Question, 3).question_text, 'Question 3') + response = self.client.post('/questions/create', json={**payload, + 'image_path': 'https://app.example.com/uploads/questions/stem-1.png'}) + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(response.json()['image_path'], 'questions/stem-1.png') + for url in ('https://external.example:bad/a.png', 'https://external.example:99999/a.png'): + response = self.client.patch('/questions/3', json={'image_path': url}) + self.assertEqual(response.status_code, 400, response.text) + response = self.client.patch('/questions/3', json={'image_path': 'https://external.example/figure.png'}) + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(response.json()['image_path'], 'https://external.example/figure.png') + self.db.get(Question, 2).image_path = 'https://app.example.com/uploads/legacy-public.svg' + self.file('legacy-public.svg') + self.db.commit() + self.client.headers.clear() + self.assertEqual(self.client.get('/uploads/legacy-public.svg').status_code, 401) + self.login(self.owner) + self.assertEqual(self.client.get('/uploads/legacy-public.svg').status_code, 200) + self.db.get(Question, 2).is_shared = 0 + self.db.commit() + self.assertEqual(self.client.get('/uploads/legacy-public.svg').status_code, 404) + self.enroll() + self.db.get(Question, 5).image_path = 'http://app.example.com/uploads/questions/stem-5.png' + self.db.commit() + self.assertEqual(self.client.get('/uploads/questions/stem-5.png').status_code, 200) + + def test_card_only_moderator_denial_admin_success_and_question_grant(self): + admin = builder.User(id=4, name='Admin', email='admin@example.com', hashed_password='unused', role='admin') + deck = FlashcardDeck(user_id=2, title='Private peer cards', is_shared=0) + self.db.add_all([admin, deck]) + self.db.flush() + card = Flashcard(deck_id=deck.id, front='Private', back='Private', image_path='cards/private.png') + self.db.add(card) + self.db.commit() + payload = {'question_text': 'Copy', 'options': ['yes', 'no'], 'correct_answer': 'yes'} + for path in ('cards/private.png', 'questions/2/card-only.png'): + card.image_path = path + self.file(path) + self.db.commit() + self.login(self.mod) + self.assertEqual(self.client.get(f'/flashcards/{deck.id}').status_code, 403) + self.assertEqual(self.client.get('/uploads/' + path).status_code, 404) + before = self.db.query(Question).count() + response = self.client.post('/questions/create', json={**payload, 'image_path': path}) + self.assertEqual(response.status_code, 403, response.text) + self.assertEqual(self.db.query(Question).count(), before) + response = self.client.patch('/questions/1', json={'explanation_image_path': path, 'question_text': 'Forged'}) + self.assertEqual(response.status_code, 403, response.text) + self.assertEqual(self.db.get(Question, 1).question_text, 'Question 1') + self.login(admin) + self.assertEqual(self.client.get(f'/flashcards/{deck.id}').status_code, 200) + self.assertEqual(self.client.get('/uploads/' + path).status_code, 200) + self.assertEqual(self.client.post('/questions/create', json={**payload, 'image_path': path}).status_code, 200) + # Even inside extraction directories, a card reference stays on card ACL. + self.login(self.mod) + card.image_path = 'images/doc_1/card-only.png' + self.file(card.image_path) + self.db.commit() + self.assertEqual(self.client.get('/uploads/images/doc_1/card-only.png').status_code, 404) + self.assertEqual(self.client.patch('/questions/1', json={'image_path': card.image_path}).status_code, 403) + self.login(admin) + self.assertEqual(self.client.get('/uploads/images/doc_1/card-only.png').status_code, 200) + # A real question reference independently gives its moderator access. + self.login(self.mod) + self.db.get(Question, 4).image_path = 'cards/moderated-question.png' + card.image_path = 'cards/moderated-question.png' + self.file(card.image_path) + self.db.commit() + self.assertEqual(self.client.get('/uploads/cards/moderated-question.png').status_code, 200) + self.assertEqual(self.client.post('/questions/create', json={**payload, 'image_path': card.image_path}).status_code, 200) + + def test_legacy_aliases_are_canonical_for_classification_and_permissions(self): + with patch.object(settings, 'APP_URL', 'https://app.example.com'): + self.file('legacy-private.svg') + self.file('course_files/card-only.svg') + question = self.db.get(Question, 3) + variants = [ + 'https://APP.EXAMPLE.COM/uploads/legacy-private.svg?v=1#view', + 'http://app.example.com:80/uploads/legacy-private.svg#part', + '/uploads/legacy-private.svg?size=1', + 'legacy-private.svg#part', + 'https://app%2Eexample.com/uploads/legacy-private.svg', + '/uploads/images/../legacy-private.svg', + 'https://app.example.com/uploads/%6Cegacy-private.svg', + r'https://app.example.com\uploads\legacy-private.svg?v=1', + ] + for value in variants: + question.image_path = value + self.db.commit() + self.client.headers.clear() + self.client.cookies.clear() + self.assertEqual(self.client.get('/uploads/legacy-private.svg?v=1').status_code, 401, value) + self.login(self.owner) + self.assertEqual(self.client.get('/uploads/legacy-private.svg?v=1').status_code, 200, value) + self.login(self.peer) + self.assertEqual(self.client.get('/uploads/legacy-private.svg').status_code, 404, value) + self.login(self.owner) + bank_paths = self.client.get('/questions/images').json() + self.assertIn({'image_path': 'legacy-private.svg', 'url': '/uploads/legacy-private.svg'}, bank_paths) + # Parsing a browser-normalized URL must not bypass checks on new writes. + self.login(self.peer) + for value in ('https://app%2Eexample.com/uploads/legacy-private.svg', + r'https://app.example.com\uploads\legacy-private.svg'): + response = self.client.patch('/questions/4', json={'image_path': value}) + self.assertEqual(response.status_code, 400, response.text) + # Even a legacy-LMS directory becomes protected when a private card references it. + deck = FlashcardDeck(user_id=1, title='Private owner cards', is_shared=0) + self.db.add(deck) + self.db.flush() + self.db.add(Flashcard(deck_id=deck.id, front='Private', back='Private', + image_path='https://APP.EXAMPLE.COM/uploads/course_files/card-only.svg?v=1#card')) + self.db.commit() + self.client.headers.clear() + self.assertEqual(self.client.get('/uploads/course_files/card-only.svg').status_code, 401) + self.login(self.owner) + self.assertEqual(self.client.get('/uploads/course_files/card-only.svg').status_code, 200) + self.login(self.mod) + self.assertEqual(self.client.get('/uploads/course_files/card-only.svg').status_code, 404) + self.assertEqual(self.client.patch('/questions/1', json={'image_path': 'course_files/card-only.svg'}).status_code, 403) + + def test_orphaned_files_do_not_become_anonymous_after_reference_deletion(self): + self.file('orphan.svg') + question = self.db.get(Question, 3) + question.image_path = 'orphan.svg' + self.db.commit() + self.assertEqual(self.client.get('/uploads/orphan.svg').status_code, 200) + self.db.delete(question) + self.db.commit() + self.client.headers.clear() + self.assertEqual(self.client.get('/uploads/orphan.svg').status_code, 401) + self.login(self.peer) + self.assertEqual(self.client.get('/uploads/orphan.svg').status_code, 404) + for prefix in ('course_files', 'course_thumbnails', 'scorm', 'certificates'): + self.file(f'{prefix}/unrelated.bin') + self.client.headers.clear() + self.assertEqual(self.client.get(f'/uploads/{prefix}/unrelated.bin').status_code, 200) + + def test_paths_svg_head_range_upload_and_legacy_policy(self): + res = self.client.post('/questions/upload-image', files={'file': ('test.svg', b'', 'image/svg+xml')}) + self.assertEqual(res.status_code, 200, res.text) + self.assertTrue(res.json()['image_path'].startswith('questions/1/')) + url = res.json()['url'] + res = self.client.get(url) + self.assertEqual(res.headers['x-content-type-options'], 'nosniff') + self.assertIn('sandbox', res.headers['content-security-policy']) + head = self.client.head(url) + self.assertEqual(head.status_code, 200) + self.assertEqual(head.content, b'') + self.assertEqual(head.headers['content-length'], str(len(res.content))) + ranged = self.client.get(url, headers={'Range': 'bytes=0-3'}) + # HTTP permits ignoring Range; pinned Starlette returns a full 200 here. + # Production byte-range delivery is checked through Nginx's native filter. + self.assertIn(ranged.status_code, (200, 206)) + self.assertEqual(ranged.content, res.content[:4] if ranged.status_code == 206 else res.content) + Path(self.tmp.name, 'questions', 'alias.png').symlink_to(Path(self.tmp.name, 'questions', 'stem-4.png')) + Path(self.tmp.name, 'questions', 'outside.png').symlink_to('/etc/passwd') + for path in ('questions/alias.png', 'questions/outside.png', 'questions/%2e%2e/escape', 'questions/%252e%252e/escape', 'questions/%5cescape', 'questions/missing.png', 'questions/' + 'x' * 300): + self.assertEqual(self.client.get('/uploads/' + path).status_code, 404, path) + self.file('scorm/test/index.html', b'Legacy SCORM') + self.client.headers.clear() + legacy = self.client.get('/uploads/scorm/test/index.html') + self.assertEqual(legacy.status_code, 200) + self.assertEqual(legacy.headers['cache-control'], 'private, no-store') + + +if __name__ == '__main__': + unittest.main() diff --git a/docs/quiz-revamp-progress.md b/docs/quiz-revamp-progress.md index 4c520f2..5b5f20f 100644 --- a/docs/quiz-revamp-progress.md +++ b/docs/quiz-revamp-progress.md @@ -55,6 +55,18 @@ Browser checks used a loopback-only fixture with synthetic accounts/questions an Proof images: [desktop](quiz-revamp/desktop-question.png), [study feedback](quiz-revamp/study-feedback.png), [mobile width](quiz-revamp/mobile-question.png). These are synthetic previews, not screenshots of deployed clinical content. +## Related-content privacy (under verification) + +Tutor authorization now precedes quota/model/AI work, attempt context must own the selected question, course answers require an authorized study/completed attempt unless privileged, and similarity context is filtered in SQL before ranking/LIMIT. Full prompts retain long stems and more than five options. + +Uploaded media moved from a public static mount to a permission-aware boundary. Browser/Markdown/Milkdown image loading continues through a Path=/uploads media-only cookie mirroring the existing JavaScript-readable token (not HttpOnly/XSS-hardening). API mutations remain bearer-only; stale refresh/error responses cannot switch or resurrect accounts. + +References authorize bytes: bank visibility, course stem/review rules, per-uploader drafts, document/extraction ownership, card owner/admin/shared rules, and moderator handling that respects card-only privacy. Legacy local URL spellings are canonicalized for classification and permissions. Attachment validation runs before mutation on create and both edit paths, so private files cannot be republished into shared content. New uploads live under questions/{user_id}/. SVG gets a sandbox CSP; Nginx no longer caches uploads and provides native byte ranges. + +Verification: 37 backend tests in the exact deployed image (new card-moderator, legacy-alias, orphan and same-site URL regressions), 69 frontend tests and production build, real Nginx 206/HEAD/416 with no-store/Vary/sandbox through the deployed frontend image, real pgvector eligibility-before-LIMIT, and synthetic browser owner/peer/logout/quiz-image checks. Follow-up independent review resolved both earlier blockers and accepted the work; see [privacy-review.md](quiz-revamp/privacy-review.md). + +Known boundaries: only course_files/course_thumbnails/scorm/certificates retain legacy LMS policy; already-cached public responses cannot be recalled by new no-store headers; downloaded offline content likewise. Privacy work is not committed or deployed yet. + ## Next Continue with the Orthobullets-inspired runner/results UI, question navigation and study tools; then article/subsection reading, linked flashcards, educator AI authoring and moderated comments. Complete related-content privacy work and end-to-end desktop/mobile validation before deployment. diff --git a/docs/quiz-revamp/privacy-logout.png b/docs/quiz-revamp/privacy-logout.png new file mode 100644 index 0000000000000000000000000000000000000000..3f6e70bda2acd71e0db0724d21e0137a757abedb GIT binary patch literal 9857 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 gO+i&^Yw0<@5HwpQAT_PIz&09-sgJ_x*i;-{X96 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 literal 0 HcmV?d00001 diff --git a/docs/quiz-revamp/privacy-peer.png b/docs/quiz-revamp/privacy-peer.png new file mode 100644 index 0000000000000000000000000000000000000000..faaad34c76f9b2a9b0b5cb1a98ceb7b96f0b2695 GIT binary patch literal 8745 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 literal 0 HcmV?d00001 diff --git a/docs/quiz-revamp/privacy-player.png b/docs/quiz-revamp/privacy-player.png new file mode 100644 index 0000000000000000000000000000000000000000..5191cdba7916113628afdfceb1a33817d923292e GIT binary patch literal 86314 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 { return config }) +function isCurrentRequest(config) { + const token = localStorage.getItem('token') + return !!token && config?.headers?.Authorization === `Bearer ${token}` +} + api.interceptors.response.use( (response) => { - // Sliding token expiration: if server sends new token, update localStorage const newToken = response.headers['x-new-token'] - if (newToken) { - localStorage.setItem('token', newToken) - } + if (newToken && isCurrentRequest(response.config)) setToken(newToken) return response }, (error) => { - // Check for token refresh even on error responses - const newToken = error.response?.headers?.['x-new-token'] - if (newToken) { - localStorage.setItem('token', newToken) - } - const url = error.config?.url || '' - // Don't redirect to login from the login/register/verify endpoints themselves + // Don't redirect from login/register/verify endpoints themselves. const isAuthEndpoint = url.includes('/auth/login') || url.includes('/auth/register') || url.includes('/auth/verify') || url.includes('/auth/reset') || url.includes('/auth/forgot') - if (error.response?.status === 401 && !isAuthEndpoint) { - localStorage.removeItem('token') - window.location.href = '/login' - } - // Unverified email — clear token and redirect to login (will show resend option) - if (error.response?.status === 403 && error.response?.headers?.['x-unverified'] === 'true') { - localStorage.removeItem('token') - window.location.href = '/login' + if (isCurrentRequest(error.config)) { + if ((error.response?.status === 401 && !isAuthEndpoint) || + (error.response?.status === 403 && error.response?.headers?.['x-unverified'] === 'true')) { + setToken(null) + window.location.href = '/login' + } else { + const newToken = error.response?.headers?.['x-new-token'] + if (newToken) setToken(newToken) + } } return Promise.reject(error) } diff --git a/frontend/src/api/client.test.js b/frontend/src/api/client.test.js new file mode 100644 index 0000000..991d324 --- /dev/null +++ b/frontend/src/api/client.test.js @@ -0,0 +1,77 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { setToken, syncMediaToken } from '../utils/token' +import api from './client' + +beforeEach(() => { + localStorage.clear() + vi.restoreAllMocks() +}) + +it('sets, clears and bootstraps the media-only cookie with the stored token', () => { + const cookie = vi.spyOn(document, 'cookie', 'set') + setToken('token-a') + expect(localStorage.getItem('token')).toBe('token-a') + expect(cookie).toHaveBeenLastCalledWith(expect.stringContaining('pedshub_media=token-a; Path=/uploads; SameSite=Strict')) + expect(cookie.mock.calls.at(-1)[0].includes('; Secure')).toBe(window.location.protocol === 'https:') + setToken(null) + expect(localStorage.getItem('token')).toBeNull() + expect(cookie).toHaveBeenLastCalledWith(expect.stringContaining('Max-Age=0')) + localStorage.setItem('token', 'existing') + syncMediaToken() + expect(cookie).toHaveBeenLastCalledWith(expect.stringContaining('pedshub_media=existing;')) +}) + +function deferredRequest() { + let finish, started + const ready = new Promise(resolve => { started = resolve }) + const promise = api.get('/synthetic', { adapter: config => new Promise(resolve => { + finish = (status = 200, headers = {}) => resolve({ status, headers, data: {}, config }) + started() + }) }) + return { promise, ready, finish: (...args) => finish(...args) } +} + +it('refreshes both stores only for the response original bearer', async () => { + const cookie = vi.spyOn(document, 'cookie', 'set') + setToken('old') + const req = deferredRequest() + await req.ready + req.finish(200, { 'x-new-token': 'new' }) + await req.promise + expect(localStorage.getItem('token')).toBe('new') + expect(cookie).toHaveBeenLastCalledWith(expect.stringContaining('pedshub_media=new;')) +}) + +it.each([null, 'different-account'])('late refresh cannot restore credentials after switching to %s', async current => { + setToken('old') + const req = deferredRequest() + await req.ready + setToken(current) + req.finish(200, { 'x-new-token': 'stale-refresh' }) + await req.promise + expect(localStorage.getItem('token')).toBe(current) +}) + +it.each([401, 403, 500])('late error %s cannot refresh or clear a new account', async status => { + setToken('current') + const error = { config: { url: '/synthetic', headers: { Authorization: 'Bearer old' } }, + response: { status, headers: { 'x-new-token': 'stale', 'x-unverified': 'true' } } } + const rejected = api.interceptors.response.handlers[0].rejected + await expect(rejected(error)).rejects.toBe(error) + expect(localStorage.getItem('token')).toBe('current') + setToken(null) + await expect(rejected(error)).rejects.toBe(error) + expect(localStorage.getItem('token')).toBeNull() +}) + +it('keeps login redirect exclusions and refreshes an authenticated ordinary error', async () => { + setToken('current') + const rejected = api.interceptors.response.handlers[0].rejected + const error = { config: { url: '/auth/login', headers: { Authorization: 'Bearer current' } }, response: { status: 401, headers: {} } } + await expect(rejected(error)).rejects.toBe(error) + expect(localStorage.getItem('token')).toBe('current') + error.config.url = '/synthetic' + error.response = { status: 500, headers: { 'x-new-token': 'new' } } + await expect(rejected(error)).rejects.toBe(error) + expect(localStorage.getItem('token')).toBe('new') +}) diff --git a/frontend/src/components/TeachChat.jsx b/frontend/src/components/TeachChat.jsx index c082502..98e7daa 100644 --- a/frontend/src/components/TeachChat.jsx +++ b/frontend/src/components/TeachChat.jsx @@ -3,6 +3,7 @@ import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import rehypeRaw from 'rehype-raw' import api from '../api/client' +import { markdownImageUrl } from '../utils/uploads' /** * TeachChat — slide-in AI tutor drawer for study mode. @@ -14,7 +15,7 @@ import api from '../api/client' * Mobile — bottom sheet, 70vh * Desktop — right-side panel inside quiz-layout, 320px wide */ -export default function TeachChat({ question, elevated = false }) { +export default function TeachChat({ question, attemptId, elevated = false }) { const [open, setOpen] = useState(false) const [messages, setMessages] = useState([]) // {role, content, suggestions?} const [input, setInput] = useState('') @@ -39,7 +40,7 @@ export default function TeachChat({ question, elevated = false }) { setMessages([]) setInput('') setNoModel(false) - }, [question?.id]) + }, [question?.id, attemptId]) // Scroll to bottom when messages change useEffect(() => { @@ -64,6 +65,7 @@ export default function TeachChat({ question, elevated = false }) { try { const res = await api.post('/teach/chat', { question_id: question.id, + attempt_id: attemptId ?? null, messages: next, model_id: selectedModelId || null, }) @@ -201,6 +203,7 @@ export default function TeachChat({ question, elevated = false }) { }}> {m.role === 'assistant' ? , p: ({children}) =>

{children}

, ul: ({children}) =>
    {children}
, ol: ({children}) =>
    {children}
, diff --git a/frontend/src/components/TeachChat.test.jsx b/frontend/src/components/TeachChat.test.jsx new file mode 100644 index 0000000..0d6d728 --- /dev/null +++ b/frontend/src/components/TeachChat.test.jsx @@ -0,0 +1,25 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import TeachChat from './TeachChat' +import api from '../api/client' + +vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn() } })) +beforeEach(() => { + vi.resetAllMocks() + Element.prototype.scrollIntoView = vi.fn() + api.get.mockResolvedValue({ data: [] }) + api.post.mockResolvedValue({ data: { reply: '![Local](/uploads/questions/answer.png) ![External](https://external.example/a.png)', suggestions: [] } }) +}) + +it.each([undefined, 50])('sends optional attempt %s and renders local markdown media with context', async attemptId => { + render() + await userEvent.click(screen.getByTitle('Ask AI tutor')) + await userEvent.type(screen.getByPlaceholderText(/Ask a question/), 'Explain this') + await userEvent.click(screen.getByRole('button', { name: 'Send' })) + await waitFor(() => expect(api.post).toHaveBeenCalledWith('/teach/chat', { + question_id: 5, attempt_id: attemptId ?? null, messages: [{ role: 'user', content: 'Explain this' }], model_id: null, + })) + expect(await screen.findByAltText('Local')).toHaveAttribute('src', `/uploads/questions/answer.png${attemptId ? '?attempt_id=50' : ''}`) + expect(screen.getByAltText('External')).toHaveAttribute('src', 'https://external.example/a.png') +}) diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx index 986cea5..32023ab 100644 --- a/frontend/src/context/AuthContext.jsx +++ b/frontend/src/context/AuthContext.jsx @@ -1,5 +1,6 @@ import { createContext, useContext, useState, useEffect } from 'react' import api from '../api/client' +import { setToken } from '../utils/token' const AuthContext = createContext(null) @@ -12,7 +13,7 @@ export function AuthProvider({ children }) { if (token) { api.get('/auth/me') .then(res => setUser(res.data)) - .catch(() => localStorage.removeItem('token')) + .catch(() => { if (localStorage.getItem('token') === token) setToken(null) }) .finally(() => setLoading(false)) } else { setLoading(false) @@ -21,21 +22,21 @@ export function AuthProvider({ children }) { const login = async (email, password) => { const res = await api.post('/auth/login', { email, password }) - localStorage.setItem('token', res.data.access_token) + setToken(res.data.access_token) const me = await api.get('/auth/me') setUser(me.data) return me.data } const loginWithToken = async (token) => { - localStorage.setItem('token', token) + setToken(token) const me = await api.get('/auth/me') setUser(me.data) return me.data } const logout = () => { - localStorage.removeItem('token') + setToken(null) setUser(null) } diff --git a/frontend/src/context/AuthContext.test.jsx b/frontend/src/context/AuthContext.test.jsx new file mode 100644 index 0000000..d05a150 --- /dev/null +++ b/frontend/src/context/AuthContext.test.jsx @@ -0,0 +1,38 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { AuthProvider, useAuth } from './AuthContext' +import api from '../api/client' + +vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn() } })) +beforeEach(() => { + localStorage.clear() + vi.restoreAllMocks() + api.get.mockResolvedValue({ data: { id: 1, name: 'Synthetic' } }) + api.post.mockResolvedValue({ data: { access_token: 'password-token' } }) +}) +function Controls() { + const { login, loginWithToken, logout, user } = useAuth() + return <>{user?.name} +} +it.each(['Password', 'SSO'])('mirrors %s login and synchronously clears logout', async method => { + const cookie = vi.spyOn(document, 'cookie', 'set') + render() + await userEvent.click(screen.getByText(method)) + await screen.findByText('Synthetic') + const token = method === 'Password' ? 'password-token' : 'sso-token' + expect(localStorage.getItem('token')).toBe(token) + expect(cookie).toHaveBeenLastCalledWith(expect.stringContaining(`pedshub_media=${token}; Path=/uploads`)) + await userEvent.click(screen.getByText('Logout')) + expect(localStorage.getItem('token')).toBeNull() + expect(cookie).toHaveBeenLastCalledWith(expect.stringContaining('Max-Age=0')) +}) +it('bootstrap failure cannot clear a switched account', async () => { + let reject + localStorage.setItem('token', 'bootstrap-token') + api.get.mockImplementationOnce(() => new Promise((_, fail) => { reject = fail })) + render() + localStorage.setItem('token', 'other-account') + reject(new Error('Old request failed')) + await waitFor(() => expect(localStorage.getItem('token')).toBe('other-account')) +}) diff --git a/frontend/src/pages/QuizEditPage.jsx b/frontend/src/pages/QuizEditPage.jsx index 27b1b65..27ffb38 100644 --- a/frontend/src/pages/QuizEditPage.jsx +++ b/frontend/src/pages/QuizEditPage.jsx @@ -1,16 +1,11 @@ import { useState, useEffect } from 'react' import { useParams, useNavigate, Link } from 'react-router-dom' import api from '../api/client' +import { uploadUrl } from '../utils/uploads' import ConfirmButton from '../components/ConfirmButton' const LETTERS = ['A', 'B', 'C', 'D', 'E', 'F'] -function uploadUrl(path) { - if (!path) return '' - if (/^https?:\/\//i.test(path) || path.startsWith('/uploads/')) return path - return `/uploads/${path}` -} - function ImagePreview({ label, path }) { if (!path) return null return ( diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index fe528c3..13769db 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -1,3 +1,4 @@ +import { uploadUrl } from '../utils/uploads' import { useState, useEffect, useRef, useCallback, lazy, Suspense } from 'react' import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom' import { useAuth } from '../context/AuthContext' @@ -1174,7 +1175,7 @@ const timerStarted = timeLeft !== null {current.image_path && ( )} @@ -1191,7 +1192,7 @@ const timerStarted = timeLeft !== null
e.stopPropagation()}> Expanded question illustrationExplanation: {current.explanation}} {current.explanation_image_path && ( )} @@ -1318,7 +1319,7 @@ const timerStarted = timeLeft !== null {/* AI tutor — only in study mode, lazy-loaded */} {isStudy && current && ( - + )}
diff --git a/frontend/src/pages/QuizPage.test.jsx b/frontend/src/pages/QuizPage.test.jsx index ab3333b..f0c89e0 100644 --- a/frontend/src/pages/QuizPage.test.jsx +++ b/frontend/src/pages/QuizPage.test.jsx @@ -8,7 +8,7 @@ import api from '../api/client' vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), delete: vi.fn() } })) vi.mock('../context/AuthContext', () => ({ useAuth: () => ({ user: { id: 1, name: 'Learner', role: 'user' } }) })) vi.mock('../components/MyNote', () => ({ default: () => null })) -vi.mock('../components/TeachChat', () => ({ default: () =>
Study tutor
})) +vi.mock('../components/TeachChat', () => ({ default: ({ attemptId }) =>
Study tutor {attemptId}
})) const questions = [ { id: 1, question_text: 'Full first clinical question.', question_type: 'mcq', options: ['First answer', 'Second answer'], category_breadcrumbs: [{ id: 10, name: 'Pediatrics' }, { id: 11, name: 'Neonatology' }] }, @@ -148,6 +148,24 @@ describe('quiz player', () => { await waitFor(() => expect(api.post).toHaveBeenCalledWith('/favorites', { question_id: 1 })) }) + it('passes its attempt to tutor, question, expanded and explanation images', async () => { + const originalGet = api.get.getMockImplementation() + api.get.mockImplementation(async (...args) => { + const res = await originalGet(...args) + if (res.data?.questions) res.data.questions[0] = { ...res.data.questions[0], image_path: 'questions/stem.png', explanation_image_path: '/uploads/questions/answer.png' } + return res + }) + await begin() + expect(await screen.findByTestId('tutor-context')).toHaveTextContent('50') + expect(screen.getByAltText('Question illustration')).toHaveAttribute('src', '/uploads/questions/stem.png?attempt_id=50') + await userEvent.click(screen.getByAltText('Question illustration')) + expect(screen.getByAltText('Expanded question illustration')).toHaveAttribute('src', '/uploads/questions/stem.png?attempt_id=50') + await userEvent.click(screen.getByRole('button', { name: 'Close expanded image' })) + fireEvent.keyDown(window, { key: '1' }) + fireEvent.keyDown(window, { key: 'Enter' }) + expect(await screen.findByAltText('Explanation illustration')).toHaveAttribute('src', '/uploads/questions/answer.png?attempt_id=50') + }) + it('offers a functional calculator without hijacking input shortcuts', async () => { await begin() await userEvent.click(screen.getByRole('button', { name: /Calculator/ })) diff --git a/frontend/src/pages/ResultsPage.jsx b/frontend/src/pages/ResultsPage.jsx index 9d918ec..dc2a42d 100644 --- a/frontend/src/pages/ResultsPage.jsx +++ b/frontend/src/pages/ResultsPage.jsx @@ -1,3 +1,4 @@ +import { uploadUrl } from '../utils/uploads' import { useState, useEffect } from 'react' import { useParams, useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom' import api from '../api/client' @@ -178,7 +179,7 @@ export default function ResultsPage() { - {ans.image_path && Question illustration} + {ans.image_path && Question illustration} {/* Numbered answers */} {ans.options && ans.options.length > 0 && (
@@ -234,7 +235,7 @@ export default function ResultsPage() { {ans.explanation &&
{ans.explanation}
} {ans.explanation_image_path && (
- Explanation illustration e.currentTarget.style.display = 'none'} />
diff --git a/frontend/src/utils/token.js b/frontend/src/utils/token.js new file mode 100644 index 0000000..0b00924 --- /dev/null +++ b/frontend/src/utils/token.js @@ -0,0 +1,10 @@ +// Media-only mirror of the existing JS-readable token, NOT HttpOnly/XSS hardening. +export function setToken(token) { + if (token) localStorage.setItem('token', token) + else localStorage.removeItem('token') + document.cookie = `pedshub_media=${token ? encodeURIComponent(token) : ''}; Path=/uploads; SameSite=Strict${window.location.protocol === 'https:' ? '; Secure' : ''}${token ? '' : '; Max-Age=0'}` +} + +export function syncMediaToken() { + setToken(localStorage.getItem('token')) +} diff --git a/frontend/src/utils/uploads.js b/frontend/src/utils/uploads.js new file mode 100644 index 0000000..41211ad --- /dev/null +++ b/frontend/src/utils/uploads.js @@ -0,0 +1,21 @@ +// Keep the editor's relative/absolute URL behavior; attempt context is local only. +export function uploadUrl(path, attemptId) { + if (!path) return '' + const value = /^(https?:)?\/\//i.test(path) || path.startsWith('/uploads/') ? path : `/uploads/${path}` + let url + try { url = new URL(value, window.location.origin) } + catch { return value } // Legacy invalid URLs must not crash the question/player. + if (attemptId != null && url.origin === window.location.origin && url.pathname.startsWith('/uploads/')) { + url.searchParams.set('attempt_id', attemptId) + return `${url.pathname}${url.search}${url.hash}` + } + return value +} + +export function markdownImageUrl(src, attemptId) { + if (!src) return src + let url + try { url = new URL(src, window.location.origin) } + catch { return src } + return url.origin === window.location.origin && url.pathname.startsWith('/uploads/') ? uploadUrl(src, attemptId) : src +} diff --git a/frontend/src/utils/uploads.test.js b/frontend/src/utils/uploads.test.js new file mode 100644 index 0000000..30639d9 --- /dev/null +++ b/frontend/src/utils/uploads.test.js @@ -0,0 +1,24 @@ +import { expect, it } from 'vitest' +import { uploadUrl, markdownImageUrl } from './uploads' + +it('leaves malformed legacy image URLs to normal browser image-error handling', () => { + for (const url of ['https://', 'https://host:bad/a.png', 'http://[invalid']) { + expect(uploadUrl(url, 12)).toBe(url) + expect(markdownImageUrl(url, 12)).toBe(url) + } +}) + +it('uses the existing upload format and adds attempt context only to same-origin uploads', () => { + expect(uploadUrl('questions/test.png')).toBe('/uploads/questions/test.png') + expect(uploadUrl('/uploads/questions/test.png', 12)).toBe('/uploads/questions/test.png?attempt_id=12') + expect(uploadUrl('questions/test.png?size=1#view', 12)).toBe('/uploads/questions/test.png?size=1&attempt_id=12#view') + expect(uploadUrl(`${window.location.origin}/uploads/test.png?attempt_id=1`, 12)).toBe('/uploads/test.png?attempt_id=12') + for (const url of ['https://external.example/uploads/test.png', '//external.example/uploads/test.png']) { + expect(uploadUrl(url, 12)).toBe(url) + expect(markdownImageUrl(url, 12)).toBe(url) + } + expect(markdownImageUrl('/uploads/test.png', 12)).toBe('/uploads/test.png?attempt_id=12') + expect(markdownImageUrl('/other/test.png', 12)).toBe('/other/test.png') + expect(markdownImageUrl('https://external.example/test.png', 12)).toBe('https://external.example/test.png') + expect(uploadUrl(null, 12)).toBe('') +})