"""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""" """ 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}")