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:
@@ -18,6 +18,12 @@ from app.database import get_db
|
||||
from app.models import Folder, SourceRoot, Photo
|
||||
from app.models.user import User
|
||||
from app.dependencies import get_current_user, get_user_folder
|
||||
from app.services.nextcloud_dav import (
|
||||
delete_for_user as nc_delete,
|
||||
is_nextcloud_path,
|
||||
mkcol_for_user,
|
||||
move_for_user as nc_move,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -251,10 +257,16 @@ async def rename_folder(
|
||||
detail=f"A folder named '{name}' already exists here",
|
||||
)
|
||||
|
||||
try:
|
||||
shutil.move(old_path, new_path)
|
||||
except OSError as e:
|
||||
raise HTTPException(status_code=500, detail=f"Rename failed: {e}")
|
||||
if is_nextcloud_path(old_path):
|
||||
# WebDAV MOVE keeps Nextcloud's oc_filecache + sharing metadata
|
||||
# consistent. NC's MOVE is recursive — descendants come along,
|
||||
# exactly like shutil.move.
|
||||
nc_move(current_user, old_path, new_path)
|
||||
else:
|
||||
try:
|
||||
shutil.move(old_path, new_path)
|
||||
except OSError as e:
|
||||
raise HTTPException(status_code=500, detail=f"Rename failed: {e}")
|
||||
|
||||
# Update folder paths: this row + every descendant. SQLite REPLACE
|
||||
# rewrites the prefix; we use the trailing separator on the LIKE
|
||||
@@ -309,10 +321,14 @@ async def create_folder(body: FolderCreate, db: AsyncSession = Depends(get_db),
|
||||
detail=f"A folder named '{name}' already exists here",
|
||||
)
|
||||
|
||||
try:
|
||||
os.makedirs(new_path, exist_ok=False)
|
||||
except OSError as e:
|
||||
raise HTTPException(status_code=500, detail=f"Create failed: {e}")
|
||||
if is_nextcloud_path(new_path):
|
||||
# MKCOL via WebDAV so Nextcloud knows about the new collection.
|
||||
mkcol_for_user(current_user, new_path)
|
||||
else:
|
||||
try:
|
||||
os.makedirs(new_path, exist_ok=False)
|
||||
except OSError as e:
|
||||
raise HTTPException(status_code=500, detail=f"Create failed: {e}")
|
||||
|
||||
new_folder = Folder(
|
||||
name=name,
|
||||
@@ -395,14 +411,28 @@ async def delete_folder(
|
||||
|
||||
# mode == 'permanent'
|
||||
file_errors = 0
|
||||
for p in photos:
|
||||
folder_is_nc = is_nextcloud_path(folder_path)
|
||||
|
||||
if folder_is_nc:
|
||||
# One WebDAV DELETE on the folder itself does the recursive
|
||||
# delete (NC moves the whole tree to trashbin and updates
|
||||
# oc_filecache for everything inside). Skip per-photo unlinks.
|
||||
try:
|
||||
if p.filepath and os.path.exists(p.filepath):
|
||||
os.unlink(p.filepath)
|
||||
except OSError as e:
|
||||
file_errors += 1
|
||||
logger.error(f"Failed to unlink {p.filepath}: {e}")
|
||||
await db.delete(p)
|
||||
nc_delete(current_user, folder_path)
|
||||
except HTTPException as e:
|
||||
logger.error(f"Nextcloud DELETE failed for {folder_path}: {e.detail}")
|
||||
raise
|
||||
for p in photos:
|
||||
await db.delete(p)
|
||||
else:
|
||||
for p in photos:
|
||||
try:
|
||||
if p.filepath and os.path.exists(p.filepath):
|
||||
os.unlink(p.filepath)
|
||||
except OSError as e:
|
||||
file_errors += 1
|
||||
logger.error(f"Failed to unlink {p.filepath}: {e}")
|
||||
await db.delete(p)
|
||||
|
||||
# Delete this folder + every descendant Folder row.
|
||||
await db.execute(
|
||||
@@ -412,13 +442,14 @@ async def delete_folder(
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
if os.path.isdir(folder_path):
|
||||
shutil.rmtree(folder_path)
|
||||
except OSError as e:
|
||||
logger.error(f"Failed to rmtree {folder_path}: {e}")
|
||||
# Don't raise — DB rows are already gone, leaving an orphan
|
||||
# directory is the lesser evil.
|
||||
if not folder_is_nc:
|
||||
try:
|
||||
if os.path.isdir(folder_path):
|
||||
shutil.rmtree(folder_path)
|
||||
except OSError as e:
|
||||
logger.error(f"Failed to rmtree {folder_path}: {e}")
|
||||
# Don't raise — DB rows are already gone, leaving an orphan
|
||||
# directory is the lesser evil.
|
||||
|
||||
await db.commit()
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user