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

@@ -54,6 +54,18 @@ class UserResponse(BaseModel):
created_at: Optional[str]
avatar_url: Optional[str] = None
display_name: Optional[str] = None
# Nextcloud integration — username override (defaults to OIDC
# preferred_username) and a flag for whether the user has stored
# an app password. Cleartext passwords are never serialized.
nextcloud_username: Optional[str] = None
has_nextcloud_app_password: bool = False
class UpdateMeRequest(BaseModel):
"""PATCH /me payload. Every field is optional — only what's set
gets touched. Setting `nextcloud_app_password` to "" clears it."""
nextcloud_username: Optional[str] = None
nextcloud_app_password: Optional[str] = None
class SetupRequest(BaseModel):
username: str
@@ -93,6 +105,8 @@ def serialize_user(user: User) -> UserResponse:
created_at=user.created_at.isoformat() if user.created_at else None,
avatar_url=avatar,
display_name=user.display_name,
nextcloud_username=user.nextcloud_username,
has_nextcloud_app_password=bool(user.nextcloud_app_password_enc),
)
@@ -186,6 +200,46 @@ async def get_me(current_user: User = Depends(get_current_user)):
return serialize_user(current_user)
_NC_USERNAME_RE = re.compile(r"^[a-zA-Z0-9._@-]{1,64}$")
@router.patch("/me", response_model=UserResponse)
async def update_me(
body: UpdateMeRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Update the authenticated user's Nextcloud integration settings.
`nextcloud_username` overrides the OIDC `preferred_username` default
so e.g. the local mule-image user `dtoro` can map to Nextcloud user
`admin`. `nextcloud_app_password` is encrypted at rest via the
Fernet helper in `services/secrets.py`; passing an empty string
clears it.
"""
from app.services.secrets import encrypt
changed = False
if body.nextcloud_username is not None:
candidate = body.nextcloud_username.strip()
if candidate and not _NC_USERNAME_RE.match(candidate):
raise HTTPException(status_code=400, detail="Invalid Nextcloud username")
current_user.nextcloud_username = candidate or None
changed = True
if body.nextcloud_app_password is not None:
if body.nextcloud_app_password == "":
current_user.nextcloud_app_password_enc = None
else:
current_user.nextcloud_app_password_enc = encrypt(body.nextcloud_app_password)
changed = True
if changed:
await db.commit()
await db.refresh(current_user)
return serialize_user(current_user)
@router.post("/change-password")
async def change_password(
body: ChangePasswordRequest,
@@ -426,6 +480,11 @@ async def oidc_callback(request: Request, db: AsyncSession = Depends(get_db)):
oidc_sub=sub,
avatar_url=picture,
display_name=display_name,
# Default the Nextcloud username from preferred_username so
# the common case "same name on both sides" needs zero
# configuration. Override is exposed in Settings for the
# mismatch case (e.g. authentik dtoro ↔ Nextcloud admin).
nextcloud_username=(claims.get("preferred_username") or None),
)
db.add(user)
await db.flush()
@@ -455,6 +514,14 @@ async def oidc_callback(request: Request, db: AsyncSession = Depends(get_db)):
if picture and user.avatar_url != picture:
user.avatar_url = picture
changed = True
# Backfill nextcloud_username on first OIDC login for users that
# predate the column. NEVER overwrites a value the user already
# set in Settings — once the override is non-null, it wins.
if not user.nextcloud_username:
preferred = claims.get("preferred_username")
if preferred:
user.nextcloud_username = preferred
changed = True
if admin_groups:
new_role = "admin" if admin_groups.intersection(groups) else "user"
if user.role != new_role: