pdf-quiz-generator/backend/app/routers/nextcloud.py
Daniel 4c48a5cf94 Security hardening, async TeachChat, rate limit UX, unthrottle
Security:
- admin.py: move api_key from URL query params to POST body (litellm/models, tts/voices) — prevents key logging
- admin.py: sanitize exception messages in voice discovery — log internally, return generic errors
- teach.py: log LLM errors server-side, show friendly message to user
- nextcloud.py: normalize path with posixpath.normpath to prevent ../ traversal
- auth.py: check_rate_limit now accepts user param — admins/moderators/unthrottled always exempt

Performance:
- teach.py: make /chat endpoint async, use litellm.acompletion() — no longer blocks a uvicorn thread per request

Features:
- users: add is_unthrottled column (DB migration in setup_pgvector)
- admin.py: PUT /users/{id}/unthrottle endpoint
- AdminPage.jsx: Unlimited/Throttle toggle button per user, shows "unlimited" badge
- Rate limit messages improved with user-friendly context

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 01:09:42 +02:00

158 lines
5.9 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:
import posixpath
base = server.rstrip("/")
# Normalize to collapse any ../ sequences before building URL
p = posixpath.normpath("/" + 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}")