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:
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