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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user