b1a136b5be
CR-01: add `except HTTPException: raise` before broad except in stream_document_content — prevents 503 (reconnect prompt) from being swallowed and replaced with misleading 502 CR-02: move pre-flight credential checks BEFORE Redis setex in oauth_initiate — no orphan state tokens written for unconfigured providers; also adds onedrive_tenant_id to OneDrive pre-flight condition (WR-02) CR-03: add CLOUD_CREDS_KEY to celery-worker environment in docker-compose.yml — worker cannot decrypt cloud credentials without this key; every cloud document task was silently failing at runtime WR-03: assert Redis store empty after 400 pre-flight responses in both new tests — confirms no token leak on misconfigured-provider requests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
947 lines
37 KiB
Python
947 lines
37 KiB
Python
"""
|
|
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}")
|
|
async def oauth_initiate(
|
|
provider: str,
|
|
request: Request,
|
|
current_user: User = Depends(get_regular_user),
|
|
) -> dict:
|
|
"""Start the OAuth flow for Google Drive or OneDrive.
|
|
|
|
Generates a CSRF state token, stores it in Redis with TTL 1800 (30 min),
|
|
and returns the provider's authorization URL as JSON so the frontend can
|
|
navigate using fetch() with the Bearer header (plan 05-10 fix).
|
|
|
|
Returns: {"url": "<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)
|
|
- Endpoint requires get_regular_user — no unauthenticated access (T-05-10-01)
|
|
"""
|
|
from fastapi.responses import JSONResponse # already available via fastapi
|
|
|
|
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)}",
|
|
)
|
|
|
|
# Pre-flight: validate credentials are configured before allocating Redis state
|
|
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.",
|
|
)
|
|
|
|
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 JSONResponse({"url": authorization_url})
|
|
|
|
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 JSONResponse({"url": auth_url})
|
|
|
|
|
|
# ── 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()
|
|
items = []
|
|
master_key = settings.cloud_creds_key.encode()
|
|
for conn in connections:
|
|
d = CloudConnectionOut.model_validate(conn).model_dump()
|
|
if conn.provider in ("nextcloud", "webdav"):
|
|
try:
|
|
creds = decrypt_credentials(master_key, str(conn.user_id), conn.credentials_enc)
|
|
d["server_url"] = creds.get("server_url")
|
|
d["connection_username"] = creds.get("username")
|
|
except Exception:
|
|
pass
|
|
items.append(d)
|
|
return {"items": items}
|
|
|
|
|
|
# ── GET /api/cloud/connections/{connection_id}/config ────────────────────────
|
|
|
|
|
|
@router.get("/connections/{connection_id}/config")
|
|
async def get_connection_config(
|
|
connection_id: uuid.UUID,
|
|
session: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_regular_user),
|
|
) -> dict:
|
|
"""Return non-secret configuration fields for a WebDAV/Nextcloud connection.
|
|
|
|
Returns server_url and connection_username (not password) so the frontend
|
|
can pre-populate the Edit modal without exposing credentials.
|
|
|
|
Only applicable to WebDAV / Nextcloud connections (not OAuth providers).
|
|
Returns 404 for wrong-owner or unknown connections (prevents ID enumeration).
|
|
Returns 400 for OAuth providers (no non-secret config to return).
|
|
|
|
Security:
|
|
- Only connection owned by current_user.id is returned (T-05-05-04)
|
|
- password is never included in the response (D-18)
|
|
- Returns 404 for wrong-owner connections (prevents ID enumeration)
|
|
"""
|
|
conn = await session.get(CloudConnection, connection_id)
|
|
if conn is None or conn.user_id != current_user.id:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found")
|
|
|
|
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",
|
|
)
|
|
|
|
master_key = settings.cloud_creds_key.encode()
|
|
try:
|
|
credentials = decrypt_credentials(master_key, str(current_user.id), conn.credentials_enc)
|
|
except Exception:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="Failed to decrypt connection credentials",
|
|
)
|
|
|
|
# Return non-secret fields only — never expose the password
|
|
return {
|
|
"id": str(conn.id),
|
|
"provider": conn.provider,
|
|
"server_url": credentials.get("server_url", ""),
|
|
"connection_username": credentials.get("username", ""),
|
|
}
|
|
|
|
|
|
# ── 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:path}")
|
|
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)
|
|
# "root" is a frontend sentinel meaning the WebDAV root; translate to ""
|
|
webdav_path = "" if folder_id == "root" else folder_id
|
|
|
|
async def fetch_webdav() -> list:
|
|
return await backend.list_folder(webdav_path)
|
|
|
|
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}
|