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

@@ -1,7 +1,9 @@
import uuid
import asyncio
from pathlib import Path
from typing import Any
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from fastapi.responses import Response
from fastapi.security.utils import get_authorization_scheme_param
@@ -33,6 +35,45 @@ _AUDIO_CONTENT_TYPES: dict[str, str] = {
}
async def _download_with_retry(storage: NextcloudClient, file_path: str, max_retries: int = 3) -> bytes:
"""Download file from Nextcloud with retry logic for transient errors."""
last_error = None
for attempt in range(max_retries):
try:
data = await storage.download(file_path)
return data
except httpx.ConnectError as e:
last_error = e
if attempt < max_retries - 1:
# Exponential backoff: 1s, 2s, 4s
wait_time = 2 ** attempt
await asyncio.sleep(wait_time)
continue
except httpx.HTTPStatusError as e:
# Don't retry on 4xx errors (client errors)
if e.response.status_code >= 500:
last_error = e
if attempt < max_retries - 1:
wait_time = 2 ** attempt
await asyncio.sleep(wait_time)
continue
else:
raise
except Exception as e:
last_error = e
break
# If we exhausted retries, raise the last error
if last_error:
raise last_error
else:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Failed to download file from storage"
)
async def _member_from_request(
request: Request,
token: str | None = Query(None),
@@ -124,7 +165,8 @@ async def create_version(
except PermissionError:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member")
song_svc = SongService(session)
storage = NextcloudClient.for_member(current_member)
song_svc = SongService(session, storage=storage)
version = await song_svc.register_version(song_id, data, current_member.id)
return AudioVersionRead.model_validate(version)
@@ -138,8 +180,36 @@ async def get_waveform(
version, _ = await _get_version_and_assert_band_membership(version_id, session, current_member)
if not version.waveform_url:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Waveform not ready")
storage = NextcloudClient()
data = await storage.download(version.waveform_url)
storage = NextcloudClient.for_member(current_member)
if storage is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No storage provider configured for this account"
)
try:
data = await _download_with_retry(storage, version.waveform_url)
except httpx.ConnectError as e:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Failed to connect to storage: {str(e)}"
)
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Waveform file not found in storage"
)
else:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Storage error: {str(e)}"
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to fetch waveform: {str(e)}"
)
import json
return json.loads(data)
@@ -161,7 +231,35 @@ async def stream_version(
else:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No audio file")
storage = NextcloudClient()
data = await storage.download(file_path)
storage = NextcloudClient.for_member(current_member)
if storage is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No storage provider configured for this account"
)
try:
data = await _download_with_retry(storage, file_path)
except httpx.ConnectError as e:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Failed to connect to storage: {str(e)}"
)
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found in storage"
)
else:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Storage error: {str(e)}"
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to stream file: {str(e)}"
)
content_type = _AUDIO_CONTENT_TYPES.get(Path(file_path).suffix.lower(), "application/octet-stream")
return Response(content=data, media_type=content_type)