From 34fc06c283564330b9894bf56777f418d4495bd6 Mon Sep 17 00:00:00 2001 From: Billy Bao Date: Thu, 11 Dec 2025 16:30:14 +0800 Subject: [PATCH] finish box connector --- api/apps/connector_app.py | 40 ++- common/data_source/box_connector.py | 6 +- rag/svr/sync_data_source.py | 13 +- .../data-source/component/box-token-field.tsx | 333 ++++++++++++++++-- .../user-setting/data-source/contant.tsx | 3 +- web/src/services/data-source-service.ts | 9 + web/src/utils/api.ts | 2 + 7 files changed, 350 insertions(+), 56 deletions(-) diff --git a/api/apps/connector_app.py b/api/apps/connector_app.py index 7e95bf866..fb074419b 100644 --- a/api/apps/connector_app.py +++ b/api/apps/connector_app.py @@ -380,7 +380,6 @@ async def poll_google_web_result(): @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() @@ -394,7 +393,7 @@ async def start_box_web_oauth(): flow_id = str(uuid.uuid4()) box_auth = BoxOAuth( - oauth_config=OAuthConfig( + OAuthConfig( client_id=client_id, client_secret=client_secret, ) @@ -411,7 +410,7 @@ async def start_box_web_oauth(): "user_id": current_user.id, "auth_url": auth_url, "client_id": client_id, - "clientsecret": client_secret, + "client_secret": client_secret, "created_at": int(time.time()), } REDIS_CONN.set_obj(_web_state_cache_key(flow_id, "box"), cache_payload, WEB_FLOW_TTL_SECS) @@ -425,11 +424,14 @@ async def start_box_web_oauth(): @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: + if 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")) + code = request.args.get("code") + if not code: + return await _render_web_oauth_popup(flow_id, False, "Missing authorization code from Box.", "box") + + cache_payload = json.loads(REDIS_CONN.get(_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.") @@ -439,15 +441,21 @@ async def box_web_oauth_callback(): 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") - + auth = BoxOAuth( + OAuthConfig( + client_id=cache_payload.get("client_id"), + client_secret=cache_payload.get("client_secret"), + ) + ) + + auth.get_tokens_authorization_code_grant(code) + token = auth.retrieve_token() result_payload = { "user_id": cache_payload.get("user_id"), "client_id": cache_payload.get("client_id"), - "client_secret": cache_payload.get("clientsecret"), - "code": code, + "client_secret": cache_payload.get("client_secret"), + "access_token": token.access_token, + "refresh_token": token.refresh_token, } REDIS_CONN.set_obj(_web_result_cache_key(flow_id, "box"), result_payload, WEB_FLOW_TTL_SECS) @@ -462,12 +470,14 @@ 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: + cache_blob = REDIS_CONN.get(_web_result_cache_key(flow_id, "box")) + if not cache_blob: return get_json_result(code=RetCode.RUNNING, message="Authorization is still pending.") - + + cache_raw = json.loads(cache_blob) 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 a820f14ac..e5aeb7614 100644 --- a/common/data_source/box_connector.py +++ b/common/data_source/box_connector.py @@ -15,9 +15,9 @@ from common.data_source.models import Document, GenerateDocumentsOutput from common.data_source.utils import get_file_ext class BoxConnector(LoadConnector, PollConnector): - def __init__(self, folder_id: str = "0", batch_size: int = INDEX_BATCH_SIZE, use_marker: bool = False) -> None: + def __init__(self, folder_id: str, batch_size: int = INDEX_BATCH_SIZE, use_marker: bool = False) -> None: self.batch_size = batch_size - self.folder_id = folder_id + self.folder_id = "0" if not folder_id else folder_id self.use_marker = use_marker def load_credentials(self, auth: Any): @@ -49,7 +49,7 @@ class BoxConnector(LoadConnector, PollConnector): result = self.box_client.folders.get_folder_items( folder_id=folder_id, limit=self.batch_size, - usemarker=self.usemarker + usemarker=self.use_marker ) while True: diff --git a/rag/svr/sync_data_source.py b/rag/svr/sync_data_source.py index 52e79d1b2..aac7307d3 100644 --- a/rag/svr/sync_data_source.py +++ b/rag/svr/sync_data_source.py @@ -31,6 +31,7 @@ import traceback from datetime import datetime, timezone from typing import Any +from flask import json import trio from api.db.services.connector_service import ConnectorService, SyncLogsService @@ -48,7 +49,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 +from box_sdk_gen import BoxOAuth, OAuthConfig, AccessToken MAX_CONCURRENT_TASKS = int(os.environ.get("MAX_CONCURRENT_TASKS", "5")) task_limiter = trio.Semaphore(MAX_CONCURRENT_TASKS) @@ -611,14 +612,20 @@ class BOX(SyncBase): use_marker=self.conf.get("use_marker", False) ) - credential = self.conf['credentials'] + credential = json.loads(self.conf['credentials']['box_tokens']) + auth = BoxOAuth( OAuthConfig( client_id=credential['client_id'], client_secret=credential['client_secret'], ) ) - auth.get_tokens_authorization_code_grant(credential['code']) + + token = AccessToken( + access_token=credential['access_token'], + refresh_token=credential['refresh_token'], + ) + auth.token_storage.store(token) self.connector.load_credentials(auth) if task["reindex"] == "1" or not task["poll_range_start"]: 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 index fe116500f..7151ebea8 100644 --- 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 @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Button } from '@/components/ui/button'; import { @@ -11,11 +11,14 @@ import { } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import message from '@/components/ui/message'; +import { + pollBoxWebAuthResult, + startBoxWebAuth, +} from '@/services/data-source-service'; +import { Loader2 } from 'lucide-react'; export type BoxTokenFieldProps = { - /** 存储在表单里的值,约定为 JSON 字符串 */ value?: string; - /** 表单回写,用 JSON 字符串承载 client_id / client_secret / redirect_uri */ onChange: (value: any) => void; placeholder?: string; }; @@ -24,8 +27,13 @@ type BoxCredentials = { client_id?: string; client_secret?: string; redirect_uri?: string; + authorization_code?: string; + access_token?: string; + refresh_token?: string; }; +type BoxAuthStatus = 'idle' | 'waiting' | 'success' | 'error'; + const parseBoxCredentials = (content?: string): BoxCredentials | null => { if (!content) return null; try { @@ -34,25 +42,30 @@ const parseBoxCredentials = (content?: string): BoxCredentials | null => { client_id: parsed.client_id, client_secret: parsed.client_secret, redirect_uri: parsed.redirect_uri, + authorization_code: parsed.authorization_code ?? parsed.code, + access_token: parsed.access_token, + refresh_token: parsed.refresh_token, }; } catch { return null; } }; -const BoxTokenField = ({ - value, - onChange, - placeholder, -}: BoxTokenFieldProps) => { +const BoxTokenField = ({ value, onChange }: BoxTokenFieldProps) => { const [dialogOpen, setDialogOpen] = useState(false); const [clientId, setClientId] = useState(''); const [clientSecret, setClientSecret] = useState(''); const [redirectUri, setRedirectUri] = useState(''); + const [submitLoading, setSubmitLoading] = useState(false); + const [webFlowId, setWebFlowId] = useState(null); + const webFlowIdRef = useRef(null); + const webPollTimerRef = useRef | null>(null); + const [webStatus, setWebStatus] = useState('idle'); + const [webStatusMessage, setWebStatusMessage] = useState(''); const parsed = useMemo(() => parseBoxCredentials(value), [value]); + const parsedRedirectUri = useMemo(() => parsed?.redirect_uri ?? '', [parsed]); - // 当外部 value 变化且弹窗关闭时,同步初始值 useEffect(() => { if (!dialogOpen) { setClientId(parsed?.client_id ?? ''); @@ -61,6 +74,18 @@ const BoxTokenField = ({ } }, [parsed, dialogOpen]); + useEffect(() => { + webFlowIdRef.current = webFlowId; + }, [webFlowId]); + + useEffect(() => { + return () => { + if (webPollTimerRef.current) { + clearTimeout(webPollTimerRef.current); + } + }; + }, []); + const hasConfigured = useMemo( () => Boolean( @@ -69,15 +94,161 @@ const BoxTokenField = ({ [parsed], ); - const handleOpenDialog = useCallback(() => { - setDialogOpen(true); + const hasAuthorized = useMemo( + () => + Boolean( + parsed?.access_token || + parsed?.refresh_token || + parsed?.authorization_code, + ), + [parsed], + ); + + const resetWebStatus = useCallback(() => { + setWebStatus('idle'); + setWebStatusMessage(''); }, []); + const clearWebState = useCallback(() => { + if (webPollTimerRef.current) { + clearTimeout(webPollTimerRef.current); + webPollTimerRef.current = null; + } + webFlowIdRef.current = null; + setWebFlowId(null); + }, []); + + const fetchWebResult = useCallback( + async (flowId: string) => { + try { + const { data } = await pollBoxWebAuthResult({ flow_id: flowId }); + if (data.code === 0 && data.data?.credentials) { + const credentials = (data.data.credentials || {}) as Record< + string, + any + >; + const { user_id: _userId, code, ...rest } = credentials; + + const finalValue: Record = { + ...rest, + // 确保客户端配置字段有值(优先后端返回,其次当前输入) + client_id: rest.client_id ?? clientId.trim(), + client_secret: rest.client_secret ?? clientSecret.trim(), + }; + + const redirect = + redirectUri.trim() || parsedRedirectUri || rest.redirect_uri; + if (redirect) { + finalValue.redirect_uri = redirect; + } + + if (code) { + finalValue.authorization_code = code; + } + + // access_token / refresh_token 由后端返回,已在 ...rest 中带上,无需额外 state + + onChange(JSON.stringify(finalValue)); + message.success('Box authorization completed.'); + clearWebState(); + resetWebStatus(); + setDialogOpen(false); + return; + } + + if (data.code === 106) { + setWebStatus('waiting'); + setWebStatusMessage( + 'Authorization confirmed. Finalizing credentials...', + ); + if (webPollTimerRef.current) { + clearTimeout(webPollTimerRef.current); + } + webPollTimerRef.current = setTimeout( + () => fetchWebResult(flowId), + 1500, + ); + return; + } + + const errorMessage = data.message || 'Authorization failed.'; + message.error(errorMessage); + setWebStatus('error'); + setWebStatusMessage(errorMessage); + clearWebState(); + } catch (_error) { + message.error('Unable to retrieve authorization result.'); + setWebStatus('error'); + setWebStatusMessage('Unable to retrieve authorization result.'); + clearWebState(); + } + }, + [ + clearWebState, + clientId, + clientSecret, + parsedRedirectUri, + redirectUri, + resetWebStatus, + onChange, + ], + ); + + useEffect(() => { + const handler = (event: MessageEvent) => { + const payload = event.data; + if (!payload || payload.type !== 'ragflow-box-oauth') { + return; + } + + const targetFlowId = payload.flowId || webFlowIdRef.current; + if (!targetFlowId) return; + if (webFlowIdRef.current && webFlowIdRef.current !== targetFlowId) { + return; + } + + if (payload.status === 'success') { + setWebStatus('waiting'); + setWebStatusMessage( + 'Authorization confirmed. Finalizing credentials...', + ); + fetchWebResult(targetFlowId); + } else { + const errorMessage = payload.message || 'Authorization failed.'; + message.error(errorMessage); + setWebStatus('error'); + setWebStatusMessage(errorMessage); + clearWebState(); + } + }; + + window.addEventListener('message', handler); + return () => window.removeEventListener('message', handler); + }, [clearWebState, fetchWebResult]); + + const handleOpenDialog = useCallback(() => { + resetWebStatus(); + clearWebState(); + setDialogOpen(true); + }, [clearWebState, resetWebStatus]); + const handleCloseDialog = useCallback(() => { setDialogOpen(false); - }, []); + clearWebState(); + resetWebStatus(); + }, [clearWebState, resetWebStatus]); - const handleSubmit = useCallback(() => { + const handleManualWebCheck = useCallback(() => { + if (!webFlowId) { + message.info('Start browser authorization first.'); + return; + } + setWebStatus('waiting'); + setWebStatusMessage('Checking authorization status...'); + fetchWebResult(webFlowId); + }, [fetchWebResult, webFlowId]); + + const handleSubmit = useCallback(async () => { if (!clientId.trim() || !clientSecret.trim() || !redirectUri.trim()) { message.error( 'Please fill in Client ID, Client Secret, and Redirect URI.', @@ -85,31 +256,91 @@ const BoxTokenField = ({ return; } - const payload: BoxCredentials = { - client_id: clientId.trim(), - client_secret: clientSecret.trim(), - redirect_uri: redirectUri.trim(), + const trimmedClientId = clientId.trim(); + const trimmedClientSecret = clientSecret.trim(); + const trimmedRedirectUri = redirectUri.trim(); + + const payloadForStorage: BoxCredentials = { + client_id: trimmedClientId, + client_secret: trimmedClientSecret, + redirect_uri: trimmedRedirectUri, }; + setSubmitLoading(true); + resetWebStatus(); + clearWebState(); + try { - onChange(JSON.stringify(payload)); - message.success('Box credentials saved locally.'); - setDialogOpen(false); - } catch { - message.error('Failed to save Box credentials.'); + const { data } = await startBoxWebAuth({ + client_id: trimmedClientId, + client_secret: trimmedClientSecret, + redirect_uri: trimmedRedirectUri, + }); + + if (data.code === 0 && data.data?.authorization_url) { + onChange(JSON.stringify(payloadForStorage)); + + const popup = window.open( + data.data.authorization_url, + 'ragflow-box-oauth', + 'width=600,height=720', + ); + if (!popup) { + message.error( + 'Popup was blocked. Please allow popups for this site.', + ); + clearWebState(); + return; + } + popup.focus(); + + const flowId = data.data.flow_id; + setWebFlowId(flowId); + webFlowIdRef.current = flowId; + setWebStatus('waiting'); + setWebStatusMessage( + 'Complete the Box consent in the opened window and return here.', + ); + message.info( + 'Authorization window opened. Complete the Box consent to continue.', + ); + } else { + message.error(data.message || 'Failed to start Box authorization.'); + } + } catch (_error) { + message.error('Failed to start Box authorization.'); + } finally { + setSubmitLoading(false); } - }, [clientId, clientSecret, redirectUri, onChange]); + }, [ + clearWebState, + clientId, + clientSecret, + redirectUri, + resetWebStatus, + onChange, + ]); return (
- {hasConfigured && ( -
- - Configured - + {(hasConfigured || hasAuthorized) && ( +
+
+ {hasAuthorized ? ( + + Authorized + + ) : null} + {hasConfigured ? ( + + Configured + + ) : null} +

- Box OAuth credentials have been configured. You can update them at - any time. + {hasAuthorized + ? 'Box OAuth credentials are authorized and ready to use.' + : 'Box OAuth client information has been stored. Run the browser authorization to finalize the setup.'}

)} @@ -143,7 +374,7 @@ const BoxTokenField = ({ setClientId(e.target.value)} />
@@ -164,13 +395,49 @@ const BoxTokenField = ({ onChange={(e) => setRedirectUri(e.target.value)} />
+ {webStatus !== 'idle' && ( +
+
+ Browser authorization +
+

+ {webStatusMessage} +

+ {webStatus === 'waiting' && webFlowId ? ( +
+ +
+ ) : null} +
+ )} - - + diff --git a/web/src/pages/user-setting/data-source/contant.tsx b/web/src/pages/user-setting/data-source/contant.tsx index 171e41229..ba80c2a55 100644 --- a/web/src/pages/user-setting/data-source/contant.tsx +++ b/web/src/pages/user-setting/data-source/contant.tsx @@ -721,8 +721,7 @@ export const DataSourceFormDefaultValues = { source: DataSourceKey.BOX, config: { name: '', - folder_id: '', - index_recursively: false, + folder_id: '0', credentials: { box_tokens: '', }, diff --git a/web/src/services/data-source-service.ts b/web/src/services/data-source-service.ts index 036c156ad..bfc54b27c 100644 --- a/web/src/services/data-source-service.ts +++ b/web/src/services/data-source-service.ts @@ -47,4 +47,13 @@ export const startGmailWebAuth = (payload: { credentials: string }) => export const pollGmailWebAuthResult = (payload: { flow_id: string }) => request.post(api.googleWebAuthResult('gmail'), { data: payload }); +export const startBoxWebAuth = (payload: { + client_id: string; + client_secret: string; + redirect_uri?: string; +}) => request.post(api.boxWebAuthStart(), { data: payload }); + +export const pollBoxWebAuthResult = (payload: { flow_id: string }) => + request.post(api.boxWebAuthResult(), { data: payload }); + export default dataSourceService; diff --git a/web/src/utils/api.ts b/web/src/utils/api.ts index 953016abf..afb657294 100644 --- a/web/src/utils/api.ts +++ b/web/src/utils/api.ts @@ -46,6 +46,8 @@ export default { `${api_host}/connector/google/oauth/web/start?type=${type}`, googleWebAuthResult: (type: 'google-drive' | 'gmail') => `${api_host}/connector/google/oauth/web/result?type=${type}`, + boxWebAuthStart: () => `${api_host}/connector/box/oauth/web/start`, + boxWebAuthResult: () => `${api_host}/connector/box/oauth/web/result`, // plugin llm_tools: `${api_host}/plugin/llm_tools`,