- Create backend/services/cloud_operations.py as the single Phase 13 orchestration seam:
get_connection_health (D-12), test_connection (D-13), reconnect_connection (CONN-01/02/03,
D-14), disconnect_connection (D-16, explicit CloudItem cascade for SQLite compatibility)
- Add POST /connections/{id}/reconnect route (CONN-01/02/03, D-14)
- Add GET /connections/{id}/health route (D-12, T-13-02)
- Add POST /connections/{id}/test route (D-13, T-13-02)
- Update DELETE /connections/{id} to use service-layer disconnect with explicit
CloudItem deletion (D-16; covers FK-cascade-less environments like SQLite tests)
- Fix (Rule 2 - T-13-02): add _scrub_audit_metadata() to admin audit log API to
remove credential fields before returning audit rows to admin callers
- All 14 reconnect tests pass + 110 provider-contract tests pass
- Pre-existing failures in test_cloud_mutations and test_extractor are unrelated
808 lines
28 KiB
Python
808 lines
28 KiB
Python
"""
|
|
Connection management endpoints — all existing cloud connection behaviors.
|
|
|
|
Preserves all existing endpoint URLs from api/cloud.py without change.
|
|
Introduces PATCH /api/cloud/connections/{connection_id} for display_name rename.
|
|
"""
|
|
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.cloud.schemas import ConnectionRenameRequest
|
|
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, parse_uuid
|
|
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()
|
|
|
|
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 _insert_cloud_connection(
|
|
session: AsyncSession,
|
|
user_id: uuid.UUID,
|
|
provider: str,
|
|
credentials_enc: str,
|
|
) -> CloudConnection:
|
|
"""Always insert a new connection row — never merges same-provider rows."""
|
|
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
|
|
|
|
|
|
# Keep legacy alias used by OAuth callback (OAuth creates one connection per flow)
|
|
_upsert_cloud_connection = _insert_cloud_connection
|
|
|
|
|
|
async def _get_owned_connection(
|
|
session: AsyncSession,
|
|
connection_id: uuid.UUID,
|
|
user_id: uuid.UUID,
|
|
) -> CloudConnection:
|
|
"""Return the connection owned by user_id, or raise 404."""
|
|
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
|
|
|
|
|
|
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
|
|
|
@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 _insert_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", ""),
|
|
}
|
|
|
|
|
|
class WebDAVCredentialUpdateRequest(BaseModel):
|
|
server_url: Optional[str] = None
|
|
username: Optional[str] = None
|
|
password: Optional[str] = None # omit to keep existing
|
|
|
|
|
|
@router.put("/connections/{connection_id}/credentials", status_code=status.HTTP_200_OK)
|
|
@account_limiter.limit("100/minute")
|
|
async def update_webdav_credentials(
|
|
connection_id: uuid.UUID,
|
|
body: WebDAVCredentialUpdateRequest,
|
|
request: Request,
|
|
session: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_regular_user),
|
|
) -> dict:
|
|
"""Update credentials for an existing WebDAV/Nextcloud connection (owner-scoped).
|
|
|
|
Only changes the fields provided. Omitting password preserves the existing secret.
|
|
Re-validates the URL (SSRF gate) and runs a health-check before committing.
|
|
T-12-06-01: owner scope via _get_owned_connection.
|
|
T-12-06-04: re-runs validate_cloud_url and health check before storing.
|
|
"""
|
|
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="Credential update is only available for WebDAV/Nextcloud connections",
|
|
)
|
|
|
|
# Decrypt current credentials so we can merge with the submitted partial update
|
|
try:
|
|
current_creds = _decrypt_connection(conn, current_user.id)
|
|
except Exception:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="Failed to decrypt existing credentials",
|
|
)
|
|
|
|
merged = {
|
|
"server_url": body.server_url if body.server_url is not None else current_creds.get("server_url", ""),
|
|
"username": body.username if body.username is not None else current_creds.get("username", ""),
|
|
"password": body.password if body.password else current_creds.get("password", ""),
|
|
}
|
|
|
|
try:
|
|
validate_cloud_url(merged["server_url"])
|
|
except ValueError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=f"Invalid server URL: {exc}",
|
|
) from exc
|
|
|
|
try:
|
|
backend = build_cloud_backend(conn.provider, merged)
|
|
except ValueError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=f"Invalid credentials: {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: {exc}",
|
|
) from exc
|
|
|
|
conn.credentials_enc = encrypt_credentials(_master_key(), str(current_user.id), merged)
|
|
conn.status = "ACTIVE"
|
|
|
|
_ip = get_client_ip(request)
|
|
await write_audit_log(
|
|
session,
|
|
event_type="cloud.credentials_updated",
|
|
user_id=current_user.id,
|
|
actor_id=current_user.id,
|
|
resource_id=conn.id,
|
|
ip_address=_ip,
|
|
metadata_={"provider": conn.provider},
|
|
)
|
|
await session.commit()
|
|
await session.refresh(conn)
|
|
|
|
return CloudConnectionOut.model_validate(conn).model_dump()
|
|
|
|
|
|
@router.patch("/connections/{connection_id}", status_code=status.HTTP_200_OK)
|
|
@account_limiter.limit("100/minute")
|
|
async def rename_connection(
|
|
connection_id: uuid.UUID,
|
|
body: ConnectionRenameRequest,
|
|
request: Request,
|
|
session: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_regular_user),
|
|
) -> dict:
|
|
"""Rename a cloud connection's display name.
|
|
|
|
Only the display_name field is accepted — mass assignment prevention.
|
|
T-12-01: owner check via _get_owned_connection.
|
|
"""
|
|
request.state.current_user = current_user
|
|
conn = await _get_owned_connection(session, connection_id, current_user.id)
|
|
conn.display_name_override = body.display_name
|
|
await session.commit()
|
|
await session.refresh(conn)
|
|
return {"id": str(conn.id), "display_name": body.display_name}
|
|
|
|
|
|
@router.post("/connections/{connection_id}/reconnect", status_code=status.HTTP_200_OK)
|
|
@account_limiter.limit("100/minute")
|
|
async def reconnect_connection(
|
|
connection_id: uuid.UUID,
|
|
request: Request,
|
|
session: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_regular_user),
|
|
) -> dict:
|
|
"""Reconnect an AUTH_FAILED connection in-place.
|
|
|
|
CONN-01: Patches the existing CloudConnection row — no new row created.
|
|
CONN-02: Re-encrypts credentials (refreshed if possible, original otherwise).
|
|
CONN-03: Response never exposes raw credentials, tokens, or provider URLs.
|
|
D-14: Cached cloud items are preserved as stale (not deleted).
|
|
|
|
Ownership verified via service layer (T-13-01).
|
|
"""
|
|
request.state.current_user = current_user
|
|
|
|
from services.cloud_operations import reconnect_connection as _svc_reconnect
|
|
from services.cloud_items import ConnectionNotFound
|
|
from services.cloud_cache import invalidate_provider_cache # lazy import
|
|
|
|
try:
|
|
result = await _svc_reconnect(
|
|
session,
|
|
connection_id=connection_id,
|
|
user_id=current_user.id,
|
|
master_key=_master_key(),
|
|
)
|
|
except ConnectionNotFound:
|
|
raise HTTPException(status_code=404, detail="Connection not found")
|
|
|
|
# Invalidate provider cache so the next browse is fresh (D-14)
|
|
invalidate_provider_cache(str(current_user.id), result["provider"])
|
|
|
|
_ip = get_client_ip(request)
|
|
await write_audit_log(
|
|
session,
|
|
event_type="cloud.reconnect",
|
|
user_id=current_user.id,
|
|
actor_id=current_user.id,
|
|
resource_id=connection_id,
|
|
ip_address=_ip,
|
|
metadata_={"provider": result["provider"], "connection_id": str(connection_id)},
|
|
)
|
|
|
|
return result
|
|
|
|
|
|
@router.get("/connections/{connection_id}/health", status_code=status.HTTP_200_OK)
|
|
@account_limiter.limit("100/minute")
|
|
async def get_connection_health(
|
|
connection_id: uuid.UUID,
|
|
request: Request,
|
|
session: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_regular_user),
|
|
) -> dict:
|
|
"""Return connection health status.
|
|
|
|
D-12: Connection health is available explicitly (not inferred from browse).
|
|
Response includes 'status': 'healthy' | 'degraded' | 'auth_failed' | 'offline'.
|
|
Response never exposes credentials (T-13-02).
|
|
|
|
Ownership verified via service layer (T-13-01).
|
|
"""
|
|
request.state.current_user = current_user
|
|
|
|
from services.cloud_operations import get_connection_health as _svc_health
|
|
from services.cloud_items import ConnectionNotFound
|
|
|
|
try:
|
|
return await _svc_health(
|
|
session,
|
|
connection_id=connection_id,
|
|
user_id=current_user.id,
|
|
)
|
|
except ConnectionNotFound:
|
|
raise HTTPException(status_code=404, detail="Connection not found")
|
|
|
|
|
|
@router.post("/connections/{connection_id}/test", status_code=status.HTTP_200_OK)
|
|
@account_limiter.limit("100/minute")
|
|
async def test_connection(
|
|
connection_id: uuid.UUID,
|
|
request: Request,
|
|
session: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_regular_user),
|
|
) -> dict:
|
|
"""Run an explicit connection health probe and update connection status.
|
|
|
|
D-13: Explicit Test action triggers a real health probe against the provider.
|
|
No probing on every folder navigation. Updates the connection status row.
|
|
Response includes 'status' field (same vocabulary as health endpoint).
|
|
Response never exposes credentials (T-13-02).
|
|
|
|
Ownership verified via service layer (T-13-01).
|
|
"""
|
|
request.state.current_user = current_user
|
|
|
|
from services.cloud_operations import test_connection as _svc_test
|
|
from services.cloud_items import ConnectionNotFound
|
|
|
|
try:
|
|
return await _svc_test(
|
|
session,
|
|
connection_id=connection_id,
|
|
user_id=current_user.id,
|
|
master_key=_master_key(),
|
|
)
|
|
except ConnectionNotFound:
|
|
raise HTTPException(status_code=404, detail="Connection not found")
|
|
|
|
|
|
@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.
|
|
|
|
D-16: Removes credentials_enc and all connection-scoped CloudItems.
|
|
Provider files are NOT deleted — only cached metadata is cleaned up.
|
|
|
|
Ownership verified via service layer (T-13-01).
|
|
"""
|
|
request.state.current_user = current_user
|
|
|
|
# Resolve connection first to get provider for cache invalidation and audit
|
|
conn = await _get_owned_connection(session, connection_id, current_user.id)
|
|
provider = conn.provider
|
|
|
|
from services.cloud_cache import invalidate_provider_cache # lazy import
|
|
from services.cloud_operations import disconnect_connection as _svc_disconnect
|
|
from services.cloud_items import ConnectionNotFound
|
|
|
|
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},
|
|
)
|
|
|
|
# Use service layer for explicit cascade delete (D-16, T-13-12)
|
|
try:
|
|
await _svc_disconnect(
|
|
session,
|
|
connection_id=connection_id,
|
|
user_id=current_user.id,
|
|
)
|
|
except ConnectionNotFound:
|
|
# Connection was already deleted between the ownership check and the
|
|
# service call — this is idempotent, return 204 normally
|
|
pass
|
|
|
|
|
|
@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 (compatibility route).
|
|
|
|
This endpoint is the original provider-keyed browse route preserved for
|
|
backward compatibility. The canonical route is GET /connections/{id}/items.
|
|
"""
|
|
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":
|
|
from api.cloud.browse import _fetch_google_drive_folders
|
|
fetcher = lambda: _fetch_google_drive_folders(credentials, folder_id)
|
|
|
|
elif provider == "onedrive":
|
|
from api.cloud.browse import _fetch_onedrive_folders
|
|
fetcher = lambda: _fetch_onedrive_folders(credentials, folder_id)
|
|
|
|
else:
|
|
from api.cloud.browse import _fetch_webdav_folders
|
|
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}
|