pdf-quiz-generator/backend/app/utils/upload_access.py
Daniel 91ec6a501c feat: key points smart links, difficulty tags, adaptive sessions, educator-only question management
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.
2026-09-09 02:26:45 +02:00

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