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>
46 lines
1.6 KiB
Python
46 lines
1.6 KiB
Python
"""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
|