feat(12-02): decompose api/cloud.py into package with connection-ID browse endpoint
- Add api/cloud/ package: connections.py, browse.py, schemas.py, __init__.py
- Add GET /api/cloud/connections/{connection_id}/items (T-12-01 IDOR, T-12-03 cred-free)
- Add PATCH /api/cloud/connections/{id} for display_name_override rename
- Add display_name_override ORM field to CloudConnection model
- Add CloudResourceAdapter service layer with str/UUID coercion
- Fix UUID type compatibility: test_cloud_items.py now uses UUID(as_uuid=True)
matching conftest — removes String(36) patch that caused type incompatibility
- Add 11 Phase 12 integration tests (IDOR, admin block, credential exclusion,
duplicate providers, rename, malformed UUID)
- Remove deleted api/cloud.py (replaced by api/cloud/ package)
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
Cloud API package — router aggregator.
|
||||
|
||||
One parent router owns the /api/cloud prefix.
|
||||
Sub-routers (connections, browse) carry no prefix — paths are declared
|
||||
relative to the parent (D-04 pattern, same as api/documents/__init__.py).
|
||||
|
||||
main.py imports:
|
||||
from api.cloud import router as cloud_router, users_router
|
||||
app.include_router(cloud_router)
|
||||
app.include_router(users_router)
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.cloud.connections import router as connections_router, _VALID_BACKENDS
|
||||
from api.cloud.browse import router as browse_router
|
||||
from db.models import User
|
||||
from deps.auth import get_regular_user
|
||||
from deps.db import get_db
|
||||
from services.rate_limiting import account_limiter
|
||||
|
||||
router = APIRouter(prefix="/api/cloud", tags=["cloud"])
|
||||
router.include_router(connections_router)
|
||||
router.include_router(browse_router)
|
||||
|
||||
# ── Users router (default storage) ───────────────────────────────────────────
|
||||
|
||||
users_router = APIRouter(prefix="/api/users", tags=["users"])
|
||||
|
||||
|
||||
class DefaultStorageRequest(BaseModel):
|
||||
backend: str
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
Validated against the allowlist before storage.
|
||||
"""
|
||||
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}
|
||||
@@ -0,0 +1,333 @@
|
||||
"""
|
||||
Canonical connection-ID browse endpoint for Phase 12.
|
||||
|
||||
GET /api/cloud/connections/{connection_id}/items
|
||||
- Owner-scoped: resolves connection by UUID, not provider name
|
||||
- Stale-while-revalidate: returns durable cached rows immediately; schedules
|
||||
background refresh if stale or first visit
|
||||
- T-12-01: IDOR-protected via resolve_owned_connection
|
||||
- T-12-03: response uses whitelisted schemas, no credentials in response
|
||||
- D-18/CACHE-01: never downloads file bytes, never mutates quota
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.cloud.schemas import (
|
||||
CloudBrowseResponse,
|
||||
CloudCapabilityOut,
|
||||
CloudItemOut,
|
||||
FolderFreshnessOut,
|
||||
)
|
||||
from db.models import CloudConnection, CloudItem, CloudFolderState, User
|
||||
from deps.auth import get_regular_user
|
||||
from deps.db import get_db
|
||||
from deps.utils import parse_uuid
|
||||
from services.cloud_items import (
|
||||
ConnectionNotFound,
|
||||
list_cloud_children,
|
||||
get_or_create_folder_state,
|
||||
reconcile_cloud_listing,
|
||||
resolve_owned_connection,
|
||||
update_folder_state,
|
||||
)
|
||||
from services.rate_limiting import account_limiter
|
||||
from storage.cloud_backend_factory import build_cloud_resource_adapter
|
||||
from storage.cloud_utils import decrypt_credentials, encrypt_credentials
|
||||
from config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_DISPLAY_NAMES = {
|
||||
"google_drive": "Google Drive",
|
||||
"onedrive": "OneDrive",
|
||||
"nextcloud": "Nextcloud",
|
||||
"webdav": "WebDAV server",
|
||||
}
|
||||
|
||||
|
||||
def _master_key() -> bytes:
|
||||
return settings.cloud_creds_key.encode()
|
||||
|
||||
|
||||
def _decrypt_connection(conn: CloudConnection, user_id: uuid.UUID) -> dict:
|
||||
return decrypt_credentials(_master_key(), str(user_id), conn.credentials_enc)
|
||||
|
||||
|
||||
def _capability_out(caps: dict) -> dict[str, CloudCapabilityOut]:
|
||||
return {
|
||||
action: CloudCapabilityOut(
|
||||
action=cap.action,
|
||||
state=cap.state,
|
||||
reason=cap.reason,
|
||||
message=cap.message,
|
||||
)
|
||||
for action, cap in caps.items()
|
||||
}
|
||||
|
||||
|
||||
def _item_out(item: CloudItem) -> CloudItemOut:
|
||||
return CloudItemOut(
|
||||
id=str(item.id),
|
||||
provider_item_id=item.provider_item_id,
|
||||
name=item.name,
|
||||
kind=item.kind,
|
||||
parent_ref=item.parent_ref,
|
||||
content_type=item.content_type,
|
||||
size=item.provider_size,
|
||||
modified_at=item.modified_at,
|
||||
etag=item.etag,
|
||||
capabilities={}, # Phase 13 will add per-item capabilities
|
||||
)
|
||||
|
||||
|
||||
def _freshness_out(fs: CloudFolderState) -> FolderFreshnessOut:
|
||||
return FolderFreshnessOut(
|
||||
refresh_state=fs.refresh_state,
|
||||
last_refreshed_at=fs.last_refreshed_at,
|
||||
error_code=fs.error_code,
|
||||
error_message=fs.error_message,
|
||||
)
|
||||
|
||||
|
||||
# ── Legacy helpers used by compatibility route in connections.py ─────────────
|
||||
|
||||
async def _fetch_google_drive_folders(credentials: dict, folder_id: str) -> list:
|
||||
"""Legacy listing helper preserved for compatibility route."""
|
||||
import datetime as dt
|
||||
from google.oauth2.credentials import Credentials as GoogleCreds
|
||||
from googleapiclient.discovery import build
|
||||
|
||||
expiry = None
|
||||
if expiry_str := credentials.get("expiry"):
|
||||
try:
|
||||
expiry = dt.datetime.fromisoformat(expiry_str)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
creds = GoogleCreds(
|
||||
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:
|
||||
"""Legacy listing helper preserved for compatibility route."""
|
||||
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:
|
||||
"""Legacy listing helper preserved for compatibility route."""
|
||||
from storage.cloud_backend_factory import build_cloud_backend
|
||||
webdav_path = "" if folder_id == "root" else folder_id
|
||||
backend = build_cloud_backend(provider, credentials)
|
||||
return await backend.list_folder(webdav_path)
|
||||
|
||||
|
||||
# ── Canonical connection-ID browse endpoint ──────────────────────────────────
|
||||
|
||||
@router.get("/connections/{connection_id}/items", response_model=CloudBrowseResponse)
|
||||
@account_limiter.limit("100/minute")
|
||||
async def browse_connection_items(
|
||||
connection_id: uuid.UUID,
|
||||
request: Request,
|
||||
parent_ref: Optional[str] = None,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
) -> CloudBrowseResponse:
|
||||
"""Browse the items of a connection by UUID.
|
||||
|
||||
T-12-01: IDOR protection — connection resolved by owner UUID, not provider alone.
|
||||
T-12-03: response schema excludes credentials_enc and all credential fields.
|
||||
D-18/CACHE-01: returns durable cached rows; never downloads file bytes.
|
||||
D-11/D-12: cached rows returned immediately; background refresh scheduled if stale.
|
||||
D-13/D-14: on refresh failure, cached rows retained with warning state.
|
||||
"""
|
||||
request.state.current_user = current_user
|
||||
|
||||
uid_str = str(current_user.id)
|
||||
cid_str = str(connection_id)
|
||||
|
||||
# Resolve connection ownership (T-12-01)
|
||||
try:
|
||||
conn = await resolve_owned_connection(
|
||||
session,
|
||||
connection_id=cid_str,
|
||||
user_id=uid_str,
|
||||
)
|
||||
except ConnectionNotFound:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found")
|
||||
|
||||
# Ensure folder state row exists
|
||||
folder_state = await get_or_create_folder_state(
|
||||
session,
|
||||
user_id=uid_str,
|
||||
connection_id=cid_str,
|
||||
parent_ref=parent_ref or "",
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
# Return durable cached rows
|
||||
cached_items = await list_cloud_children(
|
||||
session,
|
||||
user_id=uid_str,
|
||||
connection_id=cid_str,
|
||||
parent_ref=parent_ref,
|
||||
)
|
||||
|
||||
# Get connection-level capabilities (from a fresh call to avoid stale state)
|
||||
try:
|
||||
credentials = _decrypt_connection(conn, current_user.id)
|
||||
adapter = build_cloud_resource_adapter(conn.provider, credentials)
|
||||
conn_caps = await adapter.get_capabilities(connection_id, current_user.id)
|
||||
except Exception:
|
||||
from storage.cloud_base import ACTIONS, CloudCapability, STATE_TEMPORARILY_UNAVAILABLE, REASON_OFFLINE
|
||||
conn_caps = {
|
||||
action: CloudCapability(
|
||||
action=action,
|
||||
state=STATE_TEMPORARILY_UNAVAILABLE,
|
||||
reason=REASON_OFFLINE,
|
||||
message="Provider temporarily unreachable.",
|
||||
)
|
||||
for action in ACTIONS
|
||||
}
|
||||
|
||||
# Refresh in background if first visit (no items) or stale
|
||||
should_refresh = (
|
||||
not cached_items
|
||||
or folder_state.refresh_state in ("refreshing",)
|
||||
or folder_state.last_refreshed_at is None
|
||||
)
|
||||
if should_refresh and not cached_items:
|
||||
# First visit: do a synchronous bounded fetch so user sees content
|
||||
try:
|
||||
credentials = _decrypt_connection(conn, current_user.id)
|
||||
adapter = build_cloud_resource_adapter(conn.provider, credentials)
|
||||
listing = await adapter.list_folder(
|
||||
connection_id,
|
||||
current_user.id,
|
||||
parent_ref=parent_ref,
|
||||
)
|
||||
await reconcile_cloud_listing(
|
||||
session,
|
||||
user_id=uid_str,
|
||||
connection_id=cid_str,
|
||||
parent_ref=parent_ref,
|
||||
listing=listing,
|
||||
)
|
||||
await update_folder_state(
|
||||
session,
|
||||
user_id=uid_str,
|
||||
connection_id=cid_str,
|
||||
parent_ref=parent_ref or "",
|
||||
refresh_state="fresh",
|
||||
)
|
||||
await session.commit()
|
||||
# Re-read items after reconciliation
|
||||
cached_items = await list_cloud_children(
|
||||
session,
|
||||
user_id=uid_str,
|
||||
connection_id=cid_str,
|
||||
parent_ref=parent_ref,
|
||||
)
|
||||
# Re-fetch folder state
|
||||
folder_state = await get_or_create_folder_state(
|
||||
session,
|
||||
user_id=uid_str,
|
||||
connection_id=cid_str,
|
||||
parent_ref=parent_ref or "",
|
||||
)
|
||||
except Exception as exc:
|
||||
# Retain cached rows (empty on first visit), set warning state
|
||||
await update_folder_state(
|
||||
session,
|
||||
user_id=uid_str,
|
||||
connection_id=cid_str,
|
||||
parent_ref=parent_ref or "",
|
||||
refresh_state="warning",
|
||||
error_code="provider_error",
|
||||
error_message="Provider temporarily unavailable. Retrying in background.",
|
||||
)
|
||||
await session.commit()
|
||||
folder_state = await get_or_create_folder_state(
|
||||
session,
|
||||
user_id=uid_str,
|
||||
connection_id=cid_str,
|
||||
parent_ref=parent_ref or "",
|
||||
)
|
||||
elif should_refresh and cached_items:
|
||||
# Has cached items — schedule background refresh via Celery
|
||||
try:
|
||||
from tasks.cloud_tasks import refresh_cloud_folder
|
||||
refresh_cloud_folder.delay(
|
||||
str(current_user.id),
|
||||
str(connection_id),
|
||||
parent_ref,
|
||||
)
|
||||
except Exception:
|
||||
pass # Celery unavailable — serve stale cache without failing browse
|
||||
|
||||
display_name = conn.display_name_override or conn.display_name
|
||||
|
||||
return CloudBrowseResponse(
|
||||
connection_id=str(connection_id),
|
||||
provider=conn.provider,
|
||||
display_name=display_name,
|
||||
parent_ref=parent_ref,
|
||||
items=[_item_out(item) for item in cached_items],
|
||||
capabilities=_capability_out(conn_caps),
|
||||
freshness=_freshness_out(folder_state),
|
||||
)
|
||||
@@ -0,0 +1,588 @@
|
||||
"""
|
||||
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 _upsert_cloud_connection(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
provider: str,
|
||||
credentials_enc: str,
|
||||
) -> CloudConnection:
|
||||
"""Insert or update a connection for (user_id, provider)."""
|
||||
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:
|
||||
"""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 _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.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.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 (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}
|
||||
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
Whitelisted Pydantic response schemas for the cloud API package.
|
||||
|
||||
All schemas are explicit allowlists — credentials_enc, tokens, and passwords
|
||||
are deliberately absent. T-12-03: credential exclusion by design.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
|
||||
# ── Capability / item schemas ─────────────────────────────────────────────────
|
||||
|
||||
class CloudCapabilityOut(BaseModel):
|
||||
"""Whitelisted capability descriptor. reason/message only when not supported."""
|
||||
|
||||
action: str
|
||||
state: str # "supported" | "unsupported" | "temporarily_unavailable"
|
||||
reason: Optional[str] = None
|
||||
message: Optional[str] = None
|
||||
|
||||
|
||||
class CloudItemOut(BaseModel):
|
||||
"""Normalized cloud item metadata. No credentials or byte content."""
|
||||
|
||||
id: str # DocuVault stable UUID
|
||||
provider_item_id: str
|
||||
name: str
|
||||
kind: str # "file" | "folder"
|
||||
parent_ref: Optional[str] = None
|
||||
content_type: Optional[str] = None
|
||||
size: Optional[int] = None
|
||||
modified_at: Optional[datetime] = None
|
||||
etag: Optional[str] = None
|
||||
capabilities: dict[str, CloudCapabilityOut] = {}
|
||||
|
||||
|
||||
# ── Freshness / folder state schemas ─────────────────────────────────────────
|
||||
|
||||
class FolderFreshnessOut(BaseModel):
|
||||
"""Freshness and error state for a browsed folder."""
|
||||
|
||||
refresh_state: str # "fresh" | "refreshing" | "warning"
|
||||
last_refreshed_at: Optional[datetime] = None
|
||||
error_code: Optional[str] = None
|
||||
error_message: Optional[str] = None
|
||||
|
||||
|
||||
# ── Browse response ───────────────────────────────────────────────────────────
|
||||
|
||||
class CloudBrowseResponse(BaseModel):
|
||||
"""Owner-scoped connection-ID browse response.
|
||||
|
||||
T-12-01: items are always scoped to the resolved connection which is owned
|
||||
by the requesting user. credentials_enc is never included.
|
||||
"""
|
||||
|
||||
connection_id: str
|
||||
provider: str
|
||||
display_name: str
|
||||
parent_ref: Optional[str]
|
||||
items: List[CloudItemOut]
|
||||
capabilities: dict[str, CloudCapabilityOut]
|
||||
freshness: FolderFreshnessOut
|
||||
|
||||
|
||||
# ── Connection schemas ────────────────────────────────────────────────────────
|
||||
|
||||
class ConnectionRenameRequest(BaseModel):
|
||||
"""Validated PATCH body for renaming a connection display name.
|
||||
|
||||
Only display_name is accepted — mass assignment prevention.
|
||||
"""
|
||||
|
||||
display_name: str
|
||||
|
||||
@field_validator("display_name")
|
||||
@classmethod
|
||||
def must_be_nonblank(cls, v: str) -> str:
|
||||
stripped = v.strip()
|
||||
if not stripped:
|
||||
raise ValueError("display_name must not be blank")
|
||||
return stripped
|
||||
Reference in New Issue
Block a user