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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user