"""Nextcloud integration router — folder picker + per-user SourceRoots. Exposes three things: - GET /api/v1/nextcloud/whoami?candidate= 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= 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 `//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) ]