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
@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})

View file

@ -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:

View file

@ -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"]:

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 {
@ -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<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 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<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(() => {
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 (
<div className="flex flex-col gap-3">
{hasConfigured && (
<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">
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-emerald-700">
Configured
</span>
{(hasConfigured || hasAuthorized) && (
<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">
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
</span>
) : null}
</div>
<p className="m-0">
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.'}
</p>
</div>
)}
@ -143,7 +374,7 @@ const BoxTokenField = ({
<label className="text-sm font-medium">Client ID</label>
<Input
value={clientId}
placeholder={placeholder || 'Enter Box Client ID'}
placeholder="Enter Box Client ID"
onChange={(e) => setClientId(e.target.value)}
/>
</div>
@ -164,13 +395,49 @@ const BoxTokenField = ({
onChange={(e) => setRedirectUri(e.target.value)}
/>
</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>
<DialogFooter className="pt-3">
<Button variant="ghost" onClick={handleCloseDialog}>
<Button
variant="ghost"
onClick={handleCloseDialog}
disabled={submitLoading}
>
Cancel
</Button>
<Button onClick={handleSubmit}>Submit</Button>
<Button onClick={handleSubmit} disabled={submitLoading}>
{submitLoading && (
<Loader2 className="mr-2 size-4 animate-spin" />
)}
Submit & Authorize
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View file

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

View file

@ -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;

View file

@ -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`,