659 lines
22 KiB
Python
659 lines
22 KiB
Python
"""Cloud storage connection management endpoints."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import secrets
|
|
import uuid
|
|
import urllib.parse
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
from fastapi.responses import JSONResponse, RedirectResponse
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from api.schemas import CloudConnectionOut
|
|
from config import settings
|
|
from db.models import CloudConnection, User
|
|
from deps.auth import get_regular_user
|
|
from deps.db import get_db
|
|
from deps.utils import get_client_ip
|
|
from services.audit import write_audit_log
|
|
from services.rate_limiting import account_limiter
|
|
from storage.cloud_backend_factory import build_cloud_backend
|
|
from storage.cloud_utils import decrypt_credentials, encrypt_credentials, validate_cloud_url
|
|
|
|
router = APIRouter(prefix="/api/cloud", tags=["cloud"])
|
|
users_router = APIRouter(prefix="/api/users", tags=["users"])
|
|
|
|
VALID_OAUTH_PROVIDERS = {"google_drive", "onedrive"}
|
|
VALID_WEBDAV_PROVIDERS = {"nextcloud", "webdav"}
|
|
VALID_CLOUD_PROVIDERS = VALID_OAUTH_PROVIDERS | VALID_WEBDAV_PROVIDERS
|
|
_VALID_BACKENDS = frozenset({"minio", *VALID_CLOUD_PROVIDERS})
|
|
|
|
_DISPLAY_NAMES = {
|
|
"google_drive": "Google Drive",
|
|
"onedrive": "OneDrive",
|
|
"nextcloud": "Nextcloud",
|
|
"webdav": "WebDAV server",
|
|
}
|
|
|
|
|
|
class WebDAVConnectRequest(BaseModel):
|
|
server_url: str
|
|
username: str
|
|
password: str
|
|
provider: str # "nextcloud" or "webdav"
|
|
|
|
|
|
class DefaultStorageRequest(BaseModel):
|
|
backend: str
|
|
|
|
|
|
def _master_key() -> bytes:
|
|
return settings.cloud_creds_key.encode()
|
|
|
|
|
|
def _oauth_redirect_uri(provider: str) -> str:
|
|
return f"{settings.backend_url}/api/cloud/oauth/callback/{provider}"
|
|
|
|
|
|
def _oauth_state_key(state_token: str) -> str:
|
|
return f"oauth_state:{state_token}"
|
|
|
|
|
|
def _settings_redirect(**params: str) -> RedirectResponse:
|
|
query = urllib.parse.urlencode(params)
|
|
return RedirectResponse(url=f"{settings.frontend_url}/settings?{query}", status_code=302)
|
|
|
|
|
|
def _unsupported_provider_error(provider: str, valid_providers: set[str]) -> HTTPException:
|
|
return HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Unsupported provider: {provider}. Valid providers: {sorted(valid_providers)}",
|
|
)
|
|
|
|
|
|
def _ensure_oauth_configured(provider: str) -> None:
|
|
if provider == "google_drive" and (not settings.google_client_id or not settings.google_client_secret):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Google Drive OAuth is not configured on this server. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in your environment.",
|
|
)
|
|
|
|
if provider == "onedrive" and (
|
|
not settings.onedrive_client_id
|
|
or not settings.onedrive_client_secret
|
|
or not settings.onedrive_tenant_id
|
|
):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="OneDrive OAuth is not configured on this server. Set ONEDRIVE_CLIENT_ID, ONEDRIVE_CLIENT_SECRET, and ONEDRIVE_TENANT_ID in your environment.",
|
|
)
|
|
|
|
|
|
def _webdav_credentials(body: WebDAVConnectRequest) -> dict[str, str]:
|
|
return {
|
|
"server_url": body.server_url,
|
|
"username": body.username,
|
|
"password": body.password,
|
|
}
|
|
|
|
|
|
async def _upsert_cloud_connection(
|
|
session: AsyncSession,
|
|
user_id: uuid.UUID,
|
|
provider: str,
|
|
credentials_enc: str,
|
|
) -> CloudConnection:
|
|
result = await session.execute(
|
|
select(CloudConnection).where(
|
|
CloudConnection.user_id == user_id,
|
|
CloudConnection.provider == provider,
|
|
)
|
|
)
|
|
conn = result.scalar_one_or_none()
|
|
|
|
if conn is not None:
|
|
conn.credentials_enc = credentials_enc
|
|
conn.status = "ACTIVE"
|
|
else:
|
|
conn = CloudConnection(
|
|
id=uuid.uuid4(),
|
|
user_id=user_id,
|
|
provider=provider,
|
|
display_name=_DISPLAY_NAMES.get(provider, provider),
|
|
credentials_enc=credentials_enc,
|
|
status="ACTIVE",
|
|
)
|
|
session.add(conn)
|
|
|
|
return conn
|
|
|
|
|
|
async def _get_owned_connection(
|
|
session: AsyncSession,
|
|
connection_id: uuid.UUID,
|
|
user_id: uuid.UUID,
|
|
) -> CloudConnection:
|
|
conn = await session.get(CloudConnection, connection_id)
|
|
if conn is None or conn.user_id != user_id:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found")
|
|
return conn
|
|
|
|
|
|
async def _get_active_connection(
|
|
session: AsyncSession,
|
|
user_id: uuid.UUID,
|
|
provider: str,
|
|
) -> CloudConnection:
|
|
result = await session.execute(
|
|
select(CloudConnection).where(
|
|
CloudConnection.user_id == user_id,
|
|
CloudConnection.provider == provider,
|
|
CloudConnection.status == "ACTIVE",
|
|
)
|
|
)
|
|
conn = result.scalar_one_or_none()
|
|
if conn is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="No active cloud connection found for this provider",
|
|
)
|
|
return conn
|
|
|
|
|
|
def _decrypt_connection(conn: CloudConnection, user_id: uuid.UUID) -> dict:
|
|
return decrypt_credentials(_master_key(), str(user_id), conn.credentials_enc)
|
|
|
|
|
|
async def _oauth_authorization_url(provider: str, redirect_uri: str, state_token: str) -> str:
|
|
if provider == "google_drive":
|
|
from google_auth_oauthlib.flow import Flow # lazy import
|
|
|
|
flow = Flow.from_client_config(
|
|
{
|
|
"web": {
|
|
"client_id": settings.google_client_id,
|
|
"client_secret": settings.google_client_secret,
|
|
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
|
"token_uri": "https://oauth2.googleapis.com/token",
|
|
}
|
|
},
|
|
scopes=["https://www.googleapis.com/auth/drive.file"],
|
|
redirect_uri=redirect_uri,
|
|
)
|
|
authorization_url, _ = flow.authorization_url(
|
|
access_type="offline",
|
|
prompt="consent",
|
|
state=state_token,
|
|
)
|
|
return authorization_url
|
|
|
|
if provider == "onedrive":
|
|
import msal # lazy import
|
|
|
|
app = msal.ConfidentialClientApplication(
|
|
settings.onedrive_client_id,
|
|
client_credential=settings.onedrive_client_secret,
|
|
authority=f"https://login.microsoftonline.com/{settings.onedrive_tenant_id}",
|
|
)
|
|
return await asyncio.to_thread(
|
|
app.get_authorization_request_url,
|
|
scopes=["Files.ReadWrite", "offline_access"],
|
|
redirect_uri=redirect_uri,
|
|
state=state_token,
|
|
)
|
|
|
|
raise ValueError(f"Unsupported OAuth provider: {provider}")
|
|
|
|
|
|
async def _oauth_credentials_from_code(provider: str, redirect_uri: str, code: Optional[str]) -> dict:
|
|
if provider == "google_drive":
|
|
from google_auth_oauthlib.flow import Flow # lazy import
|
|
|
|
flow = Flow.from_client_config(
|
|
{
|
|
"web": {
|
|
"client_id": settings.google_client_id,
|
|
"client_secret": settings.google_client_secret,
|
|
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
|
"token_uri": "https://oauth2.googleapis.com/token",
|
|
}
|
|
},
|
|
scopes=["https://www.googleapis.com/auth/drive.file"],
|
|
redirect_uri=redirect_uri,
|
|
)
|
|
await asyncio.to_thread(flow.fetch_token, code=code)
|
|
token_credentials = flow.credentials
|
|
return {
|
|
"access_token": token_credentials.token,
|
|
"refresh_token": token_credentials.refresh_token,
|
|
"token_uri": token_credentials.token_uri,
|
|
"client_id": token_credentials.client_id,
|
|
"client_secret": token_credentials.client_secret,
|
|
"expiry": token_credentials.expiry.isoformat() if token_credentials.expiry else None,
|
|
}
|
|
|
|
if provider == "onedrive":
|
|
import msal # lazy import
|
|
|
|
app = msal.ConfidentialClientApplication(
|
|
settings.onedrive_client_id,
|
|
client_credential=settings.onedrive_client_secret,
|
|
authority=f"https://login.microsoftonline.com/{settings.onedrive_tenant_id}",
|
|
)
|
|
result = await asyncio.to_thread(
|
|
app.acquire_token_by_authorization_code,
|
|
code,
|
|
scopes=["Files.ReadWrite", "offline_access"],
|
|
redirect_uri=redirect_uri,
|
|
)
|
|
if "error" in result:
|
|
raise ValueError(f"Token exchange failed: {result.get('error_description', result['error'])}")
|
|
return {
|
|
"access_token": result["access_token"],
|
|
"refresh_token": result.get("refresh_token", ""),
|
|
"token_uri": f"https://login.microsoftonline.com/{settings.onedrive_tenant_id}/oauth2/v2.0/token",
|
|
"client_id": settings.onedrive_client_id,
|
|
"client_secret": settings.onedrive_client_secret,
|
|
}
|
|
|
|
raise ValueError(f"Unsupported OAuth provider: {provider}")
|
|
|
|
|
|
def _connection_response(conn: CloudConnection) -> dict:
|
|
data = CloudConnectionOut.model_validate(conn).model_dump()
|
|
if conn.provider not in VALID_WEBDAV_PROVIDERS:
|
|
return data
|
|
|
|
try:
|
|
credentials = _decrypt_connection(conn, conn.user_id)
|
|
except Exception:
|
|
return data
|
|
|
|
data["server_url"] = credentials.get("server_url")
|
|
data["connection_username"] = credentials.get("username")
|
|
return data
|
|
|
|
|
|
async def _fetch_google_drive_folders(credentials: dict, folder_id: str) -> list:
|
|
from google.oauth2.credentials import Credentials # lazy import
|
|
from googleapiclient.discovery import build # lazy import
|
|
|
|
import datetime as dt
|
|
|
|
expiry = None
|
|
if expiry_str := credentials.get("expiry"):
|
|
try:
|
|
expiry = dt.datetime.fromisoformat(expiry_str)
|
|
except ValueError:
|
|
pass
|
|
|
|
creds = Credentials(
|
|
token=credentials.get("access_token"),
|
|
refresh_token=credentials.get("refresh_token"),
|
|
token_uri=credentials.get("token_uri", "https://oauth2.googleapis.com/token"),
|
|
client_id=credentials.get("client_id"),
|
|
client_secret=credentials.get("client_secret"),
|
|
expiry=expiry,
|
|
)
|
|
|
|
def _list_files() -> list:
|
|
service = build("drive", "v3", credentials=creds, cache_discovery=False)
|
|
response = service.files().list(
|
|
q=f"'{folder_id}' in parents and trashed=false",
|
|
fields="files(id,name,mimeType,size)",
|
|
pageSize=200,
|
|
).execute()
|
|
items = []
|
|
for item in response.get("files", []):
|
|
is_dir = item.get("mimeType") == "application/vnd.google-apps.folder"
|
|
items.append({
|
|
"id": item["id"],
|
|
"name": item["name"],
|
|
"is_dir": is_dir,
|
|
"size": int(item.get("size", 0)) if not is_dir else 0,
|
|
})
|
|
return items
|
|
|
|
return await asyncio.to_thread(_list_files)
|
|
|
|
|
|
async def _fetch_onedrive_folders(credentials: dict, folder_id: str) -> list:
|
|
import httpx # lazy import
|
|
|
|
if folder_id in ("root", ""):
|
|
url = "https://graph.microsoft.com/v1.0/me/drive/root/children"
|
|
else:
|
|
url = f"https://graph.microsoft.com/v1.0/me/drive/items/{folder_id}/children"
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
resp = await client.get(
|
|
url,
|
|
headers={"Authorization": f"Bearer {credentials.get('access_token', '')}"},
|
|
timeout=30,
|
|
)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
|
|
items = []
|
|
for item in data.get("value", []):
|
|
is_dir = "folder" in item
|
|
items.append({
|
|
"id": item["id"],
|
|
"name": item["name"],
|
|
"is_dir": is_dir,
|
|
"size": item.get("size", 0) if not is_dir else 0,
|
|
})
|
|
return items
|
|
|
|
|
|
async def _fetch_webdav_folders(provider: str, credentials: dict, folder_id: str) -> list:
|
|
webdav_path = "" if folder_id == "root" else folder_id
|
|
backend = build_cloud_backend(provider, credentials)
|
|
return await backend.list_folder(webdav_path)
|
|
|
|
|
|
@router.get("/oauth/initiate/{provider}")
|
|
@account_limiter.limit("100/minute")
|
|
async def oauth_initiate(
|
|
provider: str,
|
|
request: Request,
|
|
current_user: User = Depends(get_regular_user),
|
|
) -> dict:
|
|
"""Start an OAuth flow and return the provider authorization URL."""
|
|
request.state.current_user = current_user
|
|
|
|
if provider not in VALID_OAUTH_PROVIDERS:
|
|
raise _unsupported_provider_error(provider, VALID_OAUTH_PROVIDERS)
|
|
_ensure_oauth_configured(provider)
|
|
|
|
state_token = secrets.token_urlsafe(32)
|
|
await request.app.state.redis.setex(_oauth_state_key(state_token), 1800, str(current_user.id))
|
|
|
|
authorization_url = await _oauth_authorization_url(
|
|
provider,
|
|
redirect_uri=_oauth_redirect_uri(provider),
|
|
state_token=state_token,
|
|
)
|
|
return JSONResponse({"url": authorization_url})
|
|
|
|
|
|
@router.get("/oauth/callback/{provider}", response_class=RedirectResponse)
|
|
async def oauth_callback(
|
|
provider: str,
|
|
request: Request,
|
|
session: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Exchange OAuth code, store encrypted credentials, and redirect to settings."""
|
|
state = request.query_params.get("state")
|
|
code = request.query_params.get("code")
|
|
error_param = request.query_params.get("error")
|
|
|
|
try:
|
|
if error_param:
|
|
raise ValueError(f"OAuth provider returned error: {error_param}")
|
|
|
|
if provider not in VALID_OAUTH_PROVIDERS:
|
|
raise ValueError(f"Unsupported OAuth provider: {provider}")
|
|
|
|
if not state:
|
|
raise ValueError("Missing OAuth state parameter")
|
|
|
|
redis_client = request.app.state.redis
|
|
state_key = _oauth_state_key(state)
|
|
stored_user_id = await redis_client.get(state_key)
|
|
|
|
if not stored_user_id:
|
|
return _settings_redirect(
|
|
cloud_error="Invalid or expired OAuth state. Please try connecting again."
|
|
)
|
|
|
|
await redis_client.delete(state_key)
|
|
|
|
if isinstance(stored_user_id, bytes):
|
|
stored_user_id = stored_user_id.decode("utf-8")
|
|
user_id = uuid.UUID(stored_user_id)
|
|
|
|
user = await session.get(User, user_id)
|
|
if user is None or not user.is_active:
|
|
raise ValueError("User not found or inactive")
|
|
|
|
credentials = await _oauth_credentials_from_code(provider, _oauth_redirect_uri(provider), code)
|
|
credentials_enc = encrypt_credentials(_master_key(), str(user_id), credentials)
|
|
conn = await _upsert_cloud_connection(session, user_id, provider, credentials_enc)
|
|
await session.flush()
|
|
|
|
await write_audit_log(
|
|
session,
|
|
event_type="cloud.connected",
|
|
user_id=user.id,
|
|
actor_id=user.id,
|
|
resource_id=conn.id,
|
|
ip_address=None,
|
|
metadata_={"provider": provider},
|
|
)
|
|
await session.commit()
|
|
|
|
return _settings_redirect(cloud_connected=provider)
|
|
|
|
except Exception as exc:
|
|
return _settings_redirect(cloud_error=str(exc))
|
|
|
|
|
|
@router.post("/connections/webdav", status_code=status.HTTP_201_CREATED)
|
|
@account_limiter.limit("100/minute")
|
|
async def connect_webdav(
|
|
body: WebDAVConnectRequest,
|
|
request: Request,
|
|
session: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_regular_user),
|
|
) -> dict:
|
|
"""Connect a WebDAV or Nextcloud server."""
|
|
request.state.current_user = current_user
|
|
if body.provider not in VALID_WEBDAV_PROVIDERS:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=f"Unsupported WebDAV provider: {body.provider}. Valid values: {sorted(VALID_WEBDAV_PROVIDERS)}",
|
|
)
|
|
|
|
try:
|
|
validate_cloud_url(body.server_url)
|
|
except ValueError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=f"Invalid server URL: {exc}",
|
|
) from exc
|
|
|
|
try:
|
|
credentials = _webdav_credentials(body)
|
|
backend = build_cloud_backend(body.provider, credentials)
|
|
except ValueError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=f"Invalid server URL: {exc}",
|
|
) from exc
|
|
|
|
try:
|
|
ok = await backend.health_check()
|
|
if not ok:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="Connection test failed — check server URL and credentials",
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=f"Connection test failed — check server URL and credentials: {exc}",
|
|
) from exc
|
|
|
|
credentials_enc = encrypt_credentials(_master_key(), str(current_user.id), credentials)
|
|
|
|
conn = await _upsert_cloud_connection(session, current_user.id, body.provider, credentials_enc)
|
|
await session.flush()
|
|
|
|
_ip = get_client_ip(request)
|
|
await write_audit_log(
|
|
session,
|
|
event_type="cloud.connected",
|
|
user_id=current_user.id,
|
|
actor_id=current_user.id,
|
|
resource_id=conn.id,
|
|
ip_address=_ip,
|
|
metadata_={"provider": body.provider},
|
|
)
|
|
await session.commit()
|
|
await session.refresh(conn)
|
|
|
|
return CloudConnectionOut.model_validate(conn).model_dump()
|
|
|
|
|
|
@router.get("/connections")
|
|
@account_limiter.limit("100/minute")
|
|
async def list_connections(
|
|
request: Request,
|
|
session: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_regular_user),
|
|
) -> dict:
|
|
"""List the current user's cloud connections."""
|
|
request.state.current_user = current_user
|
|
result = await session.execute(
|
|
select(CloudConnection).where(CloudConnection.user_id == current_user.id)
|
|
)
|
|
return {"items": [_connection_response(conn) for conn in result.scalars().all()]}
|
|
|
|
|
|
@router.get("/connections/{connection_id}/config")
|
|
@account_limiter.limit("100/minute")
|
|
async def get_connection_config(
|
|
request: Request,
|
|
connection_id: uuid.UUID,
|
|
session: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_regular_user),
|
|
) -> dict:
|
|
"""Return non-secret WebDAV/Nextcloud connection fields."""
|
|
request.state.current_user = current_user
|
|
conn = await _get_owned_connection(session, connection_id, current_user.id)
|
|
|
|
if conn.provider not in VALID_WEBDAV_PROVIDERS:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Connection config is only available for WebDAV/Nextcloud connections",
|
|
)
|
|
|
|
try:
|
|
credentials = _decrypt_connection(conn, current_user.id)
|
|
except Exception:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="Failed to decrypt connection credentials",
|
|
)
|
|
|
|
return {
|
|
"id": str(conn.id),
|
|
"provider": conn.provider,
|
|
"server_url": credentials.get("server_url", ""),
|
|
"connection_username": credentials.get("username", ""),
|
|
}
|
|
|
|
|
|
@router.delete("/connections/{connection_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
@account_limiter.limit("100/minute")
|
|
async def delete_connection(
|
|
connection_id: uuid.UUID,
|
|
request: Request,
|
|
session: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_regular_user),
|
|
) -> None:
|
|
"""Disconnect a cloud connection."""
|
|
request.state.current_user = current_user
|
|
conn = await _get_owned_connection(session, connection_id, current_user.id)
|
|
|
|
provider = conn.provider
|
|
|
|
from services.cloud_cache import invalidate_provider_cache # lazy import
|
|
|
|
invalidate_provider_cache(str(current_user.id), provider)
|
|
|
|
_ip = get_client_ip(request)
|
|
|
|
await write_audit_log(
|
|
session,
|
|
event_type="cloud.disconnected",
|
|
user_id=current_user.id,
|
|
actor_id=current_user.id,
|
|
resource_id=conn.id,
|
|
ip_address=_ip,
|
|
metadata_={"provider": provider},
|
|
)
|
|
|
|
await session.delete(conn)
|
|
await session.commit()
|
|
|
|
|
|
@router.get("/folders/{provider}/{folder_id:path}")
|
|
@account_limiter.limit("100/minute")
|
|
async def list_cloud_folders(
|
|
request: Request,
|
|
provider: str,
|
|
folder_id: str,
|
|
session: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_regular_user),
|
|
) -> dict:
|
|
"""List folder contents for a connected cloud provider."""
|
|
request.state.current_user = current_user
|
|
if provider not in VALID_CLOUD_PROVIDERS:
|
|
raise _unsupported_provider_error(provider, VALID_CLOUD_PROVIDERS)
|
|
|
|
conn = await _get_active_connection(session, current_user.id, provider)
|
|
credentials = _decrypt_connection(conn, current_user.id)
|
|
|
|
from services.cloud_cache import get_cloud_folders_cached # lazy import
|
|
|
|
if provider == "google_drive":
|
|
fetcher = lambda: _fetch_google_drive_folders(credentials, folder_id)
|
|
|
|
elif provider == "onedrive":
|
|
fetcher = lambda: _fetch_onedrive_folders(credentials, folder_id)
|
|
|
|
else:
|
|
fetcher = lambda: _fetch_webdav_folders(provider, credentials, folder_id)
|
|
|
|
items = await get_cloud_folders_cached(str(current_user.id), provider, folder_id, fetcher)
|
|
|
|
return {"items": items}
|
|
|
|
|
|
@users_router.patch("/me/default-storage")
|
|
@account_limiter.limit("100/minute")
|
|
async def update_default_storage(
|
|
request: Request,
|
|
body: DefaultStorageRequest,
|
|
session: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_regular_user),
|
|
) -> dict:
|
|
"""Update the current user's default storage backend.
|
|
|
|
The backend value is validated against the allowlist before storage.
|
|
Returns the updated default_storage_backend value.
|
|
"""
|
|
if body.backend not in _VALID_BACKENDS:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=f"Invalid backend. Valid values: {sorted(_VALID_BACKENDS)}",
|
|
)
|
|
request.state.current_user = current_user
|
|
user = await session.get(User, current_user.id)
|
|
if user is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
|
|
|
user.default_storage_backend = body.backend
|
|
session.add(user)
|
|
await session.commit()
|
|
|
|
return {"default_storage_backend": user.default_storage_backend}
|