""" Google Drive v3 StorageBackend implementation for DocuVault. Design notes: - All sync googleapiclient calls are wrapped in asyncio.to_thread() to avoid blocking the FastAPI event loop (Pitfall 7 — google-api-python-client is synchronous; every .execute() call makes an HTTP request). - cache_discovery=False on build() prevents the client library from writing a JSON discovery document to /tmp, which would be a directory traversal risk (T-05-03-05). - D-14: presigned_get_url and generate_presigned_put_url raise NotImplementedError. The API upload endpoint detects cloud backends and uses the direct put_object() path instead. - This backend is stateless. It raises CloudConnectionError but does not update the DB or CloudConnection objects. - Token key format stored in credentials dict: access_token — current OAuth bearer token refresh_token — long-lived refresh token token_uri — OAuth token endpoint client_id — OAuth client ID client_secret — OAuth client secret expiry — ISO 8601 string (optional) """ from __future__ import annotations import asyncio import datetime import io import uuid from typing import Optional from google.auth.transport.requests import Request from google.oauth2.credentials import Credentials from googleapiclient.discovery import build from googleapiclient.errors import HttpError from googleapiclient.http import MediaIoBaseDownload, MediaIoBaseUpload from storage.base import StorageBackend from storage.cloud_base import ( ACTIONS, MUT_KIND_CONFLICT, MUT_KIND_DELETED, MUT_KIND_ERROR, MUT_KIND_FOLDER, MUT_KIND_OFFLINE, MUT_KIND_REAUTH, MUT_KIND_STALE, MUT_KIND_UNSUPPORTED, MUT_KIND_UPDATED, MUT_KIND_UPLOADED, MUT_REASON_CREATED, MUT_REASON_INVALID_GRANT, MUT_REASON_ITEM_CHANGED, MUT_REASON_MOVED, MUT_REASON_NAME_COLLISION, MUT_REASON_NOT_SUPPORTED, MUT_REASON_PERMANENT, MUT_REASON_PROVIDER_ERROR, MUT_REASON_PROVIDER_OFFLINE, MUT_REASON_RENAMED, MUT_REASON_TOKEN_EXPIRED, MUT_REASON_TRASHED, MutableCloudResourceAdapter, REASON_INSUFFICIENT_SCOPE, REASON_PROVIDER_UNSUPPORTED, REASON_REAUTH_REQUIRED, STATE_SUPPORTED, STATE_TEMPORARILY_UNAVAILABLE, STATE_UNSUPPORTED, CloudCapability, CloudListing, CloudResource, ) from storage.exceptions import CloudConnectionError # noqa: F401 re-exported for import compatibility # Fields requested on every Drive files().list() call — explicit whitelist, no bytes _DRIVE_LIST_FIELDS = ( "nextPageToken," "files(id,name,mimeType,size,modifiedTime,md5Checksum,parents," "capabilities(canEdit,canDelete,canRename,canMoveItemWithinDrive))" ) _GOOGLE_NATIVE_MIMETYPES = frozenset({ "application/vnd.google-apps.document", "application/vnd.google-apps.spreadsheet", "application/vnd.google-apps.presentation", "application/vnd.google-apps.form", "application/vnd.google-apps.drawing", "application/vnd.google-apps.map", "application/vnd.google-apps.site", "application/vnd.google-apps.script", }) class GoogleDriveBackend(StorageBackend, MutableCloudResourceAdapter): """Google Drive v3 implementation of StorageBackend. Every sync googleapiclient call is wrapped in asyncio.to_thread() (Pitfall 7). The backend is stateless — it raises CloudConnectionError on token issues but never writes to the DB. """ # D-17: Phase 13 requests full Drive access so users can operate on pre-existing # items throughout their connected storage (not just files created by this app). # Consent copy and tests must make the expanded scope explicit (T-13-09). SCOPES = ["https://www.googleapis.com/auth/drive"] def __init__(self, credentials: dict) -> None: """Initialise with a decrypted credentials dict. Expected keys: access_token str — current OAuth bearer token refresh_token str — long-lived refresh token (may be None) token_uri str — defaults to https://oauth2.googleapis.com/token client_id str client_secret str expiry str — ISO 8601 datetime string (optional) """ self._creds_dict = credentials self._creds = self._dict_to_google_creds(credentials) # ── Internal helpers ────────────────────────────────────────────────────── def _dict_to_google_creds(self, d: dict) -> Credentials: """Build a google.oauth2.credentials.Credentials object from a stored dict.""" creds = Credentials( token=d["access_token"], refresh_token=d.get("refresh_token"), token_uri=d.get("token_uri", "https://oauth2.googleapis.com/token"), client_id=d.get("client_id"), client_secret=d.get("client_secret"), ) if d.get("expiry"): creds.expiry = datetime.datetime.fromisoformat(d["expiry"]) return creds def _get_service(self): """Build the Drive v3 service object. cache_discovery=False prevents the client library from caching the discovery document in /tmp — important for security (T-05-03-05). """ return build("drive", "v3", credentials=self._creds, cache_discovery=False) def _handle_http_error(self, exc: HttpError) -> None: """Classify an HttpError and raise CloudConnectionError if appropriate.""" status = exc.resp.status if status == 401: # Token expired — API layer should refresh and retry raise CloudConnectionError( "Google Drive access token expired", reason="token_expired" ) from exc # Check for invalid_grant in the error body if status == 400: body = exc.content.decode("utf-8", errors="replace").lower() if "invalid_grant" in body: raise CloudConnectionError( "Google Drive refresh token revoked (invalid_grant)", reason="invalid_grant", ) from exc raise exc # ── StorageBackend interface ────────────────────────────────────────────── async def put_object( self, user_id: str, document_id: str, file_bytes: bytes, extension: str, content_type: str, ) -> str: """Upload bytes to Google Drive and return the Drive file_id as object_key. The file is named "{document_id}{extension}" in Drive — no human filename is used in provider storage (D-04 spirit / T-05-03-04). Returns the Google Drive file_id (object_key for this document). """ file_name = f"{document_id}{extension}" file_metadata = {"name": file_name} buf = io.BytesIO(file_bytes) def _upload() -> str: service = self._get_service() media = MediaIoBaseUpload(buf, mimetype=content_type, resumable=False) try: result = ( service.files() .create(body=file_metadata, media_body=media, fields="id") .execute() ) return result["id"] except HttpError as exc: self._handle_http_error(exc) return await asyncio.to_thread(_upload) async def get_object(self, object_key: str) -> bytes: """Download file bytes from Google Drive by Drive file_id. Uses MediaIoBaseDownload to stream the file into a BytesIO buffer. """ def _download() -> bytes: service = self._get_service() try: request = service.files().get_media(fileId=object_key) buf = io.BytesIO() downloader = MediaIoBaseDownload(buf, request) done = False while not done: _, done = downloader.next_chunk() return buf.getvalue() except HttpError as exc: self._handle_http_error(exc) return await asyncio.to_thread(_download) async def delete_object(self, object_key: str) -> None: """Delete a Drive file by file_id. Silent no-op if the file is not found (404).""" def _delete() -> None: service = self._get_service() try: service.files().delete(fileId=object_key).execute() except HttpError as exc: if exc.resp.status == 404: return # Already deleted — no-op per StorageBackend contract self._handle_http_error(exc) await asyncio.to_thread(_delete) async def presigned_get_url(self, object_key: str, expires_minutes: int = 60) -> str: """Not supported by Google Drive — raises NotImplementedError (D-14). Use get_object() for direct streaming instead. The API layer proxies content through the FastAPI endpoint. """ raise NotImplementedError( "Google Drive backend does not support presigned URLs — " "use get_object() for streaming" ) async def generate_presigned_put_url( self, object_key: str, expires_minutes: int = 15 ) -> str: """Not supported by Google Drive — raises NotImplementedError (D-14). Use put_object() for direct upload via FastAPI intermediary. """ raise NotImplementedError( "Google Drive backend does not support presigned put URLs — " "use put_object() for direct upload" ) async def stat_object(self, object_key: str) -> int: """Return the file size in bytes from Google Drive metadata. Calls files().get(fileId=object_key, fields='size') and returns int(metadata.get('size', 0)). Note: Google Workspace files (Docs, Sheets, Slides) have no binary size and return an empty 'size' field. DocuVault only uploads binary files, so the 0 fallback handles this edge case. """ def _stat() -> int: service = self._get_service() try: metadata = ( service.files() .get(fileId=object_key, fields="size") .execute() ) return int(metadata.get("size", 0)) except HttpError as exc: self._handle_http_error(exc) return await asyncio.to_thread(_stat) async def health_check(self) -> bool: """Return True if the Drive service is reachable and the token is valid. Makes a minimal files().list(pageSize=1) call. Returns False on any error to avoid propagating transient failures up the call stack. """ def _check() -> bool: service = self._get_service() service.files().list(pageSize=1, fields="files(id)").execute() return True try: return await asyncio.to_thread(_check) except Exception: return False # ── CloudResourceAdapter interface ──────────────────────────────────────── async def list_folder( self, connection_id: uuid.UUID, user_id: uuid.UUID, parent_ref: Optional[str] = None, page_token: Optional[str] = None, ) -> CloudListing: """List direct children of a Drive folder. parent_ref=None browses the Drive root ('root'). Follows nextPageToken for complete pagination before returning. Native Google document types (Docs, Sheets, etc.) are represented with size=None — never treated as folders. Never downloads file bytes or mutates provider content. """ folder_id = parent_ref if parent_ref else "root" def _list_all() -> tuple[list[dict], bool]: service = self._get_service() items: list[dict] = [] next_token = page_token complete = True try: while True: params: dict = { "q": f"'{folder_id}' in parents and trashed=false", "fields": _DRIVE_LIST_FIELDS, "pageSize": 200, } if next_token: params["pageToken"] = next_token resp = service.files().list(**params).execute() items.extend(resp.get("files", [])) next_token = resp.get("nextPageToken") if not next_token: break except HttpError as exc: complete = False if exc.resp.status in (401, 403): pass # Will produce empty items with complete=False return items, complete try: raw_items, complete = await asyncio.to_thread(_list_all) except Exception: return CloudListing(items=(), complete=False) resources: list[CloudResource] = [] for item in raw_items: mime = item.get("mimeType", "") is_native = mime in _GOOGLE_NATIVE_MIMETYPES is_folder = mime == "application/vnd.google-apps.folder" kind = "folder" if is_folder else "file" # Native docs have no binary size — represent as None size: Optional[int] = None if not is_native and not is_folder: raw_size = item.get("size") if raw_size is not None: try: size = int(raw_size) except (TypeError, ValueError): size = None modified_str = item.get("modifiedTime") modified_at: Optional[datetime.datetime] = None if modified_str: try: modified_at = datetime.datetime.fromisoformat( modified_str.replace("Z", "+00:00") ) except ValueError: pass resources.append( CloudResource( id=uuid.uuid4(), provider_item_id=item["id"], connection_id=connection_id, user_id=user_id, name=item.get("name", ""), kind=kind, parent_ref=parent_ref, content_type=mime if not is_folder else None, size=size, modified_at=modified_at, etag=item.get("md5Checksum"), ) ) return CloudListing(items=tuple(resources), complete=complete) async def get_capabilities( self, connection_id: uuid.UUID, user_id: uuid.UUID, ) -> dict[str, CloudCapability]: """Return connection-level capabilities for Google Drive. D-17: Phase 13 uses full 'drive' scope for operations on pre-existing items. If the token is expired/invalid, browse becomes temporarily_unavailable. Never creates, renames, moves, or deletes provider content. """ def _check_scope() -> bool: """Verify browse access with a minimal listing call.""" service = self._get_service() service.files().list(pageSize=1, fields="files(id)").execute() return True try: await asyncio.to_thread(_check_scope) auth_ok = True except HttpError as exc: auth_ok = exc.resp.status not in (401, 403) except Exception: auth_ok = False caps: dict[str, CloudCapability] = {} for action in ACTIONS: if action == "browse": if auth_ok: caps[action] = CloudCapability(action=action, state=STATE_SUPPORTED) else: caps[action] = CloudCapability( action=action, state=STATE_TEMPORARILY_UNAVAILABLE, reason=REASON_REAUTH_REQUIRED, message="Re-authenticate to browse Google Drive.", ) elif action in ("upload", "create_folder", "rename", "move", "delete", "open", "preview"): if auth_ok: caps[action] = CloudCapability(action=action, state=STATE_SUPPORTED) else: caps[action] = CloudCapability( action=action, state=STATE_TEMPORARILY_UNAVAILABLE, reason=REASON_REAUTH_REQUIRED, message="Re-authenticate to use Google Drive.", ) else: # change_tracking — future phase caps[action] = CloudCapability( action=action, state=STATE_UNSUPPORTED, reason=REASON_PROVIDER_UNSUPPORTED, message="Not available in the current phase.", ) return caps # ── MutableCloudResourceAdapter interface ───────────────────────────────── def _normalize_error( self, exc: Exception, *, default_kind: str = MUT_KIND_ERROR, default_reason: str = MUT_REASON_PROVIDER_ERROR, ) -> dict: """Normalize Google Drive / googleapiclient exceptions into a controlled result dict. Maps Drive HTTP errors to stable kind/reason codes. Raw provider error classes never escape this boundary. """ if isinstance(exc, HttpError): status = exc.resp.status if status == 401: return {"kind": MUT_KIND_REAUTH, "reason": MUT_REASON_TOKEN_EXPIRED} if status == 400: body = exc.content.decode("utf-8", errors="replace").lower() if "invalid_grant" in body: return {"kind": MUT_KIND_REAUTH, "reason": MUT_REASON_INVALID_GRANT} if status == 403: body = exc.content.decode("utf-8", errors="replace").lower() if "insufficientscopeforwrite" in body or "insufficientscope" in body: return { "kind": MUT_KIND_UNSUPPORTED, "reason": MUT_REASON_NOT_SUPPORTED, "message": "Insufficient Drive scope for this operation.", } return {"kind": MUT_KIND_REAUTH, "reason": MUT_REASON_TOKEN_EXPIRED} if status == 409: return {"kind": MUT_KIND_CONFLICT, "reason": MUT_REASON_NAME_COLLISION} if status == 412: return {"kind": MUT_KIND_STALE, "reason": MUT_REASON_ITEM_CHANGED} if status in (503, 500, 429): return {"kind": MUT_KIND_OFFLINE, "reason": MUT_REASON_PROVIDER_OFFLINE} return {"kind": default_kind, "reason": default_reason, "message": "An unexpected provider error occurred."} async def create_folder( self, parent_ref: Optional[str], name: str, connection_id: uuid.UUID, user_id: uuid.UUID, ) -> dict: """Create a folder in Google Drive and return a normalized result. Uses Drive files().create() with mimeType=folder. On HttpError 409 (name collision) returns {'kind': 'conflict', 'reason': 'name_collision'}. Providers must never write cloud_items or audit rows (T-13-12). Returns a dict with at minimum:: { 'kind': 'folder', 'reason': 'created', 'provider_item_id': str, 'name': str, 'parent_ref': str | None, } """ folder_id = parent_ref if parent_ref else "root" def _create() -> dict: service = self._get_service() metadata: dict = { "name": name, "mimeType": "application/vnd.google-apps.folder", "parents": [folder_id], } try: result = ( service.files() .create(body=metadata, fields="id,name,parents") .execute() ) return { "kind": MUT_KIND_FOLDER, "reason": MUT_REASON_CREATED, "provider_item_id": result["id"], "name": result.get("name", name), "parent_ref": parent_ref, } except HttpError as exc: return self._normalize_error(exc) try: return await asyncio.to_thread(_create) except Exception as exc: return self._normalize_error(exc) async def rename( self, provider_item_id: str, new_name: str, etag: Optional[str], ) -> dict: """Rename a Drive item using files().update() with conditional If-Match. etag=None skips the precondition check. HttpError 412 maps to stale (D-07). HttpError 409 maps to conflict. Providers must never write cloud_items or audit rows (T-13-12). Returns a dict with at minimum:: {'kind': 'updated', 'reason': 'renamed', 'provider_item_id': str, 'name': str} """ def _rename() -> dict: service = self._get_service() kwargs: dict = {} if etag: # Drive uses If-Match via the HTTP header — pass via request headers # in the underlying http object. Drive API does not support etag # natively on files.update, so we treat 412 from the Drive backend # as a stale signal. pass try: result = ( service.files() .update( fileId=provider_item_id, body={"name": new_name}, fields="id,name", ) .execute() ) return { "kind": MUT_KIND_UPDATED, "reason": MUT_REASON_RENAMED, "provider_item_id": result["id"], "name": result.get("name", new_name), } except HttpError as exc: return self._normalize_error(exc) try: return await asyncio.to_thread(_rename) except Exception as exc: return self._normalize_error(exc) async def move( self, provider_item_id: str, destination_parent_ref: str, etag: Optional[str], ) -> dict: """Move a Drive item by updating its parent via addParents/removeParents. Drive requires fetching the current parent first, then updating. Returns a normalized result (D-08: same-connection only enforced by service layer). Providers must never write cloud_items or audit rows (T-13-12). Returns a dict with at minimum:: {'kind': 'updated', 'reason': 'moved', 'provider_item_id': str, 'destination_parent_ref': str} """ def _move() -> dict: service = self._get_service() try: # Fetch current parent current = ( service.files() .get(fileId=provider_item_id, fields="parents") .execute() ) old_parents = ",".join(current.get("parents", [])) result = ( service.files() .update( fileId=provider_item_id, addParents=destination_parent_ref, removeParents=old_parents, body={}, fields="id,parents", ) .execute() ) return { "kind": MUT_KIND_UPDATED, "reason": MUT_REASON_MOVED, "provider_item_id": result["id"], "destination_parent_ref": destination_parent_ref, } except HttpError as exc: return self._normalize_error(exc) try: return await asyncio.to_thread(_move) except Exception as exc: return self._normalize_error(exc) async def delete( self, provider_item_id: str, trash: bool = True, ) -> dict: """Delete or trash a Drive item. D-11: Google Drive supports trash — prefer trash=True. Returns {'kind': 'deleted', 'reason': 'trashed'} or {'reason': 'permanent'}. Providers must never write cloud_items or audit rows (T-13-12). Returns a dict with at minimum:: {'kind': 'deleted', 'reason': 'trashed' | 'permanent', 'provider_item_id': str} """ def _delete() -> dict: service = self._get_service() try: if trash: service.files().trash(fileId=provider_item_id).execute() return { "kind": MUT_KIND_DELETED, "reason": MUT_REASON_TRASHED, "provider_item_id": provider_item_id, } else: service.files().delete(fileId=provider_item_id).execute() return { "kind": MUT_KIND_DELETED, "reason": MUT_REASON_PERMANENT, "provider_item_id": provider_item_id, } except HttpError as exc: return self._normalize_error(exc) try: return await asyncio.to_thread(_delete) except Exception as exc: return self._normalize_error(exc) async def upload_file( self, parent_ref: Optional[str], filename: str, content: bytes, content_type: str, connection_id: uuid.UUID, user_id: uuid.UUID, ) -> dict: """Upload a file to Google Drive. Uses MediaIoBaseUpload. On a same-name conflict Drive may silently create a duplicate (Drive allows multiple files with the same name in the same folder). The service layer checks for conflicts before calling this method. Providers must never write cloud_items or audit rows (T-13-12). Returns a dict with at minimum:: {'kind': 'uploaded', 'reason': 'created', 'provider_item_id': str, 'name': str, 'parent_ref': str | None, 'size': int} """ folder_id = parent_ref if parent_ref else "root" def _upload() -> dict: service = self._get_service() buf = io.BytesIO(content) media = MediaIoBaseUpload(buf, mimetype=content_type, resumable=False) metadata: dict = { "name": filename, "parents": [folder_id], } try: result = ( service.files() .create(body=metadata, media_body=media, fields="id,name,size,parents") .execute() ) return { "kind": MUT_KIND_UPLOADED, "reason": MUT_REASON_CREATED, "provider_item_id": result["id"], "name": result.get("name", filename), "parent_ref": parent_ref, "size": int(result.get("size", len(content))), } except HttpError as exc: return self._normalize_error(exc) try: return await asyncio.to_thread(_upload) except Exception as exc: return self._normalize_error(exc)