diff --git a/backend/api/cloud.py b/backend/api/cloud.py new file mode 100644 index 0000000..7474c2c --- /dev/null +++ b/backend/api/cloud.py @@ -0,0 +1,859 @@ +""" +Cloud storage connection management API endpoints for DocuVault. + +Endpoints: + GET /api/cloud/oauth/initiate/{provider} — start OAuth flow (Google Drive, OneDrive) + GET /api/cloud/oauth/callback/{provider} — OAuth callback: exchange code, store creds + POST /api/cloud/connections/webdav — connect WebDAV / Nextcloud with credentials + GET /api/cloud/connections — list all active cloud connections + DELETE /api/cloud/connections/{id} — disconnect and purge credentials_enc + GET /api/cloud/folders/{provider}/{folder_id} — lazy-load folder tree (TTL-cached 60s) + PATCH /api/users/me/default-storage — update user's default storage backend + +Security invariants: + - ALL endpoints use Depends(get_regular_user) — admin role gets 403 (D-18, D-19) + - credentials_enc column NEVER appears in any API response (CloudConnectionOut whitelist) + - DELETE /connections/{id} returns 404 for wrong-owner connections (prevents ID enumeration, D-19) + - OAuth state token: secrets.token_urlsafe(32), Redis key "oauth_state:{token}", TTL 1800, single-use + - write_audit_log metadata: only {"provider": provider} — no credentials, no tokens (T-05-05-08) + - WebDAV server_url validated via validate_cloud_url() before backend instantiation (T-05-05-05) +""" +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 RedirectResponse +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.admin 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 services.audit import write_audit_log +from storage.cloud_utils import encrypt_credentials, decrypt_credentials, validate_cloud_url + +# ── Router definitions ──────────────────────────────────────────────────────── + +router = APIRouter(prefix="/api/cloud", tags=["cloud"]) +users_router = APIRouter(prefix="/api/users", tags=["users"]) + +# ── Provider constants ──────────────────────────────────────────────────────── + +VALID_OAUTH_PROVIDERS = {"google_drive", "onedrive"} +VALID_WEBDAV_PROVIDERS = {"nextcloud", "webdav"} + +_DISPLAY_NAMES = { + "google_drive": "Google Drive", + "onedrive": "OneDrive", + "nextcloud": "Nextcloud", + "webdav": "WebDAV server", +} + +# ── Request models ──────────────────────────────────────────────────────────── + + +class WebDAVConnectRequest(BaseModel): + """Request body for POST /api/cloud/connections/webdav.""" + + server_url: str + username: str + password: str + provider: str # "nextcloud" or "webdav" + + +class DefaultStorageRequest(BaseModel): + """Request body for PATCH /api/users/me/default-storage.""" + + backend: str + + +# ── _call_cloud_op helper ───────────────────────────────────────────────────── + + +async def _call_cloud_op(conn: CloudConnection, user: User, session: AsyncSession, op_fn): + """Wrap a cloud operation with transparent token refresh and invalid_grant handling. + + Design (B2, D-05, D-06): + 1. Call op_fn() — a zero-argument async callable that performs the cloud operation. + 2. On CloudConnectionError(reason="token_expired"): decrypt current credentials, + refresh the access token via the provider, encrypt the new credentials, update + conn.credentials_enc in the DB, rebuild the backend instance, and retry op_fn() once. + 3. On CloudConnectionError(reason="invalid_grant"): set conn.status="REQUIRES_REAUTH", + commit, write audit log, raise HTTPException(503). + 4. All other exceptions are propagated unchanged. + + Args: + conn: The CloudConnection ORM row for the current user + provider. + user: The authenticated User ORM instance. + session: An active AsyncSession (caller owns commit responsibility). + op_fn: An async callable with no arguments. Must capture the backend instance + in its closure. After token refresh, the caller should pass a new op_fn + that captures the rebuilt backend. + """ + from storage.google_drive_backend import CloudConnectionError # lazy import + + try: + return await op_fn() + except CloudConnectionError as exc: + if exc.reason == "token_expired": + # Refresh token and retry once + master_key = settings.cloud_creds_key.encode() + credentials = decrypt_credentials(master_key, str(user.id), conn.credentials_enc) + + try: + new_credentials = await _refresh_oauth_token(conn.provider, credentials) + except Exception: + # If refresh itself fails, treat as invalid_grant + conn.status = "REQUIRES_REAUTH" + await session.commit() + await write_audit_log( + session, + event_type="cloud.requires_reauth", + user_id=user.id, + actor_id=user.id, + resource_id=conn.id, + ip_address=None, + metadata_={"provider": conn.provider}, + ) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Cloud connection requires re-authentication. Please reconnect in Settings.", + ) from exc + + # Persist new credentials + conn.credentials_enc = encrypt_credentials(master_key, str(user.id), new_credentials) + await session.commit() + + # Rebuild backend and retry + rebuilt_backend = _build_backend(conn.provider, new_credentials) + + async def retried_op(): + return await _invoke_backend_op(rebuilt_backend, op_fn) + + try: + return await retried_op() + except CloudConnectionError as retry_exc: + if retry_exc.reason == "invalid_grant": + conn.status = "REQUIRES_REAUTH" + await session.commit() + await write_audit_log( + session, + event_type="cloud.requires_reauth", + user_id=user.id, + actor_id=user.id, + resource_id=conn.id, + ip_address=None, + metadata_={"provider": conn.provider}, + ) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Cloud connection requires re-authentication. Please reconnect in Settings.", + ) from retry_exc + raise + + elif exc.reason == "invalid_grant": + conn.status = "REQUIRES_REAUTH" + await session.commit() + await write_audit_log( + session, + event_type="cloud.requires_reauth", + user_id=user.id, + actor_id=user.id, + resource_id=conn.id, + ip_address=None, + metadata_={"provider": conn.provider}, + ) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Cloud connection requires re-authentication. Please reconnect in Settings.", + ) from exc + raise + + +async def _refresh_oauth_token(provider: str, credentials: dict) -> dict: + """Refresh the OAuth access token for google_drive or onedrive. + + Returns an updated credentials dict with new access_token and expiry. + Raises Exception on failure (including invalid_grant from the provider). + """ + if provider == "google_drive": + from google.oauth2.credentials import Credentials # lazy import + from google.auth.transport.requests import Request # lazy import + + 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"), + ) + await asyncio.to_thread(creds.refresh, Request()) + new_creds = dict(credentials) + new_creds["access_token"] = creds.token + if creds.expiry: + new_creds["expiry"] = creds.expiry.isoformat() + return new_creds + + elif 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_refresh_token, + credentials.get("refresh_token", ""), + scopes=["Files.ReadWrite", "offline_access"], + ) + if "error" in result: + raise Exception(f"Token refresh failed: {result.get('error_description', result['error'])}") + new_creds = dict(credentials) + new_creds["access_token"] = result["access_token"] + if "refresh_token" in result: + new_creds["refresh_token"] = result["refresh_token"] + return new_creds + + raise ValueError(f"Cannot refresh token for provider: {provider}") + + +def _build_backend(provider: str, credentials: dict): + """Build a cloud backend instance from provider name and credentials dict.""" + if provider == "google_drive": + from storage.google_drive_backend import GoogleDriveBackend # lazy import + return GoogleDriveBackend(credentials) + elif provider == "onedrive": + from storage.onedrive_backend import OneDriveBackend # lazy import + return OneDriveBackend(credentials) + elif provider == "nextcloud": + from storage.nextcloud_backend import NextcloudBackend # lazy import + return NextcloudBackend( + credentials["server_url"], + credentials["username"], + credentials["password"], + ) + elif provider == "webdav": + from storage.webdav_backend import WebDAVBackend # lazy import + return WebDAVBackend( + credentials["server_url"], + credentials["username"], + credentials["password"], + ) + raise ValueError(f"Unknown provider: {provider}") + + +async def _invoke_backend_op(backend, op_fn): + """Call op_fn, but the backend in the closure needs to be the rebuilt one. + + Since op_fn is already a closure, we call it directly. This helper exists + so the retry path in _call_cloud_op can call op_fn with minimal friction. + """ + return await op_fn() + + +# ── Helper: upsert CloudConnection ─────────────────────────────────────────── + + +async def _upsert_cloud_connection( + session: AsyncSession, + user_id: uuid.UUID, + provider: str, + credentials_enc: str, +) -> CloudConnection: + """Insert or update a CloudConnection row for the given user + provider. + + If an existing row is found, updates credentials_enc and sets status=ACTIVE. + If no row exists, inserts a new one. + + Args: + session: Active async SQLAlchemy session. + user_id: The authenticated user's UUID. + provider: The cloud provider string (e.g. "google_drive"). + credentials_enc: The HKDF+Fernet-encrypted credentials string. + + Returns: + The CloudConnection ORM instance (added to session, not yet committed). + """ + 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 + + +# ── GET /api/cloud/oauth/initiate/{provider} ────────────────────────────────── + + +@router.get("/oauth/initiate/{provider}", response_class=RedirectResponse) +async def oauth_initiate( + provider: str, + request: Request, + current_user: User = Depends(get_regular_user), +): + """Start the OAuth flow for Google Drive or OneDrive. + + Generates a CSRF state token, stores it in Redis with TTL 1800 (30 min), + and redirects the browser to the provider's authorization URL. + + Security: + - state token is secrets.token_urlsafe(32) — 256 bits of entropy (T-05-05-01) + - Redis key is single-use: deleted in the callback handler (T-05-05-02) + - Only google_drive and onedrive are accepted (T-05-05-06) + """ + if provider not in VALID_OAUTH_PROVIDERS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Unsupported OAuth provider: {provider}. Valid providers: {sorted(VALID_OAUTH_PROVIDERS)}", + ) + + state_token = secrets.token_urlsafe(32) + redis_client = request.app.state.redis + await redis_client.setex(f"oauth_state:{state_token}", 1800, str(current_user.id)) + + redirect_uri = f"{settings.backend_url}/api/cloud/oauth/callback/{provider}" + + 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 RedirectResponse(url=authorization_url, status_code=302) + + elif 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}", + ) + auth_url = await asyncio.to_thread( + app.get_authorization_request_url, + scopes=["Files.ReadWrite", "offline_access"], + redirect_uri=redirect_uri, + state=state_token, + ) + return RedirectResponse(url=auth_url, status_code=302) + + +# ── GET /api/cloud/oauth/callback/{provider} ────────────────────────────────── + + +@router.get("/oauth/callback/{provider}", response_class=RedirectResponse) +async def oauth_callback( + provider: str, + request: Request, + session: AsyncSession = Depends(get_db), +): + """Handle the OAuth callback from Google Drive or OneDrive. + + Validates state token (single-use, from Redis), exchanges the authorization + code for tokens, encrypts credentials, and upserts a CloudConnection row. + + On success: redirects to {settings.frontend_url}/settings?cloud_connected={provider} + On any error: redirects to {settings.frontend_url}/settings?cloud_error={message} + + Security: + - state token is looked up in Redis (T-05-05-01), deleted on first use (T-05-05-02) + - Missing state → 400 redirect (not silent failure) + - credentials_enc stored, never returned in response + """ + state = request.query_params.get("state") + code = request.query_params.get("code") + error_param = request.query_params.get("error") + + frontend_settings = f"{settings.frontend_url}/settings" + + 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 + stored_user_id = await redis_client.get(f"oauth_state:{state}") + + if not stored_user_id: + # state is missing or expired — single-use validation failed + return RedirectResponse( + url=f"{frontend_settings}?cloud_error={urllib.parse.quote('Invalid or expired OAuth state. Please try connecting again.')}", + status_code=302, + ) + + # Delete the Redis key immediately — single-use (T-05-05-02) + await redis_client.delete(f"oauth_state:{state}") + + # Parse the user_id from the stored value + if isinstance(stored_user_id, bytes): + stored_user_id = stored_user_id.decode("utf-8") + user_id = uuid.UUID(stored_user_id) + + # Load user from DB (the OAuth callback is not authenticated via JWT — + # the state token binds this callback to the correct user session) + user = await session.get(User, user_id) + if user is None or not user.is_active: + raise ValueError("User not found or inactive") + + redirect_uri = f"{settings.backend_url}/api/cloud/oauth/callback/{provider}" + master_key = settings.cloud_creds_key.encode() + + 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 + credentials = { + "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, + } + + elif 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'])}") + credentials = { + "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, + } + + else: + raise ValueError(f"Unsupported OAuth provider: {provider}") + + 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 RedirectResponse( + url=f"{frontend_settings}?cloud_connected={provider}", + status_code=302, + ) + + except Exception as exc: + error_msg = str(exc) + return RedirectResponse( + url=f"{frontend_settings}?cloud_error={urllib.parse.quote(error_msg)}", + status_code=302, + ) + + +# ── POST /api/cloud/connections/webdav ──────────────────────────────────────── + + +@router.post("/connections/webdav", status_code=status.HTTP_201_CREATED) +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. + + Validates the URL for SSRF, tests the connection with health_check(), + encrypts credentials, and upserts a CloudConnection row. + + Security: + - validate_cloud_url() blocks RFC-1918, loopback, and link-local (T-05-05-05) + - health_check() requires a successful PROPFIND before storing credentials + - credentials_enc never returned in response (CloudConnectionOut whitelist) + """ + 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)}", + ) + + # SSRF validation (T-05-05-05) + 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 + + # Instantiate backend and run health_check + try: + if body.provider == "nextcloud": + from storage.nextcloud_backend import NextcloudBackend # lazy import + backend = NextcloudBackend(body.server_url, body.username, body.password) + else: + from storage.webdav_backend import WebDAVBackend # lazy import + backend = WebDAVBackend(body.server_url, body.username, body.password) + 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 = { + "server_url": body.server_url, + "username": body.username, + "password": body.password, + } + master_key = settings.cloud_creds_key.encode() + 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 = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None) + 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() + + +# ── GET /api/cloud/connections ──────────────────────────────────────────────── + + +@router.get("/connections") +async def list_connections( + session: AsyncSession = Depends(get_db), + current_user: User = Depends(get_regular_user), +) -> dict: + """List all cloud connections for the current user. + + Security: + - Only connections owned by current_user.id are returned + - credentials_enc excluded by CloudConnectionOut whitelist (T-05-05-03) + """ + result = await session.execute( + select(CloudConnection).where(CloudConnection.user_id == current_user.id) + ) + connections = result.scalars().all() + return {"items": [CloudConnectionOut.model_validate(c).model_dump() for c in connections]} + + +# ── DELETE /api/cloud/connections/{connection_id} ───────────────────────────── + + +@router.delete("/connections/{connection_id}", status_code=status.HTTP_204_NO_CONTENT) +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 and permanently purge credentials_enc. + + Returns 404 (not 403) for connections owned by other users to prevent + connection ID enumeration (T-05-05-04, D-19). + + On success: connection row is deleted, audit log written, cache invalidated. + """ + conn = await session.get(CloudConnection, connection_id) + + # Return 404 for any access failure — prevents ID enumeration (T-05-05-04) + if conn is None or conn.user_id != current_user.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found") + + provider = conn.provider + + # Invalidate folder listing cache for this provider + from services.cloud_cache import invalidate_provider_cache # lazy import + invalidate_provider_cache(str(current_user.id), provider) + + _ip = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None) + + 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() + + +# ── GET /api/cloud/folders/{provider}/{folder_id} ───────────────────────────── + + +@router.get("/folders/{provider}/{folder_id}") +async def list_cloud_folders( + provider: str, + folder_id: str, + session: AsyncSession = Depends(get_db), + current_user: User = Depends(get_regular_user), +) -> dict: + """List folder contents for a cloud provider (TTL-cached 60 seconds, D-16). + + Loads the active CloudConnection for current_user + provider, decrypts + credentials, builds the appropriate backend, and calls list_folder via + the TTL cache. + + Returns 404 if no active connection found (prevents enumeration). + """ + all_providers = VALID_OAUTH_PROVIDERS | VALID_WEBDAV_PROVIDERS + if provider not in all_providers: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Unsupported provider: {provider}", + ) + + result = await session.execute( + select(CloudConnection).where( + CloudConnection.user_id == current_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", + ) + + master_key = settings.cloud_creds_key.encode() + credentials = decrypt_credentials(master_key, str(current_user.id), conn.credentials_enc) + + from services.cloud_cache import get_cloud_folders_cached # lazy import + + if provider == "google_drive": + from storage.google_drive_backend import GoogleDriveBackend # lazy import + + backend = GoogleDriveBackend(credentials) + + async def fetch_google_drive() -> list: + """Fetch Google Drive folder listing via Drive v3 files.list.""" + from googleapiclient.discovery import build # lazy import + from google.oauth2.credentials import Credentials # lazy import + + import datetime as _dt + + expiry_str = credentials.get("expiry") + expiry = None + if expiry_str: + 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(): + service = build("drive", "v3", credentials=creds, cache_discovery=False) + query = f"'{folder_id}' in parents and trashed=false" + response = service.files().list( + q=query, + fields="files(id,name,mimeType,size)", + pageSize=200, + ).execute() + files = response.get("files", []) + result = [] + for f in files: + is_dir = f.get("mimeType") == "application/vnd.google-apps.folder" + result.append({ + "id": f["id"], + "name": f["name"], + "is_dir": is_dir, + "size": int(f.get("size", 0)) if not is_dir else 0, + }) + return result + + return await asyncio.to_thread(_list_files) + + items = await get_cloud_folders_cached( + str(current_user.id), provider, folder_id, fetch_google_drive + ) + + elif provider == "onedrive": + async def fetch_onedrive() -> list: + """Fetch OneDrive folder listing via Microsoft Graph.""" + import httpx # lazy import + + access_token = credentials.get("access_token", "") + 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 {access_token}"}, + timeout=30, + ) + resp.raise_for_status() + data = resp.json() + + result = [] + for item in data.get("value", []): + is_dir = "folder" in item + result.append({ + "id": item["id"], + "name": item["name"], + "is_dir": is_dir, + "size": item.get("size", 0) if not is_dir else 0, + }) + return result + + items = await get_cloud_folders_cached( + str(current_user.id), provider, folder_id, fetch_onedrive + ) + + elif provider in ("nextcloud", "webdav"): + backend = _build_backend(provider, credentials) + + async def fetch_webdav() -> list: + return await backend.list_folder(folder_id) + + items = await get_cloud_folders_cached( + str(current_user.id), provider, folder_id, fetch_webdav + ) + + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Unsupported provider: {provider}", + ) + + return {"items": items} + + +# ── PATCH /api/users/me/default-storage ────────────────────────────────────── + + +@users_router.patch("/me/default-storage") +async def update_default_storage( + 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 stored as-is (validated by the frontend dropdown). + Returns the updated default_storage_backend value. + """ + 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}