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.
This commit is contained in:
parent
c605178cbc
commit
1ce3eec7cb
28 changed files with 1002 additions and 92 deletions
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}]
|
||||
|
|
|
|||
48
backend/app/routers/uploads.py
Normal file
48
backend/app/routers/uploads.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
193
backend/app/utils/upload_access.py
Normal file
193
backend/app/utils/upload_access.py
Normal file
|
|
@ -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
|
||||
415
backend/tests/test_related_privacy.py
Normal file
415
backend/tests/test_related_privacy.py
Normal file
|
|
@ -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'<svg xmlns="http://www.w3.org/2000/svg"/>', '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'<html>Legacy SCORM</html>')
|
||||
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()
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
BIN
docs/quiz-revamp/privacy-logout.png
Normal file
BIN
docs/quiz-revamp/privacy-logout.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.6 KiB |
BIN
docs/quiz-revamp/privacy-owner.png
Normal file
BIN
docs/quiz-revamp/privacy-owner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
docs/quiz-revamp/privacy-peer.png
Normal file
BIN
docs/quiz-revamp/privacy-peer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.5 KiB |
BIN
docs/quiz-revamp/privacy-player.png
Normal file
BIN
docs/quiz-revamp/privacy-player.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
11
docs/quiz-revamp/privacy-review.md
Normal file
11
docs/quiz-revamp/privacy-review.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
## Review — follow-up validation (static, read-only)
|
||||
|
||||
- **Finding 1 — RESOLVED.** `owns_source` (`backend/app/utils/upload_access.py:115–127`) no longer treats every file as moderator-owned. The moderator grant is now `user.is_moderator and not cards and path.startswith('questions/')` (line 121), so a live card reference (`cards` non-empty from `card_deck_ids`) forces the card ACL path: `card_access` (lines 130–134) requires `user_id == user.id` / `is_shared == 1` / admin — a non-owner moderator fails. Reads (`can_read_upload`, lines 137–152) and attachments (`validate_image_attachments`, lines 183–188) both fall through to `references()` only when the `questions/` and `images/doc_` grants do not apply, so moderator attachment of a card-only file returns 403. Admin still succeeds via `owns_source:116`. An independent question reference grants moderator access because `require_question_access` (`backend/app/utils/quiz_access.py:69–90`) returns for moderators, and `validate_image_attachments:186–187` uses `bool(ids)` for moderators. Covered by `test_card_only_moderator_denial_admin_success_and_question_grant` (`backend/tests/test_related_privacy.py`).
|
||||
|
||||
- **Finding 2 — RESOLVED.** `stored_upload_path` (`upload_access.py:60–85`) canonicalizes case (`_authority` lowercases host), default port 80/443, query/fragment (dropped via `urlsplit`), percent (`unquote` on path/host), backslash (`replace('\\','/')`), and `..` (`posixpath.normpath`), and rejects anything that does not normalize back under `/uploads/` — so hostile out-of-root paths return `None`. Both classification (`references`/`card_deck_ids`/`document_for_file` compare canonicalized stored values to the route path) and permission checks use this. Anonymous access hits `get_current_user("", db)` → 401 (`uploads.py:37–41`); owner success and peer 404 covered by `test_legacy_aliases_are_canonical_for_classification_and_permissions`. New-write strictness is unchanged: `validate_image_attachments:168–175` rejects query/fragment, `%` in netloc, backslash, and bad ports on absolute URLs, and `local_upload_path:24–31` rejects `\ % ? # :`, control chars, and `..`/empty parts. The image chooser dedupes via `stored_upload_path(row.path) or row.path` in a set (`questions.py:445–447`).
|
||||
|
||||
- **Orphan/LMS handling — RESOLVED.** `protected` (`uploads.py:35–36`) defaults non-LMS-prefix paths to private, and `document_for_file`/`questions`/`cards` force protection even inside a legacy prefix. Unreferenced `course_files|course_thumbnails|scorm|certificates` files stay anonymous (test `test_orphaned_files...`). Hostile paths can't escape: `upload_file:34–47` rejects symlinks and resolves `is_relative_to(root)`; `stored_upload_path` normalizes `..` before the `/uploads/` prefix check.
|
||||
|
||||
- **Note (not a blocker):** `owns_source:123–126` grants moderators the `images/doc_N/` branch without checking `cards`, unlike the card-aware `questions/` clause. Currently unreachable as a leak because no code path writes `Flashcard.image_path` (generation at `tasks/quiz_tasks.py:655–660` sets none; `CardEdit` has only front/back), and moderators already hold document access. One-line hardening when cards get images: `if match and not cards:`.
|
||||
|
||||
- **Residual risks (unverified by me):** I ran no tests/commands/live DB — static only. Parent re-verified 37 backend tests, 69 frontend tests/build, Nginx 206/HEAD/416 with no-store/Vary/SVG sandbox, and pgvector eligibility-before-LIMIT; those remain parent-attested, not re-run here. Full-table scans in `references`/`card_deck_ids`/`document_for_file` per request are an acknowledged perf ceiling (`upload_access.py:88`). The `pedshub_media` cookie is not HttpOnly (deliberate, `frontend/src/utils/token.js`). No blockers remain for the two P1 findings.
|
||||
|
|
@ -42,8 +42,9 @@ server {
|
|||
proxy_pass $backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
add_header Cache-Control "public, max-age=86400";
|
||||
proxy_force_ranges on; # Pinned backend FileResponse does not implement byte ranges.
|
||||
# Local add_header prevents inheriting the app CSP; preserve backend SVG sandbox.
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
}
|
||||
|
||||
# Uploaded images proxy to backend
|
||||
|
|
@ -52,8 +53,9 @@ server {
|
|||
set $backend http://backend:8000;
|
||||
proxy_pass $backend;
|
||||
proxy_set_header Host $host;
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
# Backend owns private/no-store and Vary; never cache protected media.
|
||||
proxy_force_ranges on;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
}
|
||||
|
||||
# SPA fallback
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import axios from 'axios'
|
||||
import { setToken, syncMediaToken } from '../utils/token'
|
||||
|
||||
syncMediaToken()
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
|
|
@ -12,34 +15,31 @@ api.interceptors.request.use((config) => {
|
|||
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)
|
||||
}
|
||||
|
|
|
|||
77
frontend/src/api/client.test.js
Normal file
77
frontend/src/api/client.test.js
Normal file
|
|
@ -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')
|
||||
})
|
||||
|
|
@ -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'
|
||||
? <ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]} components={{
|
||||
img: ({node, src, ...props}) => <img {...props} src={markdownImageUrl(src, attemptId)} />,
|
||||
p: ({children}) => <p style={{margin: '0 0 6px'}}>{children}</p>,
|
||||
ul: ({children}) => <ul style={{margin: '4px 0', paddingLeft: 18}}>{children}</ul>,
|
||||
ol: ({children}) => <ol style={{margin: '4px 0', paddingLeft: 18}}>{children}</ol>,
|
||||
|
|
|
|||
25
frontend/src/components/TeachChat.test.jsx
Normal file
25
frontend/src/components/TeachChat.test.jsx
Normal file
|
|
@ -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: ' ', suggestions: [] } })
|
||||
})
|
||||
|
||||
it.each([undefined, 50])('sends optional attempt %s and renders local markdown media with context', async attemptId => {
|
||||
render(<TeachChat question={{ id: 5, question_text: 'Complete question' }} attemptId={attemptId} />)
|
||||
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')
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
38
frontend/src/context/AuthContext.test.jsx
Normal file
38
frontend/src/context/AuthContext.test.jsx
Normal file
|
|
@ -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 <><span>{user?.name}</span><button onClick={() => login('synthetic@example.com', 'unused')}>Password</button><button onClick={() => loginWithToken('sso-token')}>SSO</button><button onClick={logout}>Logout</button></>
|
||||
}
|
||||
it.each(['Password', 'SSO'])('mirrors %s login and synchronously clears logout', async method => {
|
||||
const cookie = vi.spyOn(document, 'cookie', 'set')
|
||||
render(<AuthProvider><Controls /></AuthProvider>)
|
||||
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(<AuthProvider><Controls /></AuthProvider>)
|
||||
localStorage.setItem('token', 'other-account')
|
||||
reject(new Error('Old request failed'))
|
||||
await waitFor(() => expect(localStorage.getItem('token')).toBe('other-account'))
|
||||
})
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
|||
</span>
|
||||
{current.image_path && (
|
||||
<button className="question-image-preview" onClick={() => { setImageZoom(1); setExpandedImagePath(current.image_path) }} title="Expand image" type="button">
|
||||
<img src={`/uploads/${current.image_path}`} alt="Question illustration"
|
||||
<img src={uploadUrl(current.image_path, attemptId)} alt="Question illustration"
|
||||
onError={e => e.currentTarget.closest('button').style.display = 'none'} />
|
||||
</button>
|
||||
)}
|
||||
|
|
@ -1191,7 +1192,7 @@ const timerStarted = timeLeft !== null
|
|||
<button className="image-lightbox-close" onClick={() => setExpandedImagePath('')} type="button" aria-label="Close expanded image">×</button>
|
||||
<div className="image-lightbox-viewport" onClick={e => e.stopPropagation()}>
|
||||
<img
|
||||
src={`/uploads/${expandedImagePath}`}
|
||||
src={uploadUrl(expandedImagePath, attemptId)}
|
||||
alt="Expanded question illustration"
|
||||
style={{
|
||||
maxWidth: imageZoom === 1 ? 'min(100%, 1100px)' : 'none',
|
||||
|
|
@ -1265,7 +1266,7 @@ const timerStarted = timeLeft !== null
|
|||
{current.explanation && <><strong>Explanation:</strong> {current.explanation}</>}
|
||||
{current.explanation_image_path && (
|
||||
<button className="question-image-preview" onClick={() => { setImageZoom(1); setExpandedImagePath(current.explanation_image_path) }} title="Expand explanation image" type="button" style={{ marginTop: 12 }}>
|
||||
<img src={`/uploads/${current.explanation_image_path}`} alt="Explanation illustration"
|
||||
<img src={uploadUrl(current.explanation_image_path, attemptId)} alt="Explanation illustration"
|
||||
onError={e => e.currentTarget.closest('button').style.display = 'none'} />
|
||||
</button>
|
||||
)}
|
||||
|
|
@ -1318,7 +1319,7 @@ const timerStarted = timeLeft !== null
|
|||
{/* AI tutor — only in study mode, lazy-loaded */}
|
||||
{isStudy && current && (
|
||||
<Suspense fallback={null}>
|
||||
<TeachChat question={current} />
|
||||
<TeachChat question={current} attemptId={attemptId} />
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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: () => <div>Study tutor</div> }))
|
||||
vi.mock('../components/TeachChat', () => ({ default: ({ attemptId }) => <div data-testid="tutor-context">Study tutor {attemptId}</div> }))
|
||||
|
||||
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/ }))
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</span>
|
||||
</div>
|
||||
|
||||
{ans.image_path && <img src={`/uploads/${ans.image_path}`} alt="Question illustration" style={{ maxWidth: '100%', maxHeight: 360, marginBottom: 20 }} />}
|
||||
{ans.image_path && <img src={uploadUrl(ans.image_path, id)} alt="Question illustration" style={{ maxWidth: '100%', maxHeight: 360, marginBottom: 20 }} />}
|
||||
{/* Numbered answers */}
|
||||
{ans.options && ans.options.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
|
||||
|
|
@ -234,7 +235,7 @@ export default function ResultsPage() {
|
|||
{ans.explanation && <div style={{ marginTop: 8 }}>{ans.explanation}</div>}
|
||||
{ans.explanation_image_path && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<img src={`/uploads/${ans.explanation_image_path}`} alt="Explanation illustration"
|
||||
<img src={uploadUrl(ans.explanation_image_path, id)} alt="Explanation illustration"
|
||||
style={{ maxWidth: '100%', maxHeight: 320, borderRadius: 8, border: '1px solid var(--border)' }}
|
||||
onError={e => e.currentTarget.style.display = 'none'} />
|
||||
</div>
|
||||
|
|
|
|||
10
frontend/src/utils/token.js
Normal file
10
frontend/src/utils/token.js
Normal file
|
|
@ -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'))
|
||||
}
|
||||
21
frontend/src/utils/uploads.js
Normal file
21
frontend/src/utils/uploads.js
Normal file
|
|
@ -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
|
||||
}
|
||||
24
frontend/src/utils/uploads.test.js
Normal file
24
frontend/src/utils/uploads.test.js
Normal file
|
|
@ -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('')
|
||||
})
|
||||
Loading…
Reference in a new issue