Key points on questions link into article sections (AMBOSS-style) with samples; difficulty tagging with builder/bank filters; adaptive session algorithm prefers unanswered questions then recycles older incorrect ones, weakest categories first with damping; question create/edit is now admin/educator only; expired exams no longer auto-submit on resume; exam suspend messaging updated. Migrations k4f5a6b7c819, l5a6b7c8d920, m6a7b8c9d031. 63 backend and 97 frontend tests pass.
201 lines
8.4 KiB
Python
201 lines
8.4 KiB
Python
"""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, general_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)
|
|
refs = references(db, path)
|
|
if refs:
|
|
ids = [question.id for question in refs]
|
|
if user.is_moderator:
|
|
# Course-only media can never be republished into the general bank.
|
|
allowed = db.query(Question.id).filter(
|
|
Question.id.in_(ids), general_question_predicate(),
|
|
).first() is not None
|
|
else:
|
|
allowed = db.query(Question.id).filter(
|
|
Question.id.in_(ids), bank_question_predicate(user),
|
|
).first() is not None
|
|
else:
|
|
allowed = owns_source(db, path, user, cards) or card_access(db, cards, user)
|
|
if not allowed:
|
|
raise HTTPException(403, "Image is not available for attachment")
|
|
result[field] = path
|
|
return result
|