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>
388 lines
14 KiB
Python
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)
|
|
]
|