From 2e12cd080cb3c6458e596e56da0b48c71efd1448 Mon Sep 17 00:00:00 2001 From: PlushZ Date: Thu, 2 Apr 2026 18:25:08 +1100 Subject: [PATCH] Add OneDrive file source --- client/src/api/fileSources.ts | 4 + client/src/api/schema/schema.ts | 2 + .../FileSources/Instances/CreateInstance.vue | 2 +- lib/galaxy/files/sources/onedrive.py | 220 ++++++++++++++++++ .../examples/production_onedrive.yml | 15 ++ lib/galaxy/files/templates/models.py | 27 +++ 6 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 lib/galaxy/files/sources/onedrive.py create mode 100644 lib/galaxy/files/templates/examples/production_onedrive.yml diff --git a/client/src/api/fileSources.ts b/client/src/api/fileSources.ts index cdb9e2e1665..c420b9ed3d6 100644 --- a/client/src/api/fileSources.ts +++ b/client/src/api/fileSources.ts @@ -32,6 +32,10 @@ export const templateTypes: FileSourceTypesDetail = { icon: faGoogleDrive, message: "This is a repository plugin that connects with the commercial Google Drive service.", }, + onedrive: { + icon: faCloud, + message: "This is a repository plugin that connects with Microsoft OneDrive through Microsoft Graph.", + }, onedata: { icon: faNetworkWired, message: "This is a repository plugin based on the Onedata service.", diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts index 42dc2895eb1..10e3bb6d727 100644 --- a/client/src/api/schema/schema.ts +++ b/client/src/api/schema/schema.ts @@ -12873,6 +12873,7 @@ export interface components { | "webdav" | "dropbox" | "googledrive" + | "onedrive" | "elabftw" | "inveniordm" | "zenodo" @@ -24527,6 +24528,7 @@ export interface components { | "webdav" | "dropbox" | "googledrive" + | "onedrive" | "elabftw" | "inveniordm" | "zenodo" diff --git a/client/src/components/FileSources/Instances/CreateInstance.vue b/client/src/components/FileSources/Instances/CreateInstance.vue index 7c48e421d6f..68519346f94 100644 --- a/client/src/components/FileSources/Instances/CreateInstance.vue +++ b/client/src/components/FileSources/Instances/CreateInstance.vue @@ -18,7 +18,7 @@ interface Props { uuid?: string; } -const OAUTH2_TYPES = ["dropbox", "googledrive"]; +const OAUTH2_TYPES = ["dropbox", "googledrive", "onedrive"]; const fileSourceTemplatesStore = useFileSourceTemplatesStore(); fileSourceTemplatesStore.fetchTemplates(); diff --git a/lib/galaxy/files/sources/onedrive.py b/lib/galaxy/files/sources/onedrive.py new file mode 100644 index 00000000000..05076548504 --- /dev/null +++ b/lib/galaxy/files/sources/onedrive.py @@ -0,0 +1,220 @@ +from __future__ import annotations + +from typing import ( + Annotated, + Optional, + Union, +) +from urllib.parse import quote + +import requests +from pydantic import ( + AliasChoices, + Field, +) + +from galaxy.exceptions import ( + AuthenticationRequired, + MessageException, + RequestParameterInvalidException, +) +from galaxy.files.models import ( + AnyRemoteEntry, + BaseFileSourceConfiguration, + BaseFileSourceTemplateConfiguration, + Entry, + EntryData, + FilesSourceRuntimeContext, + RemoteDirectory, + RemoteFile, +) +from galaxy.util.config_templates import TemplateExpansion +from . import BaseFilesSource + +AccessTokenField = Field( + ..., + title="Access Token", + description="The OAuth2 access token for Microsoft Graph.", + validation_alias=AliasChoices("oauth2_access_token", "accessToken", "access_token"), +) + + +class OneDriveFileSourceTemplateConfiguration(BaseFileSourceTemplateConfiguration): + access_token: Annotated[Union[str, TemplateExpansion], AccessTokenField] + drive_api_base: Union[str, TemplateExpansion] = "https://graph.microsoft.com/v1.0/me/drive" + + +class OneDriveFilesSourceConfiguration(BaseFileSourceConfiguration): + access_token: Annotated[str, AccessTokenField] + drive_api_base: str = "https://graph.microsoft.com/v1.0/me/drive" + + +class OneDriveFilesSource( + BaseFilesSource[OneDriveFileSourceTemplateConfiguration, OneDriveFilesSourceConfiguration] +): + plugin_type = "onedrive" + + template_config_class = OneDriveFileSourceTemplateConfiguration + resolved_config_class = OneDriveFilesSourceConfiguration + + def _headers(self, config: OneDriveFilesSourceConfiguration) -> dict[str, str]: + return { + "Authorization": f"Bearer {config.access_token}", + } + + def _json_headers(self, config: OneDriveFilesSourceConfiguration) -> dict[str, str]: + headers = self._headers(config) + headers["Content-Type"] = "application/json" + return headers + + def _encoded_path(self, path: str) -> str: + normalized = path.strip("/") + if not normalized: + return "" + return "/".join(quote(component, safe="") for component in normalized.split("/")) + + def _item_url(self, config: OneDriveFilesSourceConfiguration, path: str) -> str: + api_base = config.drive_api_base.rstrip("/") + encoded_path = self._encoded_path(path) + if encoded_path: + return f"{api_base}/special/approot:/{encoded_path}" + return f"{api_base}/special/approot" + + def _children_url(self, config: OneDriveFilesSourceConfiguration, path: str) -> str: + return f"{self._item_url(config, path)}/children" + + def _content_url(self, config: OneDriveFilesSourceConfiguration, path: str) -> str: + return f"{self._item_url(config, path)}:/content" if path.strip("/") else f"{self._item_url(config, path)}/content" + + def _request( + self, + method: str, + url: str, + context: FilesSourceRuntimeContext[OneDriveFilesSourceConfiguration], + **kwargs, + ) -> requests.Response: + try: + response = requests.request(method, url, headers=self._headers(context.config), timeout=30, **kwargs) + except requests.RequestException as exc: + raise MessageException(f"Error connecting to OneDrive. Reason: {exc}") from exc + + if response.status_code in {401, 403}: + raise AuthenticationRequired( + "Permission denied while accessing OneDrive. Check the Microsoft app registration, granted scopes, and the stored user authorization." + ) + if response.status_code == 404: + raise RequestParameterInvalidException(f"Path not found in OneDrive: {url}") + if not response.ok: + try: + payload = response.json() + message = payload.get("error", {}).get("message", response.text) + except Exception: + message = response.text + raise MessageException(f"Error communicating with OneDrive. Reason: {message}") + return response + + def _entry_from_item( + self, + item: dict, + parent_path: str, + ) -> AnyRemoteEntry: + relative_parent = parent_path.rstrip("/") + relative_path = f"{relative_parent}/{item['name']}".replace("//", "/") + if not relative_path.startswith("/"): + relative_path = f"/{relative_path}" + uri = self.uri_from_path(relative_path) + if "folder" in item: + return RemoteDirectory( + name=item["name"], + uri=uri, + path=relative_path, + ) + return RemoteFile( + name=item["name"], + uri=uri, + path=relative_path, + size=item.get("size", 0), + ctime=item.get("lastModifiedDateTime"), + ) + + def _list( + self, + context: FilesSourceRuntimeContext[OneDriveFilesSourceConfiguration], + path: str = "/", + recursive: bool = False, + write_intent: bool = False, + limit: Optional[int] = None, + offset: Optional[int] = None, + query: Optional[str] = None, + sort_by: Optional[str] = None, + ) -> tuple[list[AnyRemoteEntry], int]: + response = self._request("GET", self._children_url(context.config, path), context) + items = response.json().get("value", []) + entries = [self._entry_from_item(item, path) for item in items] + return entries, len(entries) + + def _realize_to( + self, source_path: str, native_path: str, context: FilesSourceRuntimeContext[OneDriveFilesSourceConfiguration] + ): + response = self._request("GET", self._content_url(context.config, source_path), context, stream=True) + with open(native_path, "wb") as out: + for chunk in response.iter_content(chunk_size=1024 * 1024): + if chunk: + out.write(chunk) + + def _write_from( + self, target_path: str, native_path: str, context: FilesSourceRuntimeContext[OneDriveFilesSourceConfiguration] + ) -> str: + upload_url = self._content_url(context.config, target_path) + with open(native_path, "rb") as handle: + response = requests.put( + upload_url, + headers={"Authorization": f"Bearer {context.config.access_token}", "Content-Type": "application/octet-stream"}, + data=handle, + timeout=300, + ) + if response.status_code in {401, 403}: + raise AuthenticationRequired( + "Permission denied while writing to OneDrive. Check the Microsoft app scopes and stored user authorization." + ) + if not response.ok: + try: + payload = response.json() + message = payload.get("error", {}).get("message", response.text) + except Exception: + message = response.text + raise MessageException(f"Error uploading to OneDrive. Reason: {message}") + return self.uri_from_path(target_path) + + def _create_entry( + self, entry_data: EntryData, context: FilesSourceRuntimeContext[OneDriveFilesSourceConfiguration] + ) -> Entry: + parent_path = getattr(entry_data, "path", "/") + payload = { + "name": entry_data.name, + "folder": {}, + "@microsoft.graph.conflictBehavior": "fail", + } + response = requests.post( + self._children_url(context.config, parent_path), + json=payload, + headers=self._json_headers(context.config), + timeout=30, + ) + if response.status_code in {401, 403}: + raise AuthenticationRequired( + "Permission denied while creating a OneDrive folder. Check the Microsoft app scopes and stored user authorization." + ) + if not response.ok: + try: + body = response.json() + message = body.get("error", {}).get("message", response.text) + except Exception: + message = response.text + raise MessageException(f"Error creating OneDrive folder. Reason: {message}") + item = response.json() + path = self._entry_from_item(item, parent_path).path + return Entry(name=item["name"], uri=self.uri_from_path(path), external_link=item.get("webUrl")) + + +__all__ = ("OneDriveFilesSource",) diff --git a/lib/galaxy/files/templates/examples/production_onedrive.yml b/lib/galaxy/files/templates/examples/production_onedrive.yml new file mode 100644 index 00000000000..24d58fde67c --- /dev/null +++ b/lib/galaxy/files/templates/examples/production_onedrive.yml @@ -0,0 +1,15 @@ +- id: onedrive + name: OneDrive + description: Connect to your Microsoft OneDrive app folder to download and upload files. + configuration: + type: onedrive + oauth2_client_id: "{{ environment.oauth2_client_id }}" + oauth2_client_secret: "{{ environment.oauth2_client_secret }}" + writable: true + environment: + oauth2_client_id: + type: variable + variable: GALAXY_ONEDRIVE_CLIENT_ID + oauth2_client_secret: + type: variable + variable: GALAXY_ONEDRIVE_CLIENT_SECRET diff --git a/lib/galaxy/files/templates/models.py b/lib/galaxy/files/templates/models.py index ed9adc73f2d..cae6152c95c 100644 --- a/lib/galaxy/files/templates/models.py +++ b/lib/galaxy/files/templates/models.py @@ -41,6 +41,7 @@ FileSourceTemplateType = Literal[ "webdav", "dropbox", "googledrive", + "onedrive", "elabftw", "inveniordm", "zenodo", @@ -113,6 +114,23 @@ class GoogleDriveFileSourceConfiguration(OAuth2FileSourceConfiguration, StrictMo oauth2_access_token: str +class OneDriveFileSourceTemplateConfiguration(OAuth2TemplateConfiguration, StrictModel): + type: Literal["onedrive"] + writable: Union[bool, TemplateExpansion] = False + oauth2_client_id: Union[str, TemplateExpansion] + oauth2_client_secret: Union[str, TemplateExpansion] + # Microsoft Graph app-folder scope keeps access limited to Apps/. + oauth2_scope: Optional[Union[str, TemplateExpansion]] = None + template_start: Optional[str] = None + template_end: Optional[str] = None + + +class OneDriveFileSourceConfiguration(OAuth2FileSourceConfiguration, StrictModel): + type: Literal["onedrive"] + writable: bool = False + oauth2_access_token: str + + class S3FSFileSourceTemplateConfiguration(StrictModel): type: Literal["s3fs"] endpoint_url: Optional[Union[str, TemplateExpansion]] = None @@ -362,6 +380,7 @@ FileSourceTemplateConfiguration = Annotated[ WebdavFileSourceTemplateConfiguration, DropboxFileSourceTemplateConfiguration, GoogleDriveFileSourceTemplateConfiguration, + OneDriveFileSourceTemplateConfiguration, eLabFTWFileSourceTemplateConfiguration, InvenioFileSourceTemplateConfiguration, ZenodoFileSourceTemplateConfiguration, @@ -384,6 +403,7 @@ FileSourceConfiguration = Annotated[ WebdavFileSourceConfiguration, DropboxFileSourceConfiguration, GoogleDriveFileSourceConfiguration, + OneDriveFileSourceConfiguration, eLabFTWFileSourceConfiguration, InvenioFileSourceConfiguration, ZenodoFileSourceConfiguration, @@ -464,6 +484,7 @@ TypesToConfigurationClasses: dict[FileSourceTemplateType, type[FileSourceConfigu "webdav": WebdavFileSourceConfiguration, "dropbox": DropboxFileSourceConfiguration, "googledrive": GoogleDriveFileSourceConfiguration, + "onedrive": OneDriveFileSourceConfiguration, "elabftw": eLabFTWFileSourceConfiguration, "inveniordm": InvenioFileSourceConfiguration, "zenodo": ZenodoFileSourceConfiguration, @@ -486,6 +507,12 @@ OAUTH2_CONFIGURED_SOURCES: ConfiguredOAuth2Sources = { token_url="https://oauth2.googleapis.com/token", scope="https://www.googleapis.com/auth/drive.file", ), + "onedrive": OAuth2Configuration( + authorize_url="https://login.microsoftonline.com/common/oauth2/v2.0/authorize", + token_url="https://login.microsoftonline.com/common/oauth2/v2.0/token", + authorize_params={}, + scope="offline_access Files.ReadWrite.AppFolder", + ), }