Replaces per-member Nextcloud credentials with a BandStorage model that supports multiple providers. Credentials are Fernet-encrypted at rest; worker receives audio via an internal streaming endpoint instead of direct storage access. - Add BandStorage DB model with partial unique index (one active per band) - Add migrations 0007 (create band_storage) and 0008 (drop old nc columns) - Add StorageFactory that builds the correct StorageClient from BandStorage - Add storage router: connect/nextcloud, OAuth2 authorize/callback, list, disconnect - Add Fernet encryption helpers in security/encryption.py - Rewrite watcher for per-band polling via internal API config endpoint - Update worker to stream audio from API instead of accessing storage directly - Update frontend: new storage API in bands.ts, rewritten StorageSection, simplified band creation modal (no storage step) - Add STORAGE_ENCRYPTION_KEY to all docker-compose files Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
163 lines
6.1 KiB
Python
Executable File
163 lines
6.1 KiB
Python
Executable File
"""Nextcloud WebDAV + OCS storage client."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import xml.etree.ElementTree as ET
|
|
from typing import Any
|
|
from urllib.parse import unquote
|
|
|
|
import httpx
|
|
|
|
from rehearsalhub.storage.protocol import FileMetadata
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_DAV_NS = "{DAV:}"
|
|
|
|
|
|
class NextcloudClient:
|
|
def __init__(
|
|
self,
|
|
base_url: str,
|
|
username: str,
|
|
password: str,
|
|
) -> None:
|
|
if not base_url or not username:
|
|
raise ValueError("Nextcloud credentials must be provided explicitly")
|
|
self._base = base_url.rstrip("/")
|
|
self._username = username
|
|
self._auth = (username, password)
|
|
self._dav_root = f"{self._base}/remote.php/dav/files/{username}"
|
|
# Prefix stripped from WebDAV hrefs to produce relative paths
|
|
self._dav_prefix = f"/remote.php/dav/files/{username}/"
|
|
|
|
def _client(self) -> httpx.AsyncClient:
|
|
return httpx.AsyncClient(auth=self._auth, timeout=30.0)
|
|
|
|
def _dav_url(self, path: str) -> str:
|
|
return f"{self._dav_root}/{path.lstrip('/')}"
|
|
|
|
async def create_folder(self, path: str) -> None:
|
|
async with self._client() as c:
|
|
resp = await c.request("MKCOL", self._dav_url(path))
|
|
if resp.status_code not in (201, 405): # 405 = already exists
|
|
resp.raise_for_status()
|
|
|
|
async def get_file_metadata(self, path: str) -> FileMetadata:
|
|
body = (
|
|
'<?xml version="1.0"?>'
|
|
'<d:propfind xmlns:d="DAV:">'
|
|
" <d:prop><d:getcontentlength/><d:getetag/><d:getcontenttype/></d:prop>"
|
|
"</d:propfind>"
|
|
)
|
|
async with self._client() as c:
|
|
resp = await c.request(
|
|
"PROPFIND",
|
|
self._dav_url(path),
|
|
headers={"Depth": "0", "Content-Type": "application/xml"},
|
|
content=body,
|
|
)
|
|
resp.raise_for_status()
|
|
return _parse_propfind_single(resp.text, path)
|
|
|
|
async def list_folder(self, path: str) -> list[FileMetadata]:
|
|
body = (
|
|
'<?xml version="1.0"?>'
|
|
'<d:propfind xmlns:d="DAV:">'
|
|
" <d:prop><d:getcontentlength/><d:getetag/><d:getcontenttype/></d:prop>"
|
|
"</d:propfind>"
|
|
)
|
|
async with self._client() as c:
|
|
resp = await c.request(
|
|
"PROPFIND",
|
|
self._dav_url(path),
|
|
headers={"Depth": "1", "Content-Type": "application/xml"},
|
|
content=body,
|
|
)
|
|
resp.raise_for_status()
|
|
items = _parse_propfind_multi(resp.text)
|
|
# Normalise WebDAV absolute hrefs to provider-relative paths so callers
|
|
# never need to know about DAV internals. URL-decode to handle
|
|
# filenames that contain spaces or non-ASCII characters.
|
|
for item in items:
|
|
decoded = unquote(item.path)
|
|
if decoded.startswith(self._dav_prefix):
|
|
item.path = decoded[len(self._dav_prefix):]
|
|
else:
|
|
item.path = decoded.lstrip("/")
|
|
return items
|
|
|
|
async def download(self, path: str) -> bytes:
|
|
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)
|
|
|
|
async def delete(self, path: str) -> None:
|
|
async with self._client() as c:
|
|
resp = await c.request("DELETE", self._dav_url(path))
|
|
resp.raise_for_status()
|
|
|
|
async def get_activities(self, since_id: int = 0, limit: int = 50) -> list[dict[str, Any]]:
|
|
"""Fetch recent file activity from Nextcloud OCS API."""
|
|
url = f"{self._base}/ocs/v2.php/apps/activity/api/v2/activity/files"
|
|
params: dict[str, Any] = {"since": since_id, "limit": limit, "format": "json"}
|
|
async with self._client() as c:
|
|
resp = await c.get(url, params=params, headers={"OCS-APIRequest": "true"})
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
return data.get("ocs", {}).get("data", [])
|
|
|
|
|
|
def _parse_propfind_single(xml_text: str, path: str) -> FileMetadata:
|
|
root = ET.fromstring(xml_text)
|
|
response = root.find(f"{_DAV_NS}response")
|
|
return _response_to_metadata(response, path) # type: ignore[arg-type]
|
|
|
|
|
|
def _parse_propfind_multi(xml_text: str) -> list[FileMetadata]:
|
|
root = ET.fromstring(xml_text)
|
|
results = []
|
|
for i, response in enumerate(root.findall(f"{_DAV_NS}response")):
|
|
if i == 0:
|
|
continue # skip the folder itself
|
|
href = response.findtext(f"{_DAV_NS}href") or ""
|
|
results.append(_response_to_metadata(response, href))
|
|
return results
|
|
|
|
|
|
def _response_to_metadata(response: ET.Element, fallback_path: str) -> FileMetadata:
|
|
propstat = response.find(f"{_DAV_NS}propstat")
|
|
prop = propstat.find(f"{_DAV_NS}prop") if propstat is not None else None # type: ignore[union-attr]
|
|
href = response.findtext(f"{_DAV_NS}href") or fallback_path
|
|
etag = (prop.findtext(f"{_DAV_NS}getetag") or "").strip('"') if prop is not None else ""
|
|
size_text = prop.findtext(f"{_DAV_NS}getcontentlength") if prop is not None else "0"
|
|
ctype = (
|
|
prop.findtext(f"{_DAV_NS}getcontenttype") or "application/octet-stream"
|
|
if prop is not None
|
|
else "application/octet-stream"
|
|
)
|
|
return FileMetadata(
|
|
path=href,
|
|
etag=etag,
|
|
size=int(size_text or 0),
|
|
content_type=ctype,
|
|
)
|