Files
mule-image/backend/app/routers/nextcloud.py
Claudio bc0bb44c05 feat(nextcloud): per-user Nextcloud library integration
Lets each mule-image user (matched via OIDC preferred_username,
overridable in Settings) browse their Nextcloud files/ tree from the
mule-image UI and register subfolders as per-user SourceRoots. Reads
stay direct on the bind-mounted /nextcloud-users path; mutations
(upload, delete, rename, move within NC) dispatch through Nextcloud
WebDAV so oc_filecache, trashbin, comments, and desktop-sync clients
stay coherent.

Backend:
- users.nextcloud_username + nextcloud_app_password_enc (Fernet at rest,
  key derived from SECRET_KEY) — alembic 0016
- services/nextcloud_dav.py: minimal WebDAV client (PUT, MKCOL, DELETE,
  MOVE) with HTTP Basic auth via the per-user app password
- routers/nextcloud.py: GET /browse, /whoami, GET/POST/DELETE
  /source-roots (path-scoped to current_user.nextcloud_username with
  realpath traversal guard)
- PATCH /api/v1/auth/me to update nextcloud_username and app password
- OIDC callback defaults nextcloud_username from preferred_username on
  first login; backfill on existing users; never overwrites a manual
  override
- routers/upload.py: stream upload to NamedTemporaryFile, then PUT to
  WebDAV (with MKCOL chain) when destination is NC-rooted; existing
  Photo row creation runs unchanged
- routers/discard.py empty-trash: WebDAV DELETE for NC files
- routers/photos.py rename + move: WebDAV MOVE for NC paths;
  cross-system move/copy returns a clean error
- routers/folders.py rename + create + permanent-delete: dispatch via
  WebDAV when targeting NC-rooted paths

Frontend:
- AuthUser carries nextcloud_username + has_nextcloud_app_password
- services/api.ts: nextcloud + account namespaces
- components/dialogs/NextcloudFolderPicker.tsx: lazy tree browser, name
  + submit -> POST /source-roots
- SettingsDialog: new "Nextcloud library" card with username override +
  validate, app-password input, list/remove of NC libraries, and the
  picker entry point

docker-compose.yml: NEXTCLOUD_USERS_HOST_PATH bind to /nextcloud-users
on backend + 3 workers; NEXTCLOUD_USERS_ROOT + NEXTCLOUD_BASE_URL env.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:06:37 +02:00

324 lines
11 KiB
Python

"""Nextcloud integration router — folder picker + per-user SourceRoots.
Exposes three things:
- GET /api/v1/nextcloud/whoami?candidate=<name>
Validate that a Nextcloud username actually has a files/ tree
on the mounted homecloud volume. Used by the Settings UI to
sanity-check the override field before saving.
- GET /api/v1/nextcloud/browse?path=<rel>
List immediate subdirectories of the current user's Nextcloud
files tree, scoped server-side to their nextcloud_username.
Powers the folder picker.
- POST /api/v1/nextcloud/source-roots {name, nextcloud_path}
DELETE /api/v1/nextcloud/source-roots/{id}
Add or remove a per-user SourceRoot pointing at a Nextcloud
subfolder. Adding kicks off an immediate scan_folder task so
photos start appearing without a full library re-scan.
All paths are normalized with realpath and rejected if they escape the
user's allowed root — defense-in-depth against `..` and symlink
shenanigans.
"""
from __future__ import annotations
import logging
import os
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import get_current_user
from app.models.folders import SourceRoot, Folder
from app.models.user import User
from app.services.nextcloud_dav import (
NEXTCLOUD_USERS_ROOT,
is_nextcloud_path,
whoami_dir_exists,
)
logger = logging.getLogger(__name__)
router = APIRouter()
def _user_root(nc_username: str) -> str:
"""Absolute path of `<NEXTCLOUD_USERS_ROOT>/<user>/files`."""
return os.path.join(NEXTCLOUD_USERS_ROOT, nc_username, "files")
def _resolve_under_user_root(nc_username: str, rel: str) -> str:
"""Resolve `rel` (a relative path the client supplied) under the
user's Nextcloud `files/` directory and ensure the result is still
inside that root. Returns the absolute, realpath-normalized path.
Raises 400 on traversal attempts (`..`, absolute paths, symlinks
that point outside the root)."""
rel = (rel or "").lstrip("/")
if any(seg in ("..",) for seg in rel.split("/") if seg):
raise HTTPException(status_code=400, detail="Invalid path")
base = _user_root(nc_username)
candidate = os.path.realpath(os.path.join(base, rel))
base_real = os.path.realpath(base)
if candidate != base_real and not candidate.startswith(base_real + os.sep):
raise HTTPException(status_code=400, detail="Path escapes Nextcloud root")
return candidate
# ---------------------------------------------------------------------------
class BrowseEntry(BaseModel):
name: str
path: str # path relative to the user's files/ root
has_children: bool # True if it contains at least one sub-directory
class BrowseResponse(BaseModel):
nc_username: str
rel_path: str
parent_rel: Optional[str] # None at the root
entries: list[BrowseEntry]
@router.get("/whoami")
async def whoami(
candidate: Optional[str] = Query(None, description="Nextcloud username to validate"),
current_user: User = Depends(get_current_user),
):
"""Public to authenticated users — returns whether a Nextcloud
username has a real files/ directory on the bind mount. Used by
the Settings UI to validate the override field before save."""
name = (candidate or current_user.nextcloud_username or "").strip()
if not name:
return {
"configured": bool(current_user.nextcloud_username),
"candidate": None,
"valid": False,
"reason": "no_username",
}
valid = whoami_dir_exists(name)
return {
"configured": bool(current_user.nextcloud_username),
"candidate": name,
"valid": valid,
"reason": None if valid else "no_files_dir",
}
@router.get("/browse", response_model=BrowseResponse)
async def browse(
path: str = Query("", description="Path relative to the user's NC files/ root"),
current_user: User = Depends(get_current_user),
):
"""List immediate subdirectories of the current user's NC tree."""
nc_user = (current_user.nextcloud_username or "").strip()
if not nc_user:
raise HTTPException(
status_code=412,
detail="Set your Nextcloud username in Settings → Library first.",
)
if not whoami_dir_exists(nc_user):
raise HTTPException(
status_code=404,
detail=(
f"Nextcloud user '{nc_user}' has no files/ directory on the "
"mounted homecloud volume. Check your nextcloud_username override."
),
)
abs_path = _resolve_under_user_root(nc_user, path)
if not os.path.isdir(abs_path):
raise HTTPException(status_code=404, detail="Folder not found")
base = _user_root(nc_user)
entries: list[BrowseEntry] = []
try:
with os.scandir(abs_path) as it:
for de in it:
# Skip hidden and Nextcloud's appdata noise.
if de.name.startswith("."):
continue
if not de.is_dir(follow_symlinks=False):
continue
child_abs = os.path.join(abs_path, de.name)
rel = os.path.relpath(child_abs, base)
# Quick has_children probe: any subdir that's a real
# directory. Cap at first hit so deep trees don't slow
# the picker.
has_children = False
try:
with os.scandir(child_abs) as sub:
for s in sub:
if s.name.startswith("."):
continue
if s.is_dir(follow_symlinks=False):
has_children = True
break
except OSError:
has_children = False
entries.append(BrowseEntry(name=de.name, path=rel, has_children=has_children))
except PermissionError:
raise HTTPException(
status_code=502,
detail=(
"Cannot read Nextcloud folder — backend container lacks "
"filesystem permissions on the mount. Apply ACL fix on the host."
),
)
entries.sort(key=lambda e: e.name.lower())
rel_path = os.path.relpath(abs_path, _user_root(nc_user)) if abs_path != _user_root(nc_user) else ""
if rel_path == ".":
rel_path = ""
parent_rel: Optional[str] = None
if rel_path:
parent = os.path.dirname(rel_path)
parent_rel = parent
return BrowseResponse(
nc_username=nc_user,
rel_path=rel_path,
parent_rel=parent_rel,
entries=entries,
)
# ---------------------------------------------------------------------------
class SourceRootCreate(BaseModel):
name: str
nextcloud_path: str # relative to the user's files/ root
@router.post("/source-roots", status_code=201)
async def create_nextcloud_source_root(
body: SourceRootCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Register a Nextcloud subfolder as a SourceRoot for the current user
and kick off the initial scan."""
nc_user = (current_user.nextcloud_username or "").strip()
if not nc_user:
raise HTTPException(
status_code=412,
detail="Set your Nextcloud username in Settings → Library first.",
)
name = (body.name or "").strip()
if not name:
raise HTTPException(status_code=400, detail="Name is required")
abs_path = _resolve_under_user_root(nc_user, body.nextcloud_path)
if not os.path.isdir(abs_path):
raise HTTPException(status_code=404, detail="Folder not found in Nextcloud tree")
# Don't allow registering the user's `files/` root itself as a
# SourceRoot — it'd index everything they own (Documents, Notes,
# appdata noise). Force them to pick a subfolder.
if abs_path == os.path.realpath(_user_root(nc_user)):
raise HTTPException(
status_code=400,
detail="Pick a subfolder; the whole files/ root is too broad.",
)
# Refuse duplicates — the path is uniquely indexed but a clean error
# beats a 500 from the unique constraint.
existing = await db.execute(
select(SourceRoot).where(SourceRoot.path == abs_path)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(status_code=409, detail="A SourceRoot for that path already exists")
sr = SourceRoot(
name=name,
path=abs_path,
user_id=current_user.id,
is_active=True,
)
db.add(sr)
await db.flush()
await db.commit()
await db.refresh(sr)
# Kick off the initial scan. Failures here shouldn't block the
# SourceRoot creation — the user can hit "Re-scan source folders"
# from Settings if Celery is wedged.
try:
from app.tasks.celery import celery_app
celery_app.send_task("scan_folder", args=[sr.path, sr.id])
except Exception as exc: # noqa: BLE001
logger.warning("Failed to queue initial scan for new SourceRoot %s: %s", sr.id, exc)
return {
"id": sr.id,
"name": sr.name,
"path": sr.path,
"user_id": sr.user_id,
"is_active": sr.is_active,
"is_nextcloud": True,
}
@router.delete("/source-roots/{source_root_id}", status_code=204)
async def delete_nextcloud_source_root(
source_root_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Soft-delete: deactivate the SourceRoot and orphan its photo rows
(mark them is_discarded). The actual files in Nextcloud are NOT
touched — this is just unwiring the user's view of the library.
The user can re-add the same path later to restart scanning."""
sr = (await db.execute(
select(SourceRoot).where(
SourceRoot.id == source_root_id,
SourceRoot.user_id == current_user.id,
)
)).scalar_one_or_none()
if sr is None:
raise HTTPException(status_code=404, detail="SourceRoot not found")
if not is_nextcloud_path(sr.path):
raise HTTPException(
status_code=400,
detail="This endpoint only manages Nextcloud-rooted SourceRoots",
)
# Soft-deactivate. Existing photos stay in the DB but won't appear
# in active queries (folders.py filters by is_active=true).
sr.is_active = False
await db.commit()
return None
# ---------------------------------------------------------------------------
@router.get("/source-roots")
async def list_nextcloud_source_roots(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""All Nextcloud-rooted SourceRoots owned by the current user.
Used by the Settings panel to render the manage list."""
rows = (await db.execute(
select(SourceRoot).where(SourceRoot.user_id == current_user.id)
)).scalars().all()
return [
{
"id": r.id,
"name": r.name,
"path": r.path,
"is_active": r.is_active,
"is_nextcloud": True,
}
for r in rows
if is_nextcloud_path(r.path)
]