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

@@ -11,6 +11,7 @@ from app.database import get_db
from app.models import Photo
from app.models.user import User
from app.dependencies import get_current_user
from app.services.nextcloud_dav import delete_for_user, is_nextcloud_path
logger = logging.getLogger(__name__)
@@ -49,7 +50,7 @@ async def empty_discard(db: AsyncSession = Depends(get_db), current_user: User =
select(Photo).where(Photo.is_discarded == True, Photo.user_id == current_user.id)
)
photos = result.scalars().all()
return await _permanently_delete(db, photos)
return await _permanently_delete(db, photos, current_user)
@router.delete("")
@@ -70,19 +71,38 @@ async def delete_discarded(
)
)
photos = result.scalars().all()
return await _permanently_delete(db, photos)
return await _permanently_delete(db, photos, current_user)
async def _permanently_delete(db: AsyncSession, photos: list[Photo]) -> dict:
async def _permanently_delete(db: AsyncSession, photos: list[Photo], user: User) -> dict:
"""Shared helper: unlink files for the given photos and delete their
rows. Per-file errors are counted but don't abort the batch.
For files inside a Nextcloud-rooted SourceRoot the unlink is dispatched
through Nextcloud's WebDAV `DELETE` so Nextcloud moves the file into
the user's trashbin and updates `oc_filecache`. For everything else we
fall back to plain `os.unlink`.
"""
deleted = 0
file_errors = 0
for photo in photos:
try:
if photo.filepath and os.path.exists(photo.filepath):
os.unlink(photo.filepath)
if photo.filepath:
if is_nextcloud_path(photo.filepath):
# WebDAV DELETE — Nextcloud moves to trashbin and
# updates oc_filecache. The bind mount will then
# reflect the file's absence (Nextcloud writes
# synchronously). 404 from NC is treated as already
# gone (idempotent).
delete_for_user(user, photo.filepath)
elif os.path.exists(photo.filepath):
os.unlink(photo.filepath)
except HTTPException as e:
# WebDAV-side error — surface to caller via the file_errors
# counter rather than aborting the whole batch.
file_errors += 1
logger.error(f"Failed to delete {photo.filepath} via Nextcloud: {e.detail}")
continue
except OSError as e:
file_errors += 1
logger.error(f"Failed to unlink {photo.filepath}: {e}")