Files
mule-image/backend/app/routers/nextcloud.py
Claudio 76551d898b fix(nextcloud): NULL parent_id on cross-source-root child folders + spinner
The previous fix NULLed parent_id only for folders within the
SourceRoot being deleted, but folder rows under a *different*
SourceRoot whose path nests inside this one (e.g. 'Leóns 1st Year' at
.../Taco and Muli - 2024 onward/Leóns 1st Year) still pointed into
our delete set. folders_parent_id_fkey kept tripping. Widen the UPDATE
to NULL parent_id for any folder whose parent_id is in folder_ids,
regardless of source_root_id.

UI: trash button on a Nextcloud library now swaps to a spinning
Loader2 while the delete is in flight (only the row being deleted —
others stay as trash icons but disabled). Title updates to flag
that a cascade through every photo + folder can take a few seconds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:37:12 +02:00

388 lines
14 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 delete, select, update
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.photos import Photo
from app.models.sharing import FolderShare
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}")
async def delete_nextcloud_source_root(
source_root_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Hard-delete: drop the SourceRoot row and cascade-delete every
Folder and Photo underneath it. The actual files in Nextcloud are
NOT touched — this is just unwiring the user's library in mule-image.
Implementation notes:
- photo_tags and heap_photos cascade via DB-level ON DELETE CASCADE,
so deleting Photo rows is enough to clean those up.
- FolderShare uses a stringly-typed folder_id (no FK), so we have
to clean those rows by hand for both the SourceRoot itself and
every Folder we're about to delete.
- Chunked at 500 to mirror prune_missing_photos so postgres doesn't
choke on a 21k-photo source root.
"""
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",
)
folder_ids = (await db.execute(
select(Folder.id).where(Folder.source_root_id == sr.id)
)).scalars().all()
photo_ids: list[str] = []
if folder_ids:
photo_ids = (await db.execute(
select(Photo.id).where(Photo.folder_id.in_(folder_ids))
)).scalars().all()
CHUNK = 500
for i in range(0, len(photo_ids), CHUNK):
chunk = photo_ids[i:i + CHUNK]
await db.execute(delete(Photo).where(Photo.id.in_(chunk)))
# FolderShare rows: not a real FK, clean both 'source_root' and
# 'folder' typed shares pointing at anything we're tearing down.
await db.execute(
delete(FolderShare).where(
FolderShare.folder_id == sr.id,
FolderShare.folder_type == 'source_root',
)
)
if folder_ids:
await db.execute(
delete(FolderShare).where(
FolderShare.folder_id.in_(folder_ids),
FolderShare.folder_type == 'folder',
)
)
# Folders have a self-referential parent_id FK with no
# ON DELETE rule. NULL parent_id on every folder that points
# *into* our delete set — that includes children within this
# source root AND any folder under a different SourceRoot whose
# path happens to nest inside this one (e.g. a 'Leóns 1st Year'
# SourceRoot at `.../Taco and Muli - 2024 onward/Leóns 1st Year`
# has folder rows whose parent_id points at folder rows under
# 'Taco and Muli - 2024 onward'). Without this, deleting the
# outer SourceRoot trips folders_parent_id_fkey from the inner
# SourceRoot's still-live rows.
await db.execute(
update(Folder)
.where(Folder.parent_id.in_(folder_ids))
.values(parent_id=None)
)
await db.execute(delete(Folder).where(Folder.id.in_(folder_ids)))
await db.delete(sr)
await db.commit()
logger.info(
f"Deleted SourceRoot {sr.id} ({sr.name}): "
f"{len(photo_ids)} photos, {len(folder_ids)} folders"
)
return {
"deleted_photos": len(photo_ids),
"deleted_folders": len(folder_ids),
}
# ---------------------------------------------------------------------------
@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)
]