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

@@ -31,8 +31,22 @@ from app.dependencies import (
get_user_or_shared_heap, get_user_or_shared_folder,
can_access_photo_via_share,
)
from app.services.nextcloud_dav import is_nextcloud_path, move_for_user
from app.config import settings
def _nc_unsupported(detail: str) -> HTTPException:
"""Returns a 501 with a UI-friendly hint to use Nextcloud's web UI
for an op we haven't routed through WebDAV yet. Centralised so the
message stays consistent."""
return HTTPException(
status_code=501,
detail=(
f"{detail} For Nextcloud-managed libraries, do this from "
"Nextcloud's web UI; mule-image will pick up the change."
),
)
router = APIRouter()
@router.get("")
@@ -843,11 +857,18 @@ async def update_photo(
if os.path.exists(new_path):
raise HTTPException(status_code=409, detail="A file with that name already exists")
try:
os.rename(photo.filepath, new_path)
except OSError as e:
logger.error(f"Failed to rename {photo.filepath} -> {new_path}: {e}")
raise HTTPException(status_code=500, detail=f"Rename failed: {e}")
if is_nextcloud_path(photo.filepath):
# WebDAV MOVE within the same directory == rename. Lands
# the new name in oc_filecache so Nextcloud's web UI and
# sync clients see it; the bind mount reflects the
# rename for our own watcher.
move_for_user(current_user, photo.filepath, new_path)
else:
try:
os.rename(photo.filepath, new_path)
except OSError as e:
logger.error(f"Failed to rename {photo.filepath} -> {new_path}: {e}")
raise HTTPException(status_code=500, detail=f"Rename failed: {e}")
photo.filename = new_name
photo.filepath = new_path
@@ -974,6 +995,15 @@ async def copy_photos(
return candidate
return None
target_nc = is_nextcloud_path(target_dir)
if target_nc:
# Copy into a Nextcloud-managed folder isn't routed through
# WebDAV PUT yet — would need a download from the source path
# plus an upload, doubling IO. Defer until someone needs it.
raise _nc_unsupported(
"Copy into a Nextcloud-managed folder isn't supported yet."
)
for photo in photos_to_copy:
if not os.path.exists(photo.filepath):
errors.append({"id": photo.id, "error": "source file missing"})
@@ -986,6 +1016,17 @@ async def copy_photos(
new_path = os.path.join(target_dir, new_name)
if is_nextcloud_path(photo.filepath):
# Source is NC, dest is local. We could `shutil.copy2`
# since NC files are readable on the bind mount, but the
# local /photos tree has different ownership semantics —
# leave this off until we've thought about it.
errors.append({
"id": photo.id,
"error": "Cross-system copy (Nextcloud ↔ local) not supported yet",
})
continue
try:
shutil.copy2(photo.filepath, new_path)
except OSError as e:
@@ -1099,8 +1140,27 @@ async def move_photos(
errors.append({"id": photo.id, "error": f"name already exists in target: {photo.filename}"})
continue
src_nc = is_nextcloud_path(photo.filepath)
dst_nc = is_nextcloud_path(new_path)
if src_nc != dst_nc:
# Cross-system moves (NC ↔ local /photos) aren't supported
# in v1 — the user can copy via Nextcloud's web UI or use
# the desktop sync client to move the file, and mule-image
# will pick up both sides via inotify.
errors.append({
"id": photo.id,
"error": "Cross-system move (Nextcloud ↔ local) not supported yet",
})
continue
try:
shutil.move(photo.filepath, new_path)
if src_nc:
move_for_user(current_user, photo.filepath, new_path)
else:
shutil.move(photo.filepath, new_path)
except HTTPException as e:
errors.append({"id": photo.id, "error": str(e.detail)})
continue
except OSError as e:
errors.append({"id": photo.id, "error": str(e)})
continue