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>
This commit is contained in:
Claudio
2026-04-26 01:06:37 +02:00
parent 80dd9d0a8b
commit bc0bb44c05
16 changed files with 1635 additions and 48 deletions

View File

@@ -18,6 +18,7 @@ the same thumb + metadata pipeline that the scanner uses. An optional
import hashlib
import logging
import os
import tempfile
from pathlib import Path
from datetime import datetime
from typing import Optional
@@ -32,6 +33,11 @@ from app.models import Folder, Heap, Photo, SourceRoot
from app.models.heaps import heap_photos
from app.models.user import User
from app.services.date_guess import has_date_warning
from app.services.nextcloud_dav import (
ensure_parents_for_user,
is_nextcloud_path,
put_for_user,
)
from app.tasks.scan import SUPPORTED_EXTENSIONS, get_media_type
from app.tasks.thumbs import generate_thumbnails
from app.services.metadata import extract_metadata
@@ -122,8 +128,11 @@ async def _ensure_subfolder(
) -> Folder:
"""Return (or create) a Folder row named `name` under `parent`.
Also mkdirs the directory on disk. Idempotent — safe to call for a
path segment that already exists as a Folder row or directory.
Also mkdirs the directory on disk. For Nextcloud-rooted paths the
directory is created via WebDAV MKCOL so Nextcloud's `oc_filecache`
knows about it; otherwise plain `os.makedirs`. Idempotent — safe
to call for a path segment that already exists as a Folder row or
directory.
"""
child_path = os.path.normpath(os.path.join(parent.path, name))
@@ -134,10 +143,23 @@ async def _ensure_subfolder(
)
)).scalar_one_or_none()
if existing is not None:
os.makedirs(child_path, exist_ok=True)
# Materialise the directory if it wasn't already.
if is_nextcloud_path(child_path):
ensure_parents_for_user(user, child_path)
# Also ensure the leaf collection exists; ensure_parents
# only handles intermediate dirs.
from app.services.nextcloud_dav import mkcol_for_user
mkcol_for_user(user, child_path)
else:
os.makedirs(child_path, exist_ok=True)
return existing
os.makedirs(child_path, exist_ok=True)
if is_nextcloud_path(child_path):
ensure_parents_for_user(user, child_path)
from app.services.nextcloud_dav import mkcol_for_user
mkcol_for_user(user, child_path)
else:
os.makedirs(child_path, exist_ok=True)
child = Folder(
name=name,
path=child_path,
@@ -213,38 +235,74 @@ async def upload_file(
target_folder = await _ensure_subfolder(target_folder, seg, current_user, db)
target_dir = target_folder.path
os.makedirs(target_dir, exist_ok=True)
nc_managed = is_nextcloud_path(target_dir)
if not nc_managed:
os.makedirs(target_dir, exist_ok=True)
# else: target_dir was created via WebDAV MKCOL by _ensure_subfolder
filepath, final_name = _unique_path(target_dir, leaf)
# --- stream to disk, hash as we go ----------------------------------
# --- stream the upload to a tempfile, hashing as we go ---------------
# For Nextcloud-managed destinations we then PUT the tempfile to
# WebDAV so Nextcloud's oc_filecache + sharing/comment metadata stay
# consistent. For local destinations we just rename the tempfile
# into place. Either way the hash + size are computed once.
hasher = hashlib.sha256()
total = 0
tmp_dir = os.path.dirname(filepath) if not nc_managed else None
# NamedTemporaryFile in the same directory as filepath when local
# (so the final rename is atomic on the same filesystem). For NC,
# use the system tmpdir — we re-upload via HTTP either way.
tmp = tempfile.NamedTemporaryFile(
delete=False, dir=tmp_dir, suffix=".part"
)
tmp_path = tmp.name
try:
with open(filepath, 'wb') as out:
try:
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
total += len(chunk)
if total > MAX_UPLOAD_BYTES:
out.close()
os.unlink(filepath)
raise HTTPException(
status_code=413,
detail=f"File exceeds {MAX_UPLOAD_BYTES // (1024*1024)}MB limit",
)
hasher.update(chunk)
out.write(chunk)
tmp.write(chunk)
finally:
tmp.close()
if nc_managed:
# PUT to Nextcloud WebDAV. The PUT lands the bytes on
# `/mnt/library/homecloud/<nc_user>/files/<rel>` AND
# registers the file in oc_filecache, so the desktop sync
# client and Nextcloud's web UI both see it.
with open(tmp_path, "rb") as body:
put_for_user(current_user, filepath, body)
else:
os.replace(tmp_path, filepath)
tmp_path = None # consumed
except HTTPException:
raise
except Exception as e:
logger.error(f"Upload write failed for {filepath}: {e}")
if os.path.exists(filepath):
raise HTTPException(status_code=500, detail=f"Upload failed: {e}")
finally:
if tmp_path and os.path.exists(tmp_path):
try:
os.unlink(filepath)
os.unlink(tmp_path)
except OSError:
pass
raise HTTPException(status_code=500, detail=f"Upload failed: {e}")
if not os.path.exists(filepath):
# WebDAV wrote it; the bind mount should reflect it. If it
# doesn't, surface a clean error rather than building a Photo
# row that points at a missing file.
raise HTTPException(
status_code=502,
detail="Nextcloud accepted the upload but the file isn't visible on the mount yet.",
)
file_hash = hasher.hexdigest()