Add Nextcloud integration, TTS key fix, Settings/Upload improvements

- 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) <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-31 18:17:16 +02:00
parent 799b5b3ee5
commit 84f5927e5d
4 changed files with 357 additions and 66 deletions

View file

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

View file

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

View file

@ -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 (
<Section title="Nextcloud Integration">
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: 16 }}>
Connect your Nextcloud account to import PDFs directly when uploading.
Connect your Nextcloud to import PDFs directly from Upload page.
</p>
{saved && <div className="alert alert-success">Nextcloud settings saved</div>}
{status === 'ok' && <div className="alert alert-success"> {statusMsg}</div>}
{status === 'error' && <div className="alert alert-error"> {statusMsg}</div>}
{status === 'saved' && <div className="alert alert-success">Settings saved</div>}
<div className="form-group">
<label>Server URL</label>
<input type="url" value={server} onChange={e => setServer(e.target.value)} placeholder="https://cloud.example.com" />
@ -146,12 +164,15 @@ function NextcloudSection() {
<label>App Password</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="Generate in Nextcloud → Security → App Passwords" autoComplete="new-password" />
</div>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button className="btn btn-primary" onClick={save}>Save</button>
<button className="btn btn-secondary" onClick={test} disabled={!username || !password || status === 'testing'}>
{status === 'testing' ? 'Testing...' : 'Test Connection'}
</button>
{username && <button className="btn btn-secondary" onClick={clear}>Disconnect</button>}
</div>
<p style={{ fontSize: '0.78rem', color: 'var(--text-subtle)', marginTop: 10 }}>
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.
</p>
</Section>
)

View file

@ -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 (
<div style={{ padding: '20px', textAlign: 'center', color: 'var(--text-muted)', fontSize: '0.875rem' }}>
<div style={{ fontSize: '1.5rem', marginBottom: 8 }}></div>
No Nextcloud account configured.{' '}
<a href="/settings" style={{ color: 'var(--primary)' }}>Go to Settings</a> to add one.
</div>
)
}
return (
<div>
{error && <div className="alert alert-error" style={{ marginBottom: 8 }}>{error}</div>}
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 12, fontSize: '0.85rem' }}>
<span style={{ color: 'var(--text-muted)' }}> {ncServer}</span>
<span style={{ color: 'var(--text-muted)' }}></span>
<code style={{ color: 'var(--text)', fontSize: '0.82rem' }}>{path}</code>
{path !== '/' && (
<button className="btn btn-sm btn-secondary" onClick={goUp}> Up</button>
)}
{items === null && (
<button className="btn btn-sm btn-primary" onClick={() => browse('/')} disabled={loading}>
{loading ? 'Loading...' : 'Browse'}
</button>
)}
{items !== null && (
<button className="btn btn-sm btn-secondary" onClick={() => browse(path)} disabled={loading}>
{loading ? '...' : '↻'}
</button>
)}
</div>
{items !== null && (
<div style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', maxHeight: 300, overflowY: 'auto' }}>
{items.length === 0 && (
<div style={{ padding: '20px', textAlign: 'center', color: 'var(--text-muted)', fontSize: '0.85rem' }}>
No PDFs in this folder
</div>
)}
{items.map((item, i) => (
<div key={i} style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px',
borderBottom: i < items.length - 1 ? '1px solid var(--border)' : 'none',
background: 'var(--card-bg)',
}}>
<span style={{ fontSize: '1rem' }}>{item.type === 'dir' ? '📁' : '📄'}</span>
<span style={{ flex: 1, fontSize: '0.875rem', color: 'var(--text)' }}>
{item.name}
{item.type === 'pdf' && (
<span style={{ marginLeft: 8, fontSize: '0.75rem', color: 'var(--text-muted)' }}>
{(item.size / 1024 / 1024).toFixed(1)} MB
</span>
)}
</span>
{item.type === 'dir' ? (
<button className="btn btn-sm btn-secondary" onClick={() => browse(item.path)}>Open</button>
) : (
<button className="btn btn-sm btn-primary"
onClick={() => importFile(item)}
disabled={downloading === item.path}>
{downloading === item.path ? 'Downloading...' : 'Import'}
</button>
)}
</div>
))}
</div>
)}
</div>
)
}
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 (
<div>
<div className="card">
<h2>Upload PDF Document</h2>
<p style={{ color: '#64748b', marginBottom: 20 }}>
Upload a PDF file (up to 500MB) to generate interactive quizzes from its content.
<p style={{ color: 'var(--text-muted)', marginBottom: 20, fontSize: '0.9rem' }}>
Upload a PDF file (up to 500MB) to generate interactive quizzes.
</p>
{/* Tab selector */}
<div style={{ display: 'flex', gap: 4, marginBottom: 20 }}>
<button className={`btn btn-sm ${tab === 'local' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setTab('local')}>
💻 Local File
</button>
<button className={`btn btn-sm ${tab === 'nextcloud' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setTab('nextcloud')}>
Nextcloud{!hasNextcloud && ' (not set up)'}
</button>
</div>
{error && <div className="alert alert-error">{error}</div>}
<div
className={`upload-area ${dragging ? 'dragging' : ''}`}
onClick={() => fileRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault()
setDragging(false)
handleFile(e.dataTransfer.files[0])
}}
>
{file ? (
<div>
<div style={{ fontSize: '1.1rem', fontWeight: 600 }}>{file.name}</div>
<div style={{ color: '#64748b', marginTop: 4 }}>
{(file.size / 1024 / 1024).toFixed(1)} MB
</div>
{tab === 'local' && (
<>
<div
className={`upload-area ${dragging ? 'dragging' : ''}`}
onClick={() => fileRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={(e) => { e.preventDefault(); setDragging(false); handleFile(e.dataTransfer.files[0]) }}
>
{file ? (
<div>
<div style={{ fontSize: '1.1rem', fontWeight: 600 }}>{file.name}</div>
<div style={{ color: 'var(--text-muted)', marginTop: 4 }}>{(file.size / 1024 / 1024).toFixed(1)} MB</div>
</div>
) : (
<div>
<div style={{ fontSize: '2rem', marginBottom: 8 }}>PDF</div>
<div>Click or drag a PDF file here</div>
<div style={{ color: 'var(--text-subtle)', fontSize: '0.85rem', marginTop: 4 }}>Supports files up to 500MB</div>
</div>
)}
</div>
) : (
<div>
<div style={{ fontSize: '2rem', marginBottom: 8 }}>PDF</div>
<div>Click or drag a PDF file here</div>
<div style={{ color: '#94a3b8', fontSize: '0.85rem', marginTop: 4 }}>
Supports files up to 500MB
</div>
</div>
)}
</div>
<input ref={fileRef} type="file" accept=".pdf" hidden onChange={(e) => handleFile(e.target.files[0])} />
</>
)}
<input
ref={fileRef}
type="file"
accept=".pdf"
hidden
onChange={(e) => handleFile(e.target.files[0])}
/>
{tab === 'nextcloud' && (
<NextcloudBrowser onFile={handleFile} />
)}
{/* Selected file from Nextcloud */}
{tab === 'nextcloud' && file && (
<div style={{ marginTop: 12, padding: '10px 14px', background: 'var(--option-sel-bg)', borderRadius: 8, border: '1px solid var(--option-sel-bd)', fontSize: '0.875rem' }}>
Selected: <strong>{file.name}</strong> ({(file.size / 1024 / 1024).toFixed(1)} MB)
<button onClick={() => setFile(null)} style={{ marginLeft: 12, background: 'none', border: 'none', color: 'var(--text-muted)', cursor: 'pointer' }}></button>
</div>
)}
{uploading && (
<div className="progress-bar">
<div className="fill" style={{ width: `${progress}%` }}></div>
<div className="progress-bar" style={{ marginTop: 12 }}>
<div className="fill" style={{ width: `${progress}%` }} />
</div>
)}
<div style={{ marginTop: 20, display: 'flex', gap: 12 }}>
<button
className="btn btn-primary"
onClick={handleUpload}
disabled={!file || uploading}
>
<button className="btn btn-primary" onClick={handleUpload} disabled={!file || uploading}>
{uploading ? `Uploading ${progress}%...` : 'Upload & Process'}
</button>
<button className="btn btn-secondary" onClick={() => navigate('/')}>
Cancel
</button>
<button className="btn btn-secondary" onClick={() => navigate('/')}>Cancel</button>
</div>
</div>
</div>