box connector_app
This commit is contained in:
parent
58205f62e9
commit
d263989317
5 changed files with 309 additions and 57 deletions
|
|
@ -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})
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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'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;
|
||||
|
|
@ -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: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue