box connector_app

This commit is contained in:
Billy Bao 2025-12-11 11:53:43 +08:00
parent 58205f62e9
commit d263989317
5 changed files with 309 additions and 57 deletions

View file

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

View file

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

View file

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

View file

@ -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 (
<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>
<p className="m-0">
Box OAuth credentials have been configured. You can update them at
any time.
</p>
</div>
)}
<Button variant="outline" onClick={handleOpenDialog}>
{hasConfigured ? 'Edit Box credentials' : 'Configure Box credentials'}
</Button>
<Dialog
open={dialogOpen}
onOpenChange={(open) =>
!open ? handleCloseDialog() : setDialogOpen(true)
}
>
<DialogContent
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>Configure Box OAuth credentials</DialogTitle>
<DialogDescription>
Enter your Box application&apos;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.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 pt-2">
<div className="space-y-1">
<label className="text-sm font-medium">Client ID</label>
<Input
value={clientId}
placeholder={placeholder || 'Enter Box Client ID'}
onChange={(e) => setClientId(e.target.value)}
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Client Secret</label>
<Input
type="password"
value={clientSecret}
placeholder="Enter Box Client Secret"
onChange={(e) => setClientSecret(e.target.value)}
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Redirect URI</label>
<Input
value={redirectUri}
placeholder="https://example.com/box/oauth/callback"
onChange={(e) => setRedirectUri(e.target.value)}
/>
</div>
</div>
<DialogFooter className="pt-3">
<Button variant="ghost" onClick={handleCloseDialog}>
Cancel
</Button>
<Button onClick={handleSubmit}>Submit</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
export default BoxTokenField;

View file

@ -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) => (
<BoxTokenField
value={fieldProps.value}
onChange={fieldProps.onChange}
placeholder='{ "client_id": "...", "client_secret": "...", "redirect_uri": "..." }'
/>
),
},
{
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: '',
},
},
},