From d263989317652180abc5d092105afd4a28173ed6 Mon Sep 17 00:00:00 2001 From: Billy Bao Date: Thu, 11 Dec 2025 11:53:43 +0800 Subject: [PATCH] box connector_app --- api/apps/connector_app.py | 124 +++++++++--- common/data_source/box_connector.py | 7 +- rag/svr/sync_data_source.py | 12 +- .../data-source/component/box-token-field.tsx | 181 ++++++++++++++++++ .../user-setting/data-source/contant.tsx | 42 ++-- 5 files changed, 309 insertions(+), 57 deletions(-) create mode 100644 web/src/pages/user-setting/data-source/component/box-token-field.tsx diff --git a/api/apps/connector_app.py b/api/apps/connector_app.py index 44d3a3344..7e95bf866 100644 --- a/api/apps/connector_app.py +++ b/api/apps/connector_app.py @@ -28,11 +28,12 @@ from api.db import InputType from api.db.services.connector_service import ConnectorService, SyncLogsService from api.utils.api_utils import get_data_error_result, get_json_result, get_request_json, validate_request from common.constants import RetCode, TaskStatus -from common.data_source.config import GOOGLE_DRIVE_WEB_OAUTH_REDIRECT_URI, GMAIL_WEB_OAUTH_REDIRECT_URI, DocumentSource -from common.data_source.google_util.constant import GOOGLE_WEB_OAUTH_POPUP_TEMPLATE, GOOGLE_SCOPES +from common.data_source.config import GOOGLE_DRIVE_WEB_OAUTH_REDIRECT_URI, GMAIL_WEB_OAUTH_REDIRECT_URI, BOX_WEB_OAUTH_REDIRECT_URI, DocumentSource +from common.data_source.google_util.constant import WEB_OAUTH_POPUP_TEMPLATE, GOOGLE_SCOPES from common.misc_utils import get_uuid from rag.utils.redis_conn import REDIS_CONN from api.apps import login_required, current_user +from box_sdk_gen import BoxOAuth, OAuthConfig, GetAuthorizeUrlOptions @manager.route("/set", methods=["POST"]) # noqa: F821 @@ -117,8 +118,6 @@ def rm_connector(connector_id): return get_json_result(data=True) -GOOGLE_WEB_FLOW_STATE_PREFIX = "google_drive_web_flow_state" -GOOGLE_WEB_FLOW_RESULT_PREFIX = "google_drive_web_flow_result" WEB_FLOW_TTL_SECS = 15 * 60 @@ -129,10 +128,7 @@ def _web_state_cache_key(flow_id: str, source_type: str | None = None) -> str: When source_type == "gmail", a different prefix is used so that Drive/Gmail flows don't clash in Redis. """ - if source_type == "gmail": - prefix = "gmail_web_flow_state" - else: - prefix = GOOGLE_WEB_FLOW_STATE_PREFIX + prefix = f"{source_type}_web_flow_state" return f"{prefix}:{flow_id}" @@ -141,10 +137,7 @@ def _web_result_cache_key(flow_id: str, source_type: str | None = None) -> str: Mirrors _web_state_cache_key logic for result storage. """ - if source_type == "gmail": - prefix = "gmail_web_flow_result" - else: - prefix = GOOGLE_WEB_FLOW_RESULT_PREFIX + prefix = f"{source_type}_web_flow_result" return f"{prefix}:{flow_id}" @@ -180,7 +173,7 @@ async def _render_web_oauth_popup(flow_id: str, success: bool, message: str, sou } ) # TODO(google-oauth): title/heading/message may need to reflect drive/gmail based on cached type - html = GOOGLE_WEB_OAUTH_POPUP_TEMPLATE.format( + html = WEB_OAUTH_POPUP_TEMPLATE.format( title=f"Google {source.capitalize()} Authorization", heading="Authorization complete" if success else "Authorization failed", message=escaped_message, @@ -204,8 +197,8 @@ async def start_google_web_oauth(): redirect_uri = GMAIL_WEB_OAUTH_REDIRECT_URI scopes = GOOGLE_SCOPES[DocumentSource.GMAIL] else: - redirect_uri = GOOGLE_DRIVE_WEB_OAUTH_REDIRECT_URI if source == "google-drive" else GMAIL_WEB_OAUTH_REDIRECT_URI - scopes = GOOGLE_SCOPES[DocumentSource.GOOGLE_DRIVE if source == "google-drive" else DocumentSource.GMAIL] + redirect_uri = GOOGLE_DRIVE_WEB_OAUTH_REDIRECT_URI + scopes = GOOGLE_SCOPES[DocumentSource.GOOGLE_DRIVE] if not redirect_uri: return get_json_result( @@ -271,8 +264,6 @@ async def google_gmail_web_oauth_callback(): state_id = request.args.get("state") error = request.args.get("error") source = "gmail" - if source != 'gmail': - return await _render_web_oauth_popup("", False, "Invalid Google OAuth type.", source) error_description = request.args.get("error_description") or error @@ -313,9 +304,6 @@ async def google_gmail_web_oauth_callback(): "credentials": creds_json, } REDIS_CONN.set_obj(_web_result_cache_key(state_id, source), result_payload, WEB_FLOW_TTL_SECS) - - print("\n\n", _web_result_cache_key(state_id, source), "\n\n") - REDIS_CONN.delete(_web_state_cache_key(state_id, source)) return await _render_web_oauth_popup(state_id, True, "Authorization completed successfully.", source) @@ -326,8 +314,6 @@ async def google_drive_web_oauth_callback(): state_id = request.args.get("state") error = request.args.get("error") source = "google-drive" - if source not in ("google-drive", "gmail"): - return await _render_web_oauth_popup("", False, "Invalid Google OAuth type.", source) error_description = request.args.get("error_description") or error @@ -391,3 +377,97 @@ async def poll_google_web_result(): REDIS_CONN.delete(_web_result_cache_key(flow_id, source)) return get_json_result(data={"credentials": result.get("credentials")}) + +@manager.route("/box/oauth/web/start", methods=["POST"]) # noqa: F821 +@login_required +@validate_request("") +async def start_box_web_oauth(): + req = await get_request_json() + + client_id = req.get("client_id") + client_secret = req.get("client_secret") + redirect_uri = req.get("redirect_uri", BOX_WEB_OAUTH_REDIRECT_URI) + + if not client_id or not client_secret: + return get_json_result(code=RetCode.ARGUMENT_ERROR, message="Box client_id and client_secret are required.") + + flow_id = str(uuid.uuid4()) + + box_auth = BoxOAuth( + oauth_config=OAuthConfig( + client_id=client_id, + client_secret=client_secret, + ) + ) + + auth_url = box_auth.get_authorize_url( + options=GetAuthorizeUrlOptions( + redirect_uri=redirect_uri, + state=flow_id, + ) + ) + + cache_payload = { + "user_id": current_user.id, + "auth_url": auth_url, + "client_id": client_id, + "clientsecret": client_secret, + "created_at": int(time.time()), + } + REDIS_CONN.set_obj(_web_state_cache_key(flow_id, "box"), cache_payload, WEB_FLOW_TTL_SECS) + return get_json_result( + data = { + "flow_id": flow_id, + "authorization_url": auth_url, + "expires_in": WEB_FLOW_TTL_SECS,} + ) + +@manager.route("/box/oauth/web/callback", methods=["GET"]) # noqa: F821 +async def box_web_oauth_callback(): + flow_id = request.args.get("state") + + if not code or not flow_id: + return await _render_web_oauth_popup("", False, "Missing OAuth parameters.", "box") + + cache_payload = REDIS_CONN.get_obj(_web_state_cache_key(flow_id, "box")) + if not cache_payload: + return get_json_result(code=RetCode.ARGUMENT_ERROR, message="Box OAuth session expired or invalid.") + + error = request.args.get("error") + error_description = request.args.get("error_description") or error + if error: + REDIS_CONN.delete(_web_state_cache_key(flow_id, "box")) + return await _render_web_oauth_popup(flow_id, False, error_description or "Authorization failed.", "box") + + code = request.args.get("code") + if not code: + return await _render_web_oauth_popup(flow_id, False, "Missing authorization code from Box.", "box") + + result_payload = { + "user_id": cache_payload.get("user_id"), + "client_id": cache_payload.get("client_id"), + "client_secret": cache_payload.get("clientsecret"), + "code": code, + } + + REDIS_CONN.set_obj(_web_result_cache_key(flow_id, "box"), result_payload, WEB_FLOW_TTL_SECS) + REDIS_CONN.delete(_web_state_cache_key(flow_id, "box")) + + return await _render_web_oauth_popup(flow_id, True, "Authorization completed successfully.", "box") + +@manager.route("/box/oauth/web/result", methods=["POST"]) # noqa: F821 +@login_required +@validate_request("flow_id") +async def poll_box_web_result(): + req = await get_request_json() + flow_id = req.get("flow_id") + + cache_raw = REDIS_CONN.get_obj(_web_result_cache_key(flow_id, "box")) + if not cache_raw: + return get_json_result(code=RetCode.RUNNING, message="Authorization is still pending.") + + if cache_raw.get("user_id") != current_user.id: + return get_json_result(code=RetCode.PERMISSION_ERROR, message="You are not allowed to access this authorization result.") + + REDIS_CONN.delete(_web_result_cache_key(flow_id, "box")) + return get_json_result(data={"credentials": cache_raw}) \ No newline at end of file diff --git a/common/data_source/box_connector.py b/common/data_source/box_connector.py index ed79ebc11..a820f14ac 100644 --- a/common/data_source/box_connector.py +++ b/common/data_source/box_connector.py @@ -20,12 +20,7 @@ class BoxConnector(LoadConnector, PollConnector): self.folder_id = folder_id self.use_marker = use_marker - - def load_credentials(self, credentials): - auth = credentials.get("auth") - if not auth: - raise ConnectorMissingCredentialError("Box auth is required") - + def load_credentials(self, auth: Any): self.box_client = BoxClient(auth=auth) return None diff --git a/rag/svr/sync_data_source.py b/rag/svr/sync_data_source.py index 1f5c3df76..52e79d1b2 100644 --- a/rag/svr/sync_data_source.py +++ b/rag/svr/sync_data_source.py @@ -48,6 +48,7 @@ from common.data_source.utils import load_all_docs_from_checkpoint_connector from common.log_utils import init_root_logger from common.signal_utils import start_tracemalloc_and_snapshot, stop_tracemalloc from common.versions import get_ragflow_version +from box_sdk_gen import BoxClient, BoxOAuth, OAuthConfig, GetAuthorizeUrlOptions MAX_CONCURRENT_TASKS = int(os.environ.get("MAX_CONCURRENT_TASKS", "5")) task_limiter = trio.Semaphore(MAX_CONCURRENT_TASKS) @@ -610,7 +611,16 @@ class BOX(SyncBase): use_marker=self.conf.get("use_marker", False) ) - self.connector.load_credentials(self.conf["credentials"]) + credential = self.conf['credentials'] + auth = BoxOAuth( + OAuthConfig( + client_id=credential['client_id'], + client_secret=credential['client_secret'], + ) + ) + auth.get_tokens_authorization_code_grant(credential['code']) + + self.connector.load_credentials(auth) if task["reindex"] == "1" or not task["poll_range_start"]: document_generator = self.connector.load_from_state() begin_info = "totally" diff --git a/web/src/pages/user-setting/data-source/component/box-token-field.tsx b/web/src/pages/user-setting/data-source/component/box-token-field.tsx new file mode 100644 index 000000000..fe116500f --- /dev/null +++ b/web/src/pages/user-setting/data-source/component/box-token-field.tsx @@ -0,0 +1,181 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import message from '@/components/ui/message'; + +export type BoxTokenFieldProps = { + /** 存储在表单里的值,约定为 JSON 字符串 */ + value?: string; + /** 表单回写,用 JSON 字符串承载 client_id / client_secret / redirect_uri */ + onChange: (value: any) => void; + placeholder?: string; +}; + +type BoxCredentials = { + client_id?: string; + client_secret?: string; + redirect_uri?: string; +}; + +const parseBoxCredentials = (content?: string): BoxCredentials | null => { + if (!content) return null; + try { + const parsed = JSON.parse(content); + return { + client_id: parsed.client_id, + client_secret: parsed.client_secret, + redirect_uri: parsed.redirect_uri, + }; + } catch { + return null; + } +}; + +const BoxTokenField = ({ + value, + onChange, + placeholder, +}: BoxTokenFieldProps) => { + const [dialogOpen, setDialogOpen] = useState(false); + const [clientId, setClientId] = useState(''); + const [clientSecret, setClientSecret] = useState(''); + const [redirectUri, setRedirectUri] = useState(''); + + const parsed = useMemo(() => parseBoxCredentials(value), [value]); + + // 当外部 value 变化且弹窗关闭时,同步初始值 + useEffect(() => { + if (!dialogOpen) { + setClientId(parsed?.client_id ?? ''); + setClientSecret(parsed?.client_secret ?? ''); + setRedirectUri(parsed?.redirect_uri ?? ''); + } + }, [parsed, dialogOpen]); + + const hasConfigured = useMemo( + () => + Boolean( + parsed?.client_id && parsed?.client_secret && parsed?.redirect_uri, + ), + [parsed], + ); + + const handleOpenDialog = useCallback(() => { + setDialogOpen(true); + }, []); + + const handleCloseDialog = useCallback(() => { + setDialogOpen(false); + }, []); + + const handleSubmit = useCallback(() => { + if (!clientId.trim() || !clientSecret.trim() || !redirectUri.trim()) { + message.error( + 'Please fill in Client ID, Client Secret, and Redirect URI.', + ); + return; + } + + const payload: BoxCredentials = { + client_id: clientId.trim(), + client_secret: clientSecret.trim(), + redirect_uri: redirectUri.trim(), + }; + + try { + onChange(JSON.stringify(payload)); + message.success('Box credentials saved locally.'); + setDialogOpen(false); + } catch { + message.error('Failed to save Box credentials.'); + } + }, [clientId, clientSecret, redirectUri, onChange]); + + return ( +
+ {hasConfigured && ( +
+ + Configured + +

+ Box OAuth credentials have been configured. You can update them at + any time. +

+
+ )} + + + + + !open ? handleCloseDialog() : setDialogOpen(true) + } + > + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + onEscapeKeyDown={(e) => e.preventDefault()} + > + + Configure Box OAuth credentials + + Enter your Box application's Client ID, Client Secret, and + Redirect URI. These values will be stored in the form field and + can be used later to start the OAuth flow. + + + +
+
+ + setClientId(e.target.value)} + /> +
+
+ + setClientSecret(e.target.value)} + /> +
+
+ + setRedirectUri(e.target.value)} + /> +
+
+ + + + + +
+
+
+ ); +}; + +export default BoxTokenField; diff --git a/web/src/pages/user-setting/data-source/contant.tsx b/web/src/pages/user-setting/data-source/contant.tsx index 0e57c4a7f..171e41229 100644 --- a/web/src/pages/user-setting/data-source/contant.tsx +++ b/web/src/pages/user-setting/data-source/contant.tsx @@ -1,6 +1,7 @@ import { FormFieldType } from '@/components/dynamic-form'; import SvgIcon from '@/components/svg-icon'; import { t } from 'i18next'; +import BoxTokenField from './component/box-token-field'; import { ConfluenceIndexingModeField } from './component/confluence-token-field'; import GmailTokenField from './component/gmail-token-field'; import GoogleDriveTokenField from './component/google-drive-token-field'; @@ -559,37 +560,24 @@ export const DataSourceFormFields = { ], [DataSourceKey.BOX]: [ { - label: 'Client ID', - name: 'config.credentials.box_client_id', - type: FormFieldType.Text, + label: 'Box OAuth JSON', + name: 'config.credentials.box_tokens', + type: FormFieldType.Textarea, required: true, - }, - { - label: 'Client Secret', - name: 'config.credentials.box_client_secret', - type: FormFieldType.Password, - required: true, - }, - { - label: 'Redirect URI', - name: 'config.credentials.box_redirect_uri', - type: FormFieldType.Text, - required: true, - placeholder: 'https://example.com/oauth2/callback', + render: (fieldProps: any) => ( + + ), }, { label: 'Folder ID', name: 'config.folder_id', type: FormFieldType.Text, required: false, - placeholder: 'Defaults to root (0)', - }, - { - label: 'Index recursively', - name: 'config.index_recursively', - type: FormFieldType.Checkbox, - required: false, - defaultValue: false, + placeholder: 'Defaults root', }, ], }; @@ -733,12 +721,10 @@ export const DataSourceFormDefaultValues = { source: DataSourceKey.BOX, config: { name: '', - folder_id: '0', + folder_id: '', index_recursively: false, credentials: { - box_client_id: '', - box_client_secret: '', - box_redirect_uri: '', + box_tokens: '', }, }, },