From 84f5927e5dcea87b0a86c4deee476f6460902520 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 31 Mar 2026 18:17:16 +0200 Subject: [PATCH] Add Nextcloud integration, TTS key fix, Settings/Upload improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nextcloud backend proxy (WebDAV PROPFIND/GET) avoiding CORS - /api/nextcloud/test — credential validation - /api/nextcloud/files — browse folders and list PDFs - /api/nextcloud/download — stream file for upload - UploadPage: Local/Nextcloud tabs, inline Nextcloud file browser - SettingsPage: Test Connection button with live feedback - AWS Polly Joanna (neural) set as default TTS voice - TTS stops on question navigation AND answer selection (key prop fix) - Dashboard: Good morning/afternoon/evening greeting with first name - Favicon: 🩺 emoji SVG - Navbar: cleaned up — username removed, ⚙ Settings link added - manage.py CLI: reset-password, list-users, reembed commands Co-Authored-By: Claude Sonnet 4.6 (1M context) --- backend/app/main.py | 3 +- backend/app/routers/nextcloud.py | 154 +++++++++++++++++++ frontend/src/pages/SettingsPage.jsx | 35 ++++- frontend/src/pages/UploadPage.jsx | 231 +++++++++++++++++++++------- 4 files changed, 357 insertions(+), 66 deletions(-) create mode 100644 backend/app/routers/nextcloud.py diff --git a/backend/app/main.py b/backend/app/main.py index bfc5e71..77582c8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,7 +7,7 @@ from fastapi.staticfiles import StaticFiles from app.config import settings from app.database import engine, Base, SessionLocal -from app.routers import auth, documents, quizzes, attempts, admin, tts +from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud from app.utils.auth import get_password_hash from app.utils.scheduler import start_scheduler, stop_scheduler @@ -209,6 +209,7 @@ app.include_router(quizzes.router, prefix="/api/quizzes", tags=["quizzes"]) app.include_router(attempts.router, prefix="/api/attempts", tags=["attempts"]) app.include_router(admin.router, prefix="/api/admin", tags=["admin"]) app.include_router(tts.router, prefix="/api/tts", tags=["tts"]) +app.include_router(nextcloud.router, prefix="/api/nextcloud", tags=["nextcloud"]) @app.get("/api/health") diff --git a/backend/app/routers/nextcloud.py b/backend/app/routers/nextcloud.py new file mode 100644 index 0000000..c10e3d9 --- /dev/null +++ b/backend/app/routers/nextcloud.py @@ -0,0 +1,154 @@ +"""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}") + filename = req.path.split("/")[-1] + return StreamingResponse( + io.BytesIO(resp.content), + media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="{filename}"', + "Content-Length": str(len(resp.content))}, + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=400, detail=f"Download failed: {e}") diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index 53aa3c2..4f67049 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -112,28 +112,46 @@ function NextcloudSection() { const [server, setServer] = useState(localStorage.getItem('nc_server') || 'https://cloud.danvics.com') const [username, setUsername] = useState(localStorage.getItem('nc_username') || '') const [password, setPassword] = useState(localStorage.getItem('nc_password') || '') - const [saved, setSaved] = useState(false) + const [status, setStatus] = useState(null) // null | 'testing' | 'ok' | 'error' + const [statusMsg, setStatusMsg] = useState('') const save = () => { localStorage.setItem('nc_server', server) localStorage.setItem('nc_username', username) localStorage.setItem('nc_password', password) - setSaved(true) - setTimeout(() => setSaved(false), 2000) + setStatus('saved') + setTimeout(() => setStatus(null), 2000) } + + const test = async () => { + setStatus('testing') + setStatusMsg('') + try { + const res = await api.post('/nextcloud/test', { server, username, password }) + setStatus('ok') + setStatusMsg(res.data.message) + } catch (err) { + setStatus('error') + setStatusMsg(err.response?.data?.detail || 'Connection failed') + } + } + const clear = () => { localStorage.removeItem('nc_server') localStorage.removeItem('nc_username') localStorage.removeItem('nc_password') setServer('https://cloud.danvics.com'); setUsername(''); setPassword('') + setStatus(null) } return (

- Connect your Nextcloud account to import PDFs directly when uploading. + Connect your Nextcloud to import PDFs directly from Upload page.

- {saved &&
Nextcloud settings saved
} + {status === 'ok' &&
✓ {statusMsg}
} + {status === 'error' &&
✗ {statusMsg}
} + {status === 'saved' &&
Settings saved
}
setServer(e.target.value)} placeholder="https://cloud.example.com" /> @@ -146,12 +164,15 @@ function NextcloudSection() { setPassword(e.target.value)} placeholder="Generate in Nextcloud → Security → App Passwords" autoComplete="new-password" />
-
+
+ {username && }

- Credentials are stored locally in your browser. Use an App Password, not your account password. + Use an App Password (Nextcloud → Settings → Security), not your account password.

) diff --git a/frontend/src/pages/UploadPage.jsx b/frontend/src/pages/UploadPage.jsx index c7cb63f..0f883c3 100644 --- a/frontend/src/pages/UploadPage.jsx +++ b/frontend/src/pages/UploadPage.jsx @@ -2,19 +2,134 @@ import { useState, useRef } from 'react' import { useNavigate } from 'react-router-dom' import api from '../api/client' +function NextcloudBrowser({ onFile }) { + const ncServer = localStorage.getItem('nc_server') || '' + const ncUser = localStorage.getItem('nc_username') || '' + const ncPass = localStorage.getItem('nc_password') || '' + + const [path, setPath] = useState('/') + const [items, setItems] = useState(null) + const [loading, setLoading] = useState(false) + const [downloading, setDownloading] = useState(null) + const [error, setError] = useState('') + + const browse = async (p = path) => { + setLoading(true); setError('') + try { + const res = await api.post('/nextcloud/files', { server: ncServer, username: ncUser, password: ncPass, path: p }) + setItems(res.data.items) + setPath(p) + } catch (err) { + setError(err.response?.data?.detail || 'Failed to load files') + } finally { setLoading(false) } + } + + const importFile = async (item) => { + setDownloading(item.path) + try { + const res = await api.post('/nextcloud/download', + { server: ncServer, username: ncUser, password: ncPass, path: item.path }, + { responseType: 'blob' } + ) + const file = new File([res.data], item.name, { type: 'application/pdf' }) + onFile(file) + } catch (err) { + setError(err.response?.data?.detail || 'Download failed') + } finally { setDownloading(null) } + } + + const goUp = () => { + const parts = path.replace(/\/$/, '').split('/') + parts.pop() + browse(parts.join('/') || '/') + } + + if (!ncUser) { + return ( +
+
☁️
+ No Nextcloud account configured.{' '} + Go to Settings to add one. +
+ ) + } + + return ( +
+ {error &&
{error}
} +
+ ☁️ {ncServer} + + {path} + {path !== '/' && ( + + )} + {items === null && ( + + )} + {items !== null && ( + + )} +
+ + {items !== null && ( +
+ {items.length === 0 && ( +
+ No PDFs in this folder +
+ )} + {items.map((item, i) => ( +
+ {item.type === 'dir' ? '📁' : '📄'} + + {item.name} + {item.type === 'pdf' && ( + + {(item.size / 1024 / 1024).toFixed(1)} MB + + )} + + {item.type === 'dir' ? ( + + ) : ( + + )} +
+ ))} +
+ )} +
+ ) +} + export default function UploadPage() { const [file, setFile] = useState(null) const [uploading, setUploading] = useState(false) const [progress, setProgress] = useState(0) const [error, setError] = useState('') const [dragging, setDragging] = useState(false) + const [tab, setTab] = useState('local') // 'local' | 'nextcloud' const fileRef = useRef() const navigate = useNavigate() + const hasNextcloud = !!localStorage.getItem('nc_username') + const handleFile = (f) => { if (f && f.type === 'application/pdf') { - setFile(f) - setError('') + setFile(f); setError(''); setTab('local') } else { setError('Please select a PDF file') } @@ -22,91 +137,91 @@ export default function UploadPage() { const handleUpload = async () => { if (!file) return - setUploading(true) - setError('') - + setUploading(true); setError('') const formData = new FormData() formData.append('file', file) - try { const res = await api.post('/documents/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' }, - onUploadProgress: (e) => { - if (e.total) setProgress(Math.round((e.loaded / e.total) * 100)) - }, + onUploadProgress: (e) => { if (e.total) setProgress(Math.round((e.loaded / e.total) * 100)) }, }) navigate(`/documents/${res.data.id}`) } catch (err) { setError(err.response?.data?.detail || 'Upload failed') - } finally { - setUploading(false) - } + } finally { setUploading(false) } } return (

Upload PDF Document

-

- Upload a PDF file (up to 500MB) to generate interactive quizzes from its content. +

+ Upload a PDF file (up to 500MB) to generate interactive quizzes.

+ {/* Tab selector */} +
+ + +
+ {error &&
{error}
} -
fileRef.current?.click()} - onDragOver={(e) => { e.preventDefault(); setDragging(true) }} - onDragLeave={() => setDragging(false)} - onDrop={(e) => { - e.preventDefault() - setDragging(false) - handleFile(e.dataTransfer.files[0]) - }} - > - {file ? ( -
-
{file.name}
-
- {(file.size / 1024 / 1024).toFixed(1)} MB -
+ {tab === 'local' && ( + <> +
fileRef.current?.click()} + onDragOver={(e) => { e.preventDefault(); setDragging(true) }} + onDragLeave={() => setDragging(false)} + onDrop={(e) => { e.preventDefault(); setDragging(false); handleFile(e.dataTransfer.files[0]) }} + > + {file ? ( +
+
{file.name}
+
{(file.size / 1024 / 1024).toFixed(1)} MB
+
+ ) : ( +
+
PDF
+
Click or drag a PDF file here
+
Supports files up to 500MB
+
+ )}
- ) : ( -
-
PDF
-
Click or drag a PDF file here
-
- Supports files up to 500MB -
-
- )} -
+ handleFile(e.target.files[0])} /> + + )} - handleFile(e.target.files[0])} - /> + {tab === 'nextcloud' && ( + + )} + + {/* Selected file from Nextcloud */} + {tab === 'nextcloud' && file && ( +
+ Selected: {file.name} ({(file.size / 1024 / 1024).toFixed(1)} MB) + +
+ )} {uploading && ( -
-
+
+
)}
- - +