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 ( +
+ Box OAuth credentials have been configured. You can update them at + any time. +
+