- attempts.py: dashboard quiz_stats now queries by attempted quizzes not created quizzes — regular users now see per-quiz breakdown correctly - documents.py: moderators/admins can fetch documents by ID (were getting 404 despite seeing them in the list); also restrict section creation and delete to moderators via require_moderator - nextcloud.py: sanitise filename in Content-Disposition header (strip quotes, backslashes, newlines to prevent header injection) - UploadPage.jsx: importing from Nextcloud no longer switches tab to local, preserving the selected-file info display - ResultsPage.jsx: fill-blank correct answers now show correct-bg (green) instead of always showing wrong-bg (red) Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
156 lines
5.8 KiB
Python
156 lines
5.8 KiB
Python
"""Nextcloud WebDAV proxy — avoids browser CORS issues."""
|
|
import io
|
|
from xml.etree import ElementTree as ET
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from fastapi.responses import StreamingResponse
|
|
from pydantic import BaseModel
|
|
|
|
from app.models.user import User
|
|
from app.utils.auth import require_moderator
|
|
|
|
router = APIRouter()
|
|
|
|
DAV_PROPFIND = b"""<?xml version="1.0"?>
|
|
<d:propfind xmlns:d="DAV:">
|
|
<d:prop>
|
|
<d:displayname/>
|
|
<d:getcontenttype/>
|
|
<d:getcontentlength/>
|
|
<d:resourcetype/>
|
|
</d:prop>
|
|
</d:propfind>"""
|
|
|
|
|
|
class NCRequest(BaseModel):
|
|
server: str
|
|
username: str
|
|
password: str
|
|
path: str = "/"
|
|
|
|
|
|
def _dav_url(server: str, username: str, path: str) -> str:
|
|
base = server.rstrip("/")
|
|
p = path.lstrip("/")
|
|
return f"{base}/remote.php/dav/files/{username}/{p}"
|
|
|
|
|
|
def _parse_propfind(xml_bytes: bytes, base_path: str) -> list[dict]:
|
|
"""Parse WebDAV PROPFIND response into a list of file/folder dicts."""
|
|
ns = {"d": "DAV:"}
|
|
tree = ET.fromstring(xml_bytes)
|
|
items = []
|
|
for response in tree.findall("d:response", ns):
|
|
href = (response.findtext("d:href", "", ns) or "").rstrip("/")
|
|
# Skip the directory itself
|
|
props = response.find("d:propstat/d:prop", ns)
|
|
if props is None:
|
|
continue
|
|
name = props.findtext("d:displayname", "", ns) or href.split("/")[-1]
|
|
content_type = props.findtext("d:getcontenttype", "", ns) or ""
|
|
size = props.findtext("d:getcontentlength", "0", ns) or "0"
|
|
is_dir = props.find("d:resourcetype/d:collection", ns) is not None
|
|
|
|
# Build clean path from href
|
|
dav_prefix = "/remote.php/dav/files/"
|
|
if dav_prefix in href:
|
|
clean = href[href.index(dav_prefix) + len(dav_prefix):]
|
|
# Remove username prefix
|
|
parts = clean.split("/", 1)
|
|
item_path = "/" + (parts[1] if len(parts) > 1 else "")
|
|
else:
|
|
item_path = "/" + name
|
|
|
|
if is_dir:
|
|
items.append({"name": name, "path": item_path, "type": "dir", "size": 0})
|
|
elif "pdf" in content_type.lower() or name.lower().endswith(".pdf"):
|
|
items.append({"name": name, "path": item_path, "type": "pdf", "size": int(size)})
|
|
|
|
# Sort: dirs first, then PDFs
|
|
items.sort(key=lambda x: (0 if x["type"] == "dir" else 1, x["name"].lower()))
|
|
return items
|
|
|
|
|
|
@router.post("/test")
|
|
def test_connection(req: NCRequest, _: User = Depends(require_moderator)):
|
|
"""Test Nextcloud credentials by listing the root."""
|
|
url = _dav_url(req.server, req.username, "/")
|
|
try:
|
|
resp = httpx.request(
|
|
"PROPFIND", url,
|
|
auth=(req.username, req.password),
|
|
headers={"Depth": "0", "Content-Type": "application/xml"},
|
|
content=DAV_PROPFIND,
|
|
timeout=10,
|
|
follow_redirects=True,
|
|
)
|
|
if resp.status_code in (200, 207):
|
|
return {"ok": True, "message": "Connected successfully"}
|
|
if resp.status_code == 401:
|
|
raise HTTPException(status_code=401, detail="Invalid username or password")
|
|
raise HTTPException(status_code=400, detail=f"Nextcloud returned {resp.status_code}")
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=400, detail=f"Connection failed: {e}")
|
|
|
|
|
|
@router.post("/files")
|
|
def list_files(req: NCRequest, _: User = Depends(require_moderator)):
|
|
"""List PDFs and folders at the given path."""
|
|
url = _dav_url(req.server, req.username, req.path)
|
|
try:
|
|
resp = httpx.request(
|
|
"PROPFIND", url,
|
|
auth=(req.username, req.password),
|
|
headers={"Depth": "1", "Content-Type": "application/xml"},
|
|
content=DAV_PROPFIND,
|
|
timeout=15,
|
|
follow_redirects=True,
|
|
)
|
|
if resp.status_code == 401:
|
|
raise HTTPException(status_code=401, detail="Invalid credentials")
|
|
if resp.status_code not in (200, 207):
|
|
raise HTTPException(status_code=400, detail=f"Nextcloud error {resp.status_code}")
|
|
|
|
items = _parse_propfind(resp.content, req.path)
|
|
# Remove the current directory entry itself
|
|
items = [i for i in items if i["path"] != req.path]
|
|
return {"path": req.path, "items": items}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=400, detail=f"Failed to list files: {e}")
|
|
|
|
|
|
@router.post("/download")
|
|
def download_file(req: NCRequest, _: User = Depends(require_moderator)):
|
|
"""Download a file from Nextcloud and stream it back for upload."""
|
|
if not req.path.lower().endswith(".pdf"):
|
|
raise HTTPException(status_code=400, detail="Only PDF files can be imported")
|
|
url = _dav_url(req.server, req.username, req.path)
|
|
try:
|
|
resp = httpx.get(
|
|
url,
|
|
auth=(req.username, req.password),
|
|
timeout=120,
|
|
follow_redirects=True,
|
|
)
|
|
if resp.status_code == 401:
|
|
raise HTTPException(status_code=401, detail="Invalid credentials")
|
|
if resp.status_code != 200:
|
|
raise HTTPException(status_code=400, detail=f"Download failed: {resp.status_code}")
|
|
raw_name = req.path.split("/")[-1]
|
|
# Strip characters that would break the Content-Disposition header
|
|
safe_name = raw_name.replace('"', '').replace('\\', '').replace('\n', '').replace('\r', '') or "document.pdf"
|
|
return StreamingResponse(
|
|
io.BytesIO(resp.content),
|
|
media_type="application/pdf",
|
|
headers={"Content-Disposition": f'attachment; filename="{safe_name}"',
|
|
"Content-Length": str(len(resp.content))},
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=400, detail=f"Download failed: {e}")
|