From 933fb940f9c1a0704f61734a2ebeb32ab794dfe5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 18:56:31 +0000 Subject: [PATCH 1/3] Initial plan From d1f9819f4e12320bb901b76366fa8d4a8cd23a66 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 19:17:12 +0000 Subject: [PATCH 2/3] feat(integrations): add Dropbox connection test and global-credential sharing Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- app/api/dropbox.py | 75 ++++++++++++++++ app/api/integrations.py | 53 +++++++++++ app/auth.py | 13 ++- app/config.py | 20 +++++ app/utils/settings_service.py | 25 ++++++ app/views/dropbox.py | 10 ++- docs/API.md | 2 +- frontend/templates/dropbox.html | 61 ++++++++++++- frontend/templates/dropbox_callback.html | 90 +++++++++++++++++-- .../templates/integrations_dashboard.html | 2 +- tests/test_api_integrations.py | 83 ++++++++++++++++- 11 files changed, 420 insertions(+), 14 deletions(-) diff --git a/app/api/dropbox.py b/app/api/dropbox.py index da52c758..2707e1c0 100644 --- a/app/api/dropbox.py +++ b/app/api/dropbox.py @@ -23,6 +23,81 @@ logger = logging.getLogger(__name__) router = APIRouter() +@router.get("/dropbox/global-authorize-url") +@require_login +async def dropbox_global_authorize_url(request: Request): + """Return the Dropbox OAuth authorization URL using the global app credentials. + + This endpoint is used when ``DROPBOX_ALLOW_GLOBAL_CREDENTIALS_FOR_INTEGRATIONS`` + is enabled so that users can authorize their personal Dropbox integration without + needing to supply their own app key/secret. Only the public ``app_key`` is + embedded in the URL; the ``app_secret`` is never sent to the browser. + """ + if not settings.dropbox_allow_global_credentials_for_integrations: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Global credentials for integrations are not enabled", + ) + if not settings.dropbox_app_key or not settings.dropbox_app_secret: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Global Dropbox credentials are not configured", + ) + redirect_uri = str(request.base_url).rstrip("/") + "/dropbox-callback" + authorize_url = ( + "https://www.dropbox.com/oauth2/authorize" + f"?client_id={settings.dropbox_app_key}" + "&response_type=code" + "&token_access_type=offline" + f"&redirect_uri={redirect_uri}" + ) + return {"authorize_url": authorize_url} + + +@router.post("/dropbox/exchange-token-global") +@require_login +async def exchange_dropbox_token_global( + request: Request, + code: Annotated[str, Form(...)], + redirect_uri: Annotated[str, Form(...)], +): + """Exchange an authorization code using the global Dropbox app credentials. + + Used when ``DROPBOX_ALLOW_GLOBAL_CREDENTIALS_FOR_INTEGRATIONS`` is enabled so + that the ``app_secret`` is never exposed to the browser. Only the OAuth code + and redirect URI need to be supplied by the client. + """ + if not settings.dropbox_allow_global_credentials_for_integrations: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Global credentials for integrations are not enabled", + ) + if not settings.dropbox_app_key or not settings.dropbox_app_secret: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Global Dropbox credentials are not configured", + ) + + token_url = "https://api.dropboxapi.com/oauth2/token" + payload = { + "client_id": settings.dropbox_app_key, + "client_secret": settings.dropbox_app_secret, + "code": code, + "redirect_uri": redirect_uri, + "grant_type": "authorization_code", + } + + token_data = exchange_oauth_token(provider_name="Dropbox", token_url=token_url, payload=payload) + + return { + "refresh_token": token_data["refresh_token"], + "access_token": token_data["access_token"], + "expires_in": token_data.get("expires_in", 14400), + # Return the public app_key so the callback can store it in the integration + "app_key": settings.dropbox_app_key, + } + + @router.post("/dropbox/exchange-token") @require_login async def exchange_dropbox_token( diff --git a/app/api/integrations.py b/app/api/integrations.py index 8d2d9209..9261e1a6 100644 --- a/app/api/integrations.py +++ b/app/api/integrations.py @@ -32,6 +32,20 @@ from app.utils.encryption import decrypt_value, encrypt_value from app.utils.subscription import get_tier, get_user_tier_id from app.utils.user_scope import get_current_owner_id +# Optional Dropbox SDK — imported at module level so tests can patch it cleanly. +try: + import dropbox as dbx_lib + from dropbox.exceptions import AuthError as _DropboxAuthError + from dropbox.exceptions import BadInputError as _DropboxBadInputError +except ImportError: # pragma: no cover + dbx_lib = None # type: ignore[assignment] + + class _DropboxAuthError(Exception): # type: ignore[no-redef] + """Stub — only used when the dropbox package is missing.""" + + class _DropboxBadInputError(Exception): # type: ignore[no-redef] + """Stub — only used when the dropbox package is missing.""" + logger = logging.getLogger(__name__) router = APIRouter(prefix="/integrations", tags=["integrations"]) @@ -550,6 +564,44 @@ def _test_s3_connection(config: dict[str, Any] | None, credentials: dict[str, An return {"success": False, "message": "S3 connection failed"} +def _test_dropbox_connection(config: dict[str, Any] | None, credentials: dict[str, Any] | None) -> dict[str, Any]: + """Test a Dropbox connection by verifying OAuth credentials via the Dropbox API.""" + if dbx_lib is None: + return {"success": False, "message": "dropbox package is not installed"} # pragma: no cover + + creds = credentials or {} + app_key = creds.get("app_key", "") + app_secret = creds.get("app_secret", "") + refresh_token = creds.get("refresh_token", "") + + if not refresh_token: + return {"success": False, "message": "Missing required credential: refresh_token"} + if not app_key or not app_secret: + return {"success": False, "message": "Missing required credentials: app_key and app_secret"} + + try: + dbx = dbx_lib.Dropbox( + app_key=app_key, + app_secret=app_secret, + oauth2_refresh_token=refresh_token, + ) + account = dbx.users_get_current_account() + display_name = getattr(account, "name", None) + name_str = "" + if display_name: + name_str = f" ({getattr(display_name, 'display_name', '') or ''})" + return {"success": True, "message": f"Dropbox connection successful{name_str}"} + except _DropboxAuthError as exc: + logger.warning("Dropbox auth error: %s", exc) + return {"success": False, "message": "Dropbox authentication failed — check app_key, app_secret, and refresh_token"} + except _DropboxBadInputError as exc: + logger.warning("Dropbox bad input error: %s", exc) + return {"success": False, "message": "Dropbox connection failed — invalid credentials format"} + except Exception as exc: # noqa: BLE001 + logger.warning("Dropbox connection error: %s", exc) + return {"success": False, "message": "Dropbox connection failed — check credentials and network connectivity"} + + def _test_webdav_connection(config: dict[str, Any] | None, credentials: dict[str, Any] | None) -> dict[str, Any]: """Test a WebDAV/Nextcloud connection by issuing an HTTP PROPFIND.""" import urllib.request @@ -596,6 +648,7 @@ def _test_webdav_connection(config: dict[str, Any] | None, credentials: dict[str _CONNECTION_TESTERS: dict[str, Any] = { + IntegrationType.DROPBOX: _test_dropbox_connection, IntegrationType.IMAP: _test_imap_connection, IntegrationType.S3: _test_s3_connection, IntegrationType.WEBDAV: _test_webdav_connection, diff --git a/app/auth.py b/app/auth.py index 072e173b..5b92b156 100644 --- a/app/auth.py +++ b/app/auth.py @@ -103,11 +103,18 @@ if AUTH_ENABLED and settings.social_auth_apple_enabled: logger.warning("SOCIAL_AUTH_APPLE_ENABLED=true but client ID/team ID not configured") if AUTH_ENABLED and settings.social_auth_dropbox_enabled: - if settings.social_auth_dropbox_client_id and settings.social_auth_dropbox_client_secret: + # Determine which credentials to use for Dropbox social login + _dropbox_client_id = settings.social_auth_dropbox_client_id + _dropbox_client_secret = settings.social_auth_dropbox_client_secret + if settings.social_auth_dropbox_use_global_credentials and not _dropbox_client_id: + _dropbox_client_id = settings.dropbox_app_key + _dropbox_client_secret = settings.dropbox_app_secret + + if _dropbox_client_id and _dropbox_client_secret: oauth.register( name="dropbox", - client_id=settings.social_auth_dropbox_client_id, - client_secret=settings.social_auth_dropbox_client_secret, + client_id=_dropbox_client_id, + client_secret=_dropbox_client_secret, authorize_url="https://www.dropbox.com/oauth2/authorize", access_token_url="https://api.dropboxapi.com/oauth2/token", userinfo_endpoint="https://api.dropboxapi.com/2/users/get_current_account", diff --git a/app/config.py b/app/config.py index 70c50ae5..64701642 100644 --- a/app/config.py +++ b/app/config.py @@ -120,6 +120,16 @@ class Settings(BaseSettings): dropbox_app_secret: Optional[str] = None dropbox_folder: Optional[str] = None dropbox_refresh_token: Optional[str] = None + dropbox_allow_global_credentials_for_integrations: bool = Field( + default=False, + description=( + "When True, users may authorize their personal Dropbox integrations using the global " + "DROPBOX_APP_KEY / DROPBOX_APP_SECRET credentials configured by the admin, without " + "needing to create their own Dropbox app. The Dropbox OAuth flow is initiated " + "server-side so the app secret is never exposed to the browser. " + "Default: False (each user must supply their own app credentials)." + ), + ) # Making Nextcloud optional nextcloud_enabled: bool = Field( @@ -317,6 +327,16 @@ class Settings(BaseSettings): social_auth_dropbox_enabled: bool = False social_auth_dropbox_client_id: Optional[str] = None social_auth_dropbox_client_secret: Optional[str] = None + social_auth_dropbox_use_global_credentials: bool = Field( + default=False, + description=( + "When True, Dropbox social login uses the global DROPBOX_APP_KEY / DROPBOX_APP_SECRET " + "credentials (the storage integration credentials) instead of requiring separate " + "SOCIAL_AUTH_DROPBOX_CLIENT_ID / SOCIAL_AUTH_DROPBOX_CLIENT_SECRET values. " + "Requires SOCIAL_AUTH_DROPBOX_ENABLED=True and the global Dropbox app credentials to be set. " + "Default: False." + ), + ) # Local user signup allow_local_signup: bool = Field( diff --git a/app/utils/settings_service.py b/app/utils/settings_service.py index 41fe3874..58be76d9 100644 --- a/app/utils/settings_service.py +++ b/app/utils/settings_service.py @@ -370,6 +370,19 @@ SETTING_METADATA = { "required": False, "restart_required": True, }, + "social_auth_dropbox_use_global_credentials": { + "category": "Social Login", + "description": ( + "When True, Dropbox social login uses the global DROPBOX_APP_KEY / DROPBOX_APP_SECRET " + "credentials instead of requiring separate SOCIAL_AUTH_DROPBOX_CLIENT_ID / " + "SOCIAL_AUTH_DROPBOX_CLIENT_SECRET values. " + "Requires SOCIAL_AUTH_DROPBOX_ENABLED=True and global Dropbox credentials to be set." + ), + "type": "boolean", + "sensitive": False, + "required": False, + "restart_required": True, + }, "social_auth_dropbox_enabled": { "category": "Social Login", "description": ( @@ -763,6 +776,18 @@ SETTING_METADATA = { "required": False, "restart_required": False, }, + "dropbox_allow_global_credentials_for_integrations": { + "category": "Storage Providers", + "description": ( + "When True, users may authorize their personal Dropbox integrations using the global " + "DROPBOX_APP_KEY / DROPBOX_APP_SECRET credentials configured by the admin, without " + "needing to create their own Dropbox app." + ), + "type": "boolean", + "sensitive": False, + "required": False, + "restart_required": False, + }, # Storage Providers - Nextcloud "nextcloud_enabled": { "category": "Storage Providers", diff --git a/app/views/dropbox.py b/app/views/dropbox.py index f623a7c6..db3c3b30 100644 --- a/app/views/dropbox.py +++ b/app/views/dropbox.py @@ -46,6 +46,12 @@ async def dropbox_setup_page( cfg = {} # Support both "folder" (DROPBOX destination) and "folder_path" (WATCH_FOLDER source) folder_path = cfg.get("folder", cfg.get("folder_path", "")) + # Determine if global credentials are available for users to reuse + global_creds_available = bool( + settings.dropbox_allow_global_credentials_for_integrations + and settings.dropbox_app_key + and settings.dropbox_app_secret + ) return templates.TemplateResponse( "dropbox.html", { @@ -56,9 +62,11 @@ async def dropbox_setup_page( "integration_name": integration.name, "integration_type": integration.integration_type, "folder_path": folder_path, - "app_key_value": "", + # Only expose the public app key (not the secret) when global creds are allowed + "app_key_value": settings.dropbox_app_key if global_creds_available else "", "app_secret_value": "", "refresh_token_value": "", + "global_creds_available": global_creds_available, }, ) diff --git a/docs/API.md b/docs/API.md index ec6d2dea..a31e98cc 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1379,7 +1379,7 @@ Test an integration connection without saving. Useful for "Test connection" UI b {"success": true, "message": "IMAP connection successful"} ``` -Supported connection tests: `IMAP`, `S3`, `WEBDAV`, `NEXTCLOUD`. Other types return a message that testing is not yet supported. +Supported connection tests: `DROPBOX`, `IMAP`, `S3`, `WEBDAV`, `NEXTCLOUD`. Other types return a message that testing is not yet supported. ### GET /api/integrations/quota/ diff --git a/frontend/templates/dropbox.html b/frontend/templates/dropbox.html index a94a5b96..46d4cc6d 100644 --- a/frontend/templates/dropbox.html +++ b/frontend/templates/dropbox.html @@ -116,6 +116,34 @@
Using shared application credentials
+Your administrator has enabled shared Dropbox app credentials. You can authorize your account without supplying your own App Key and Secret.
+Target folder (from integration settings)
+{{ folder_path }}
+Exchanging authorization code using shared credentials…
'; + + fetch('/api/dropbox/exchange-token-global', { + method: 'POST', + body: formData + }) + .then(response => { + if (!response.ok) { + return response.json().then(err => { + throw new Error(err.detail || 'Failed to exchange token'); + }); + } + return response.json(); + }) + .then(data => { + if (data.refresh_token) { + const resolvedAppKey = data.app_key || ''; + if (integrationId) { + const creds = { + refresh_token: data.refresh_token, + // Store public app_key with the integration (no secret stored browser-side) + app_key: resolvedAppKey, + // Flag so the backend knows to use global app_secret for future operations + use_global_app_secret: true, + }; + + const body = { credentials: creds }; + + document.getElementById('processing-message').innerHTML = + 'Saving credentials to your integration…
'; + + return fetch(`/api/integrations/${integrationId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }).then(response => { + if (!response.ok) { + return response.json().then(err => { + throw new Error('Failed to save credentials: ' + (err.detail || 'Unknown error')); + }); + } + return response.json(); + }).then(() => { + // Clean up + sessionStorage.removeItem('dropbox_use_global_creds'); + sessionStorage.removeItem('oauth_integration_id'); + + document.getElementById('processing-message').innerHTML = + '✓ Dropbox authorized successfully!
' + + 'Redirecting to Integrations…
'; + document.querySelector('.animate-spin').parentNode.classList.add('hidden'); + setTimeout(() => { window.location.href = '/integrations'; }, 2000); + }); + } + // Global admin flow — the exchange-token-global endpoint is intended for + // per-user integrations, so reaching here without an integrationId is unexpected. + console.warn('dropbox_callback: global creds flow reached without integration_id'); + sessionStorage.removeItem('dropbox_use_global_creds'); + showSuccess(data.refresh_token, resolvedAppKey, '', folderPath); + setTimeout(() => { window.location.href = '/status'; }, 10000); + } else { + throw new Error('No refresh token was received from the server'); + } + }) + .catch(error => { + showError(error.message); + }); + } + function exchangeCode(code, appKey, appSecret, redirectUri) { const formData = new FormData(); formData.append('client_id', appKey); diff --git a/frontend/templates/integrations_dashboard.html b/frontend/templates/integrations_dashboard.html index 335b6745..5de07de2 100644 --- a/frontend/templates/integrations_dashboard.html +++ b/frontend/templates/integrations_dashboard.html @@ -1392,7 +1392,7 @@ function integrationsDashboard() { body: JSON.stringify({ integration_type: intg.integration_type, config: intg.config, - credentials: creds, + credentials: creds.credentials, }), }); const data = await resp.json(); diff --git a/tests/test_api_integrations.py b/tests/test_api_integrations.py index 90d1c1f3..df67a71d 100644 --- a/tests/test_api_integrations.py +++ b/tests/test_api_integrations.py @@ -887,9 +887,9 @@ class TestConnectionTestEndpoint: def test_test_unsupported_type(self, int_client): """Unsupported integration types return a helpful non-error message.""" payload = { - "integration_type": "DROPBOX", + "integration_type": "FTP", "config": {}, - "credentials": {"token": "abc"}, + "credentials": {"username": "user", "password": "pass"}, } resp = int_client.post("/api/integrations/test", json=payload) assert resp.status_code == 200 @@ -897,6 +897,85 @@ class TestConnectionTestEndpoint: assert data["success"] is False assert "not yet supported" in data["message"] + def test_test_dropbox_missing_refresh_token(self, int_client): + """Dropbox test with missing refresh_token returns failure.""" + payload = { + "integration_type": "DROPBOX", + "config": {}, + "credentials": {"app_key": "key", "app_secret": "secret"}, + } + resp = int_client.post("/api/integrations/test", json=payload) + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is False + assert "refresh_token" in data["message"].lower() + + def test_test_dropbox_missing_app_key(self, int_client): + """Dropbox test with missing app_key/app_secret returns failure.""" + payload = { + "integration_type": "DROPBOX", + "config": {}, + "credentials": {"refresh_token": "rtoken"}, + } + resp = int_client.post("/api/integrations/test", json=payload) + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is False + assert "app_key" in data["message"].lower() + + def test_test_dropbox_invalid_credentials(self, int_client): + """Dropbox test with bad credentials returns an auth failure.""" + from unittest.mock import MagicMock, patch + + import dropbox.exceptions as dbx_exc + + with patch("app.api.integrations.dbx_lib") as mock_dbx: + mock_instance = MagicMock() + mock_dbx.Dropbox.return_value = mock_instance + mock_instance.users_get_current_account.side_effect = dbx_exc.AuthError( + "req_id", MagicMock() + ) + payload = { + "integration_type": "DROPBOX", + "config": {}, + "credentials": { + "app_key": "bad_key", + "app_secret": "bad_secret", + "refresh_token": "bad_token", + }, + } + resp = int_client.post("/api/integrations/test", json=payload) + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is False + assert "authentication failed" in data["message"].lower() + + def test_test_dropbox_success(self, int_client): + """Dropbox test with valid (mocked) credentials returns success.""" + from unittest.mock import MagicMock, patch + + with patch("app.api.integrations.dbx_lib") as mock_dbx: + mock_instance = MagicMock() + mock_dbx.Dropbox.return_value = mock_instance + mock_account = MagicMock() + mock_account.name.display_name = "Test User" + mock_instance.users_get_current_account.return_value = mock_account + + payload = { + "integration_type": "DROPBOX", + "config": {}, + "credentials": { + "app_key": "valid_key", + "app_secret": "valid_secret", + "refresh_token": "valid_token", + }, + } + resp = int_client.post("/api/integrations/test", json=payload) + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert "dropbox connection successful" in data["message"].lower() + def test_test_invalid_type_returns_400(self, int_client): """Invalid integration_type returns 400.""" payload = { From 910fb297ba1122b751250b323a330e15e276daf6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]"