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:
302
backend/app/services/nextcloud_dav.py
Normal file
302
backend/app/services/nextcloud_dav.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""Nextcloud WebDAV client — only the verbs we actually need.
|
||||
|
||||
Outgoing mutations (upload, delete, rename/move) on files that live
|
||||
under a user's Nextcloud-rooted SourceRoot route through this client
|
||||
instead of touching the filesystem directly. That way Nextcloud's
|
||||
oc_filecache, trashbin, sharing/comments metadata, and desktop sync
|
||||
clients all stay coherent — the price of bypassing it is a stale
|
||||
Nextcloud and resurrected files when sync clients re-upload.
|
||||
|
||||
Reads (scanning, hashing, EXIF, ML pipelines) keep using the bind
|
||||
mount at NEXTCLOUD_USERS_ROOT. WebDAV is far too slow for every byte
|
||||
of every photo, and the read side has no consistency cost — Nextcloud
|
||||
is the writer, the bind mount is the reader, that's it.
|
||||
|
||||
Auth: HTTP Basic with the user's Nextcloud app password (set via the
|
||||
Settings UI, stored Fernet-encrypted at rest). OIDC bearer reuse is a
|
||||
later optimization; app passwords work today and are well-supported.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import BinaryIO, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.config import settings
|
||||
from app.models.user import User
|
||||
from app.services.secrets import decrypt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Top-level mount inside the backend container. The Nextcloud user tree
|
||||
# `/mnt/library/homecloud/<nc_user>/files/...` shows up here as
|
||||
# `/nextcloud-users/<nc_user>/files/...`.
|
||||
NEXTCLOUD_USERS_ROOT = os.environ.get("NEXTCLOUD_USERS_ROOT", "/nextcloud-users")
|
||||
|
||||
|
||||
def is_nextcloud_path(path: str) -> bool:
|
||||
"""True iff `path` resolves under the configured NC users mount."""
|
||||
if not path:
|
||||
return False
|
||||
norm = os.path.normpath(path)
|
||||
root = os.path.normpath(NEXTCLOUD_USERS_ROOT)
|
||||
return norm == root or norm.startswith(root + os.sep)
|
||||
|
||||
|
||||
def split_nextcloud_path(path: str) -> Tuple[str, str]:
|
||||
"""Return (nc_username, rel_path) for a file/dir under the NC mount.
|
||||
|
||||
rel_path is the path relative to `<NEXTCLOUD_USERS_ROOT>/<user>/files/`,
|
||||
suitable for appending to the WebDAV base URL. Raises if `path`
|
||||
isn't a Nextcloud-rooted path or doesn't sit under a `files/`
|
||||
directory.
|
||||
"""
|
||||
norm = os.path.normpath(path)
|
||||
root = os.path.normpath(NEXTCLOUD_USERS_ROOT)
|
||||
if not (norm == root or norm.startswith(root + os.sep)):
|
||||
raise ValueError(f"Not a Nextcloud-rooted path: {path!r}")
|
||||
rest = norm[len(root):].lstrip(os.sep) # "<user>/files/foo/bar.jpg"
|
||||
parts = rest.split(os.sep, 2)
|
||||
if len(parts) < 3 or parts[1] != "files":
|
||||
# Either we got just /<user>, /<user>/files (no rel), or a
|
||||
# different second segment — only the user's `files/` tree is
|
||||
# safe to mutate via WebDAV.
|
||||
if len(parts) == 2 and parts[1] == "files":
|
||||
return parts[0], ""
|
||||
raise ValueError(
|
||||
f"Path doesn't live under <user>/files/: {path!r}"
|
||||
)
|
||||
return parts[0], parts[2]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Client
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class NextcloudCredentialsMissing(HTTPException):
|
||||
"""The user hasn't set their Nextcloud app password yet, but the
|
||||
request needs it to mutate a Nextcloud-managed file. 412 because
|
||||
the precondition (credentials) is missing rather than the request
|
||||
itself being malformed."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
status_code=status.HTTP_412_PRECONDITION_FAILED,
|
||||
detail=(
|
||||
"Set your Nextcloud app password in Settings → Library "
|
||||
"before mutating files in your Nextcloud library."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _credentials_for(user: User) -> tuple[str, str]:
|
||||
"""Resolve the (nc_username, app_password) pair for a user.
|
||||
Raises NextcloudCredentialsMissing when either is missing."""
|
||||
nc_user = (user.nextcloud_username or "").strip()
|
||||
app_pw = decrypt(user.nextcloud_app_password_enc)
|
||||
if not nc_user or not app_pw:
|
||||
raise NextcloudCredentialsMissing()
|
||||
return nc_user, app_pw
|
||||
|
||||
|
||||
def _base_url() -> str:
|
||||
"""The Nextcloud WebDAV base URL (without trailing slash, without
|
||||
user-suffixed path). Resolved per-call so a config reload picks up
|
||||
a new value without restarting workers."""
|
||||
base = (
|
||||
os.environ.get("NEXTCLOUD_BASE_URL")
|
||||
or getattr(settings, "nextcloud_base_url", None)
|
||||
or ""
|
||||
).rstrip("/")
|
||||
if not base:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="NEXTCLOUD_BASE_URL is not configured on the backend",
|
||||
)
|
||||
return base
|
||||
|
||||
|
||||
def _dav_url(nc_username: str, rel_path: str) -> str:
|
||||
"""Compose the absolute WebDAV URL for a relative path under the
|
||||
user's `files/` collection."""
|
||||
base = _base_url()
|
||||
rel = (rel_path or "").lstrip("/")
|
||||
# Each segment must be URL-encoded. httpx encodes path segments at
|
||||
# request time, so we hand it the raw join — but we explicitly drop
|
||||
# `..` traversals here as defense in depth.
|
||||
if any(seg in ("", "..") for seg in rel.split("/") if seg):
|
||||
raise HTTPException(status_code=400, detail="Invalid relative path")
|
||||
parts = [base, "remote.php/dav/files", nc_username]
|
||||
if rel:
|
||||
parts.append(rel)
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
# httpx Client TTL: short, since a single request is the unit of work.
|
||||
_TIMEOUT = httpx.Timeout(30.0, connect=10.0)
|
||||
|
||||
|
||||
def _client(auth: tuple[str, str]) -> httpx.Client:
|
||||
return httpx.Client(timeout=_TIMEOUT, auth=httpx.BasicAuth(*auth), follow_redirects=False)
|
||||
|
||||
|
||||
def _async_client(auth: tuple[str, str]) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(timeout=_TIMEOUT, auth=httpx.BasicAuth(*auth), follow_redirects=False)
|
||||
|
||||
|
||||
def _raise_for_dav(resp: httpx.Response, action: str) -> None:
|
||||
"""Translate Nextcloud WebDAV errors into FastAPI HTTPExceptions
|
||||
the frontend can show. We surface Nextcloud's body verbatim when
|
||||
it's small enough, since it tends to carry the actually-useful
|
||||
detail (quota, permission denied, etc.)."""
|
||||
if resp.is_success:
|
||||
return
|
||||
body = resp.text or ""
|
||||
if len(body) > 400:
|
||||
body = body[:400] + "…"
|
||||
logger.warning("Nextcloud %s failed: %s %s — %s", action, resp.status_code, resp.reason_phrase, body[:200])
|
||||
if resp.status_code in (401, 403):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Nextcloud rejected the {action}: {resp.reason_phrase}. "
|
||||
f"Check your app password under Settings → Library.",
|
||||
)
|
||||
if resp.status_code == 404:
|
||||
raise HTTPException(status_code=404, detail=f"Not found in Nextcloud during {action}")
|
||||
if resp.status_code == 507:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_507_INSUFFICIENT_STORAGE,
|
||||
detail="Nextcloud quota exceeded",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Nextcloud error during {action}: {resp.status_code} {resp.reason_phrase}",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verbs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def delete_for_user(user: User, abs_path: str) -> None:
|
||||
"""WebDAV DELETE — moves the file/dir into the user's NC trashbin.
|
||||
`abs_path` is the absolute filesystem path under the bind mount."""
|
||||
nc_user, app_pw = _credentials_for(user)
|
||||
expected_user, rel = split_nextcloud_path(abs_path)
|
||||
if expected_user != nc_user:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Path belongs to a different Nextcloud user",
|
||||
)
|
||||
url = _dav_url(nc_user, rel)
|
||||
with _client((nc_user, app_pw)) as c:
|
||||
resp = c.request("DELETE", url)
|
||||
# 204 = deleted. 404 = already gone (treat as success, idempotent).
|
||||
if resp.status_code == 404:
|
||||
logger.info("Nextcloud DELETE %s already gone, treating as success", rel)
|
||||
return
|
||||
_raise_for_dav(resp, "delete")
|
||||
|
||||
|
||||
def move_for_user(user: User, src_abs: str, dst_abs: str) -> None:
|
||||
"""WebDAV MOVE — rename or move within the same Nextcloud user."""
|
||||
nc_user, app_pw = _credentials_for(user)
|
||||
src_user, src_rel = split_nextcloud_path(src_abs)
|
||||
dst_user, dst_rel = split_nextcloud_path(dst_abs)
|
||||
if src_user != nc_user or dst_user != nc_user:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="MOVE across Nextcloud users is not supported",
|
||||
)
|
||||
src_url = _dav_url(nc_user, src_rel)
|
||||
dst_url = _dav_url(nc_user, dst_rel)
|
||||
with _client((nc_user, app_pw)) as c:
|
||||
resp = c.request(
|
||||
"MOVE",
|
||||
src_url,
|
||||
headers={"Destination": dst_url, "Overwrite": "F"},
|
||||
)
|
||||
_raise_for_dav(resp, "move")
|
||||
|
||||
|
||||
def mkcol_for_user(user: User, abs_path: str) -> None:
|
||||
"""WebDAV MKCOL — create a directory. Idempotent: a 405 (Method Not
|
||||
Allowed) means the collection already exists, treat as success."""
|
||||
nc_user, app_pw = _credentials_for(user)
|
||||
expected_user, rel = split_nextcloud_path(abs_path)
|
||||
if expected_user != nc_user:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Path belongs to a different Nextcloud user",
|
||||
)
|
||||
url = _dav_url(nc_user, rel)
|
||||
with _client((nc_user, app_pw)) as c:
|
||||
resp = c.request("MKCOL", url)
|
||||
if resp.status_code == 405:
|
||||
return
|
||||
_raise_for_dav(resp, "mkcol")
|
||||
|
||||
|
||||
def put_for_user(
|
||||
user: User,
|
||||
abs_path: str,
|
||||
fileobj: BinaryIO,
|
||||
content_type: Optional[str] = None,
|
||||
) -> None:
|
||||
"""WebDAV PUT — upload `fileobj` to `abs_path`. Caller is
|
||||
responsible for ensuring intermediate collections exist via
|
||||
`mkcol_for_user`. Streams the body, no in-memory copy."""
|
||||
nc_user, app_pw = _credentials_for(user)
|
||||
expected_user, rel = split_nextcloud_path(abs_path)
|
||||
if expected_user != nc_user:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Path belongs to a different Nextcloud user",
|
||||
)
|
||||
url = _dav_url(nc_user, rel)
|
||||
headers = {}
|
||||
if content_type:
|
||||
headers["Content-Type"] = content_type
|
||||
with _client((nc_user, app_pw)) as c:
|
||||
resp = c.request("PUT", url, content=fileobj, headers=headers)
|
||||
_raise_for_dav(resp, "upload")
|
||||
|
||||
|
||||
def ensure_parents_for_user(user: User, abs_path: str) -> None:
|
||||
"""Walk the parent chain of `abs_path` under the user's NC root and
|
||||
`mkcol` any missing collection. Stops at the user's `files/`
|
||||
directory — never tries to create that, which is owned by Nextcloud
|
||||
itself."""
|
||||
nc_user, _ = _credentials_for(user)
|
||||
expected_user, rel = split_nextcloud_path(abs_path)
|
||||
if expected_user != nc_user:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Path belongs to a different Nextcloud user",
|
||||
)
|
||||
if not rel:
|
||||
return
|
||||
parts = rel.split("/")
|
||||
if len(parts) <= 1:
|
||||
return # no intermediate dirs to make
|
||||
accum: list[str] = []
|
||||
for seg in parts[:-1]:
|
||||
accum.append(seg)
|
||||
sub_rel = "/".join(accum)
|
||||
sub_abs = os.path.join(NEXTCLOUD_USERS_ROOT, nc_user, "files", sub_rel)
|
||||
mkcol_for_user(user, sub_abs)
|
||||
|
||||
|
||||
def whoami_dir_exists(nc_username: str) -> bool:
|
||||
"""True iff the bind-mounted `<NEXTCLOUD_USERS_ROOT>/<user>/files`
|
||||
directory exists. Used by the UI to validate the override field
|
||||
without round-tripping to Nextcloud — the bind mount is enough to
|
||||
confirm Nextcloud actually has that user."""
|
||||
if not nc_username or "/" in nc_username or nc_username in (".", ".."):
|
||||
return False
|
||||
target = os.path.join(NEXTCLOUD_USERS_ROOT, nc_username, "files")
|
||||
return os.path.isdir(target)
|
||||
45
backend/app/services/secrets.py
Normal file
45
backend/app/services/secrets.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Symmetric encryption for credentials we have to store.
|
||||
|
||||
Used today for the per-user Nextcloud app password — we need the
|
||||
plaintext to put it in an outgoing HTTP Basic header, so a one-way
|
||||
hash won't do. Key is derived from `settings.secret_key` via SHA-256
|
||||
so existing deployments don't need a separate KMS dance, and a stable
|
||||
SECRET_KEY rotates these credentials automatically.
|
||||
|
||||
Fernet is symmetric AES-128-CBC + HMAC-SHA256 with a versioned
|
||||
ciphertext envelope; good enough for column-level secrecy in a
|
||||
single-host homelab. Rotate by setting a new SECRET_KEY and asking
|
||||
users to re-enter their app password.
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
from typing import Optional
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def _fernet() -> Fernet:
|
||||
# Fernet requires a 32-byte url-safe base64 key. SHA-256 of the
|
||||
# configured secret gives us exactly 32 bytes; b64-urlsafe-encode
|
||||
# to fit the API contract.
|
||||
digest = hashlib.sha256(settings.secret_key.encode("utf-8")).digest()
|
||||
return Fernet(base64.urlsafe_b64encode(digest))
|
||||
|
||||
|
||||
def encrypt(plaintext: str) -> str:
|
||||
"""Return a base64 token that can be stored in a VARCHAR column."""
|
||||
return _fernet().encrypt(plaintext.encode("utf-8")).decode("ascii")
|
||||
|
||||
|
||||
def decrypt(token: Optional[str]) -> Optional[str]:
|
||||
"""Inverse of encrypt. Returns None for None / empty input. Raises
|
||||
on tampered or wrong-key tokens — callers should treat that as
|
||||
"credential unset" rather than crashing the request."""
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
return _fernet().decrypt(token.encode("ascii")).decode("utf-8")
|
||||
except InvalidToken:
|
||||
return None
|
||||
Reference in New Issue
Block a user