feat: remove global Nextcloud config, enforce member-specific storage providers

- Remove global Nextcloud settings from config
- Make NextcloudClient require explicit credentials
- Update for_member() to return None when no credentials
- Modify services to accept optional storage client
- Update routers to pass member storage to services
- Add 403 responses when no storage provider configured
- Update internal endpoints to use member storage credentials

This change enforces that each member must configure their own
Nextcloud storage provider. If no provider is configured,
file operations will return 403 FORBIDDEN instead of falling
back to global placeholders.
This commit is contained in:
Mistral Vibe
2026-03-29 20:06:12 +02:00
parent 5e169342db
commit 02fd556372
8 changed files with 155 additions and 31 deletions

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
import xml.etree.ElementTree as ET
from typing import Any
@@ -10,31 +11,34 @@ import httpx
from rehearsalhub.config import get_settings
from rehearsalhub.storage.protocol import FileMetadata
logger = logging.getLogger(__name__)
_DAV_NS = "{DAV:}"
class NextcloudClient:
def __init__(
self,
base_url: str | None = None,
username: str | None = None,
password: str | None = None,
base_url: str,
username: str,
password: str,
) -> None:
s = get_settings()
self._base = (base_url or s.nextcloud_url).rstrip("/")
self._auth = (username or s.nextcloud_user, password or s.nextcloud_pass)
if not base_url or not username:
raise ValueError("Nextcloud credentials must be provided explicitly")
self._base = base_url.rstrip("/")
self._auth = (username, password)
self._dav_root = f"{self._base}/remote.php/dav/files/{self._auth[0]}"
@classmethod
def for_member(cls, member: object) -> "NextcloudClient":
"""Return a client using member's personal NC credentials if configured,
falling back to the global env-var credentials."""
def for_member(cls, member: object) -> "NextcloudClient | None":
"""Return a client using member's personal NC credentials if configured.
Returns None if member has no Nextcloud configuration."""
nc_url = getattr(member, "nc_url", None)
nc_username = getattr(member, "nc_username", None)
nc_password = getattr(member, "nc_password", None)
if nc_url and nc_username and nc_password:
return cls(base_url=nc_url, username=nc_username, password=nc_password)
return cls()
return None
def _client(self) -> httpx.AsyncClient:
return httpx.AsyncClient(auth=self._auth, timeout=30.0)
@@ -83,10 +87,23 @@ class NextcloudClient:
return _parse_propfind_multi(resp.text)
async def download(self, path: str) -> bytes:
async with self._client() as c:
resp = await c.get(self._dav_url(path))
resp.raise_for_status()
return resp.content
logger.debug("Downloading file from Nextcloud: %s", path)
try:
async with self._client() as c:
resp = await c.get(self._dav_url(path))
resp.raise_for_status()
logger.debug("Successfully downloaded file: %s", path)
return resp.content
except httpx.ConnectError as e:
logger.error("Failed to connect to Nextcloud at %s: %s", self._base, str(e))
raise
except httpx.HTTPStatusError as e:
logger.error("Nextcloud request failed for %s: %s (status: %d)",
path, str(e), e.response.status_code)
raise
except Exception as e:
logger.error("Unexpected error downloading from Nextcloud: %s", str(e))
raise
async def get_direct_url(self, path: str) -> str:
return self._dav_url(path)