finish box connector

This commit is contained in:
Billy Bao 2025-12-11 16:30:14 +08:00
parent d263989317
commit 34fc06c283
7 changed files with 350 additions and 56 deletions

View file

@ -380,7 +380,6 @@ async def poll_google_web_result():
@manager.route("/box/oauth/web/start", methods=["POST"]) # noqa: F821 @manager.route("/box/oauth/web/start", methods=["POST"]) # noqa: F821
@login_required @login_required
@validate_request("")
async def start_box_web_oauth(): async def start_box_web_oauth():
req = await get_request_json() req = await get_request_json()
@ -394,7 +393,7 @@ async def start_box_web_oauth():
flow_id = str(uuid.uuid4()) flow_id = str(uuid.uuid4())
box_auth = BoxOAuth( box_auth = BoxOAuth(
oauth_config=OAuthConfig( OAuthConfig(
client_id=client_id, client_id=client_id,
client_secret=client_secret, client_secret=client_secret,
) )
@ -411,7 +410,7 @@ async def start_box_web_oauth():
"user_id": current_user.id, "user_id": current_user.id,
"auth_url": auth_url, "auth_url": auth_url,
"client_id": client_id, "client_id": client_id,
"clientsecret": client_secret, "client_secret": client_secret,
"created_at": int(time.time()), "created_at": int(time.time()),
} }
REDIS_CONN.set_obj(_web_state_cache_key(flow_id, "box"), cache_payload, WEB_FLOW_TTL_SECS) 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 @manager.route("/box/oauth/web/callback", methods=["GET"]) # noqa: F821
async def box_web_oauth_callback(): async def box_web_oauth_callback():
flow_id = request.args.get("state") flow_id = request.args.get("state")
if not flow_id:
if not code or not flow_id:
return await _render_web_oauth_popup("", False, "Missing OAuth parameters.", "box") 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: if not cache_payload:
return get_json_result(code=RetCode.ARGUMENT_ERROR, message="Box OAuth session expired or invalid.") 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")) 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") return await _render_web_oauth_popup(flow_id, False, error_description or "Authorization failed.", "box")
code = request.args.get("code") auth = BoxOAuth(
if not code: OAuthConfig(
return await _render_web_oauth_popup(flow_id, False, "Missing authorization code from Box.", "box") 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 = { result_payload = {
"user_id": cache_payload.get("user_id"), "user_id": cache_payload.get("user_id"),
"client_id": cache_payload.get("client_id"), "client_id": cache_payload.get("client_id"),
"client_secret": cache_payload.get("clientsecret"), "client_secret": cache_payload.get("client_secret"),
"code": code, "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) 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() req = await get_request_json()
flow_id = req.get("flow_id") flow_id = req.get("flow_id")
cache_raw = REDIS_CONN.get_obj(_web_result_cache_key(flow_id, "box")) cache_blob = REDIS_CONN.get(_web_result_cache_key(flow_id, "box"))
if not cache_raw: if not cache_blob:
return get_json_result(code=RetCode.RUNNING, message="Authorization is still pending.") 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: 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.") 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")) REDIS_CONN.delete(_web_result_cache_key(flow_id, "box"))
return get_json_result(data={"credentials": cache_raw}) return get_json_result(data={"credentials": cache_raw})

View file

@ -15,9 +15,9 @@ from common.data_source.models import Document, GenerateDocumentsOutput
from common.data_source.utils import get_file_ext from common.data_source.utils import get_file_ext
class BoxConnector(LoadConnector, PollConnector): 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.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 self.use_marker = use_marker
def load_credentials(self, auth: Any): def load_credentials(self, auth: Any):
@ -49,7 +49,7 @@ class BoxConnector(LoadConnector, PollConnector):
result = self.box_client.folders.get_folder_items( result = self.box_client.folders.get_folder_items(
folder_id=folder_id, folder_id=folder_id,
limit=self.batch_size, limit=self.batch_size,
usemarker=self.usemarker usemarker=self.use_marker
) )
while True: while True:

View file

@ -31,6 +31,7 @@ import traceback
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
from flask import json
import trio import trio
from api.db.services.connector_service import ConnectorService, SyncLogsService 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.log_utils import init_root_logger
from common.signal_utils import start_tracemalloc_and_snapshot, stop_tracemalloc from common.signal_utils import start_tracemalloc_and_snapshot, stop_tracemalloc
from common.versions import get_ragflow_version 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")) MAX_CONCURRENT_TASKS = int(os.environ.get("MAX_CONCURRENT_TASKS", "5"))
task_limiter = trio.Semaphore(MAX_CONCURRENT_TASKS) task_limiter = trio.Semaphore(MAX_CONCURRENT_TASKS)
@ -611,14 +612,20 @@ class BOX(SyncBase):
use_marker=self.conf.get("use_marker", False) use_marker=self.conf.get("use_marker", False)
) )
credential = self.conf['credentials'] credential = json.loads(self.conf['credentials']['box_tokens'])
auth = BoxOAuth( auth = BoxOAuth(
OAuthConfig( OAuthConfig(
client_id=credential['client_id'], client_id=credential['client_id'],
client_secret=credential['client_secret'], 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) self.connector.load_credentials(auth)
if task["reindex"] == "1" or not task["poll_range_start"]: if task["reindex"] == "1" or not task["poll_range_start"]:

View file

@ -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 { Button } from '@/components/ui/button';
import { import {
@ -11,11 +11,14 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import message from '@/components/ui/message'; import message from '@/components/ui/message';
import {
pollBoxWebAuthResult,
startBoxWebAuth,
} from '@/services/data-source-service';
import { Loader2 } from 'lucide-react';
export type BoxTokenFieldProps = { export type BoxTokenFieldProps = {
/** 存储在表单里的值,约定为 JSON 字符串 */
value?: string; value?: string;
/** 表单回写,用 JSON 字符串承载 client_id / client_secret / redirect_uri */
onChange: (value: any) => void; onChange: (value: any) => void;
placeholder?: string; placeholder?: string;
}; };
@ -24,8 +27,13 @@ type BoxCredentials = {
client_id?: string; client_id?: string;
client_secret?: string; client_secret?: string;
redirect_uri?: 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 => { const parseBoxCredentials = (content?: string): BoxCredentials | null => {
if (!content) return null; if (!content) return null;
try { try {
@ -34,25 +42,30 @@ const parseBoxCredentials = (content?: string): BoxCredentials | null => {
client_id: parsed.client_id, client_id: parsed.client_id,
client_secret: parsed.client_secret, client_secret: parsed.client_secret,
redirect_uri: parsed.redirect_uri, redirect_uri: parsed.redirect_uri,
authorization_code: parsed.authorization_code ?? parsed.code,
access_token: parsed.access_token,
refresh_token: parsed.refresh_token,
}; };
} catch { } catch {
return null; return null;
} }
}; };
const BoxTokenField = ({ const BoxTokenField = ({ value, onChange }: BoxTokenFieldProps) => {
value,
onChange,
placeholder,
}: BoxTokenFieldProps) => {
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [clientId, setClientId] = useState(''); const [clientId, setClientId] = useState('');
const [clientSecret, setClientSecret] = useState(''); const [clientSecret, setClientSecret] = useState('');
const [redirectUri, setRedirectUri] = useState(''); const [redirectUri, setRedirectUri] = useState('');
const [submitLoading, setSubmitLoading] = useState(false);
const [webFlowId, setWebFlowId] = useState<string | null>(null);
const webFlowIdRef = useRef<string | null>(null);
const webPollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [webStatus, setWebStatus] = useState<BoxAuthStatus>('idle');
const [webStatusMessage, setWebStatusMessage] = useState('');
const parsed = useMemo(() => parseBoxCredentials(value), [value]); const parsed = useMemo(() => parseBoxCredentials(value), [value]);
const parsedRedirectUri = useMemo(() => parsed?.redirect_uri ?? '', [parsed]);
// 当外部 value 变化且弹窗关闭时,同步初始值
useEffect(() => { useEffect(() => {
if (!dialogOpen) { if (!dialogOpen) {
setClientId(parsed?.client_id ?? ''); setClientId(parsed?.client_id ?? '');
@ -61,6 +74,18 @@ const BoxTokenField = ({
} }
}, [parsed, dialogOpen]); }, [parsed, dialogOpen]);
useEffect(() => {
webFlowIdRef.current = webFlowId;
}, [webFlowId]);
useEffect(() => {
return () => {
if (webPollTimerRef.current) {
clearTimeout(webPollTimerRef.current);
}
};
}, []);
const hasConfigured = useMemo( const hasConfigured = useMemo(
() => () =>
Boolean( Boolean(
@ -69,15 +94,161 @@ const BoxTokenField = ({
[parsed], [parsed],
); );
const handleOpenDialog = useCallback(() => { const hasAuthorized = useMemo(
setDialogOpen(true); () =>
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<string, any> = {
...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(() => { const handleCloseDialog = useCallback(() => {
setDialogOpen(false); 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()) { if (!clientId.trim() || !clientSecret.trim() || !redirectUri.trim()) {
message.error( message.error(
'Please fill in Client ID, Client Secret, and Redirect URI.', 'Please fill in Client ID, Client Secret, and Redirect URI.',
@ -85,31 +256,91 @@ const BoxTokenField = ({
return; return;
} }
const payload: BoxCredentials = { const trimmedClientId = clientId.trim();
client_id: clientId.trim(), const trimmedClientSecret = clientSecret.trim();
client_secret: clientSecret.trim(), const trimmedRedirectUri = redirectUri.trim();
redirect_uri: redirectUri.trim(),
const payloadForStorage: BoxCredentials = {
client_id: trimmedClientId,
client_secret: trimmedClientSecret,
redirect_uri: trimmedRedirectUri,
}; };
setSubmitLoading(true);
resetWebStatus();
clearWebState();
try { try {
onChange(JSON.stringify(payload)); const { data } = await startBoxWebAuth({
message.success('Box credentials saved locally.'); client_id: trimmedClientId,
setDialogOpen(false); client_secret: trimmedClientSecret,
} catch { redirect_uri: trimmedRedirectUri,
message.error('Failed to save Box credentials.'); });
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;
} }
}, [clientId, clientSecret, redirectUri, onChange]); 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);
}
}, [
clearWebState,
clientId,
clientSecret,
redirectUri,
resetWebStatus,
onChange,
]);
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{hasConfigured && ( {(hasConfigured || hasAuthorized) && (
<div className="flex flex-wrap items-center gap-2 rounded-md border border-dashed border-muted-foreground/40 bg-muted/20 px-3 py-2 text-xs text-muted-foreground"> <div className="flex flex-wrap items-center gap-3 rounded-md border border-dashed border-muted-foreground/40 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
<div className="flex flex-wrap items-center gap-2">
{hasAuthorized ? (
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-emerald-700"> <span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-emerald-700">
Authorized
</span>
) : null}
{hasConfigured ? (
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-blue-700">
Configured Configured
</span> </span>
) : null}
</div>
<p className="m-0"> <p className="m-0">
Box OAuth credentials have been configured. You can update them at {hasAuthorized
any time. ? 'Box OAuth credentials are authorized and ready to use.'
: 'Box OAuth client information has been stored. Run the browser authorization to finalize the setup.'}
</p> </p>
</div> </div>
)} )}
@ -143,7 +374,7 @@ const BoxTokenField = ({
<label className="text-sm font-medium">Client ID</label> <label className="text-sm font-medium">Client ID</label>
<Input <Input
value={clientId} value={clientId}
placeholder={placeholder || 'Enter Box Client ID'} placeholder="Enter Box Client ID"
onChange={(e) => setClientId(e.target.value)} onChange={(e) => setClientId(e.target.value)}
/> />
</div> </div>
@ -164,13 +395,49 @@ const BoxTokenField = ({
onChange={(e) => setRedirectUri(e.target.value)} onChange={(e) => setRedirectUri(e.target.value)}
/> />
</div> </div>
{webStatus !== 'idle' && (
<div className="rounded-md border border-dashed border-muted-foreground/40 bg-muted/10 px-4 py-4 text-sm text-muted-foreground">
<div className="text-sm font-semibold text-foreground">
Browser authorization
</div>
<p
className={`mt-2 text-xs ${
webStatus === 'error'
? 'text-destructive'
: 'text-muted-foreground'
}`}
>
{webStatusMessage}
</p>
{webStatus === 'waiting' && webFlowId ? (
<div className="mt-3 flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={handleManualWebCheck}
>
Refresh status
</Button>
</div>
) : null}
</div>
)}
</div> </div>
<DialogFooter className="pt-3"> <DialogFooter className="pt-3">
<Button variant="ghost" onClick={handleCloseDialog}> <Button
variant="ghost"
onClick={handleCloseDialog}
disabled={submitLoading}
>
Cancel Cancel
</Button> </Button>
<Button onClick={handleSubmit}>Submit</Button> <Button onClick={handleSubmit} disabled={submitLoading}>
{submitLoading && (
<Loader2 className="mr-2 size-4 animate-spin" />
)}
Submit & Authorize
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View file

@ -721,8 +721,7 @@ export const DataSourceFormDefaultValues = {
source: DataSourceKey.BOX, source: DataSourceKey.BOX,
config: { config: {
name: '', name: '',
folder_id: '', folder_id: '0',
index_recursively: false,
credentials: { credentials: {
box_tokens: '', box_tokens: '',
}, },

View file

@ -47,4 +47,13 @@ export const startGmailWebAuth = (payload: { credentials: string }) =>
export const pollGmailWebAuthResult = (payload: { flow_id: string }) => export const pollGmailWebAuthResult = (payload: { flow_id: string }) =>
request.post(api.googleWebAuthResult('gmail'), { data: payload }); 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; export default dataSourceService;

View file

@ -46,6 +46,8 @@ export default {
`${api_host}/connector/google/oauth/web/start?type=${type}`, `${api_host}/connector/google/oauth/web/start?type=${type}`,
googleWebAuthResult: (type: 'google-drive' | 'gmail') => googleWebAuthResult: (type: 'google-drive' | 'gmail') =>
`${api_host}/connector/google/oauth/web/result?type=${type}`, `${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 // plugin
llm_tools: `${api_host}/plugin/llm_tools`, llm_tools: `${api_host}/plugin/llm_tools`,