Feat: Add box connector (#11845)

### What problem does this PR solve?

Feat: Add box connector

### Type of change

- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
Magicbook1108
2025-12-12 10:23:40 +08:00
committed by GitHub
parent a6bd765a02
commit 7db9045b74
19 changed files with 1019 additions and 131 deletions

View File

@ -28,7 +28,6 @@ from api.db.services import UserService
from api.utils.json_encode import CustomJSONEncoder
from api.utils import commands
from flask_mail import Mail
from quart_auth import Unauthorized
from common import settings
from api.utils.api_utils import server_error_response
@ -42,7 +41,6 @@ __all__ = ["app"]
app = Quart(__name__)
app = cors(app, allow_origin="*")
smtp_mail_server = Mail()
# Add this at the beginning of your file to configure Swagger UI
swagger_config = {

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,107 @@ 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
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(
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,
"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)
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 flow_id:
return await _render_web_oauth_popup("", False, "Missing OAuth parameters.", "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.")
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")
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("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)
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_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

@ -13,7 +13,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
import logging
import asyncio
from api.db import UserTenantRole
from api.db.db_models import UserTenant
from api.db.services.user_service import UserTenantService, UserService
@ -24,7 +25,7 @@ from common.time_utils import delta_seconds
from api.utils.api_utils import get_data_error_result, get_json_result, get_request_json, server_error_response, validate_request
from api.utils.web_utils import send_invite_email
from common import settings
from api.apps import smtp_mail_server, login_required, current_user
from api.apps import login_required, current_user
@manager.route("/<tenant_id>/user/list", methods=["GET"]) # noqa: F821
@ -80,20 +81,24 @@ async def create(tenant_id):
role=UserTenantRole.INVITE,
status=StatusEnum.VALID.value)
if smtp_mail_server and settings.SMTP_CONF:
from threading import Thread
try:
user_name = ""
_, user = UserService.get_by_id(current_user.id)
if user:
user_name = user.nickname
Thread(
target=send_invite_email,
args=(invite_user_email, settings.MAIL_FRONTEND_URL, tenant_id, user_name or current_user.email),
daemon=True
).start()
asyncio.create_task(
send_invite_email(
to_email=invite_user_email,
invite_url=settings.MAIL_FRONTEND_URL,
tenant_id=tenant_id,
inviter=user_name or current_user.email
)
)
except Exception as e:
logging.exception(f"Failed to send invite email to {invite_user_email}: {e}")
return get_json_result(data=False, message="Failed to send invite email.", code=RetCode.SERVER_ERROR)
usr = invite_users[0].to_dict()
usr = {k: v for k, v in usr.items() if k in ["id", "avatar", "email", "nickname"]}

View File

@ -22,7 +22,7 @@ import secrets
import time
from datetime import datetime
from quart import redirect, request, session, make_response
from quart import redirect, request, session
from werkzeug.security import check_password_hash, generate_password_hash
from api.apps.auth import get_auth_client
@ -45,7 +45,7 @@ from api.utils.api_utils import (
)
from api.utils.crypt import decrypt
from rag.utils.redis_conn import REDIS_CONN
from api.apps import smtp_mail_server, login_required, current_user, login_user, logout_user
from api.apps import login_required, current_user, login_user, logout_user
from api.utils.web_utils import (
send_email_html,
OTP_LENGTH,
@ -868,9 +868,12 @@ async def forget_get_captcha():
from captcha.image import ImageCaptcha
image = ImageCaptcha(width=300, height=120, font_sizes=[50, 60, 70])
img_bytes = image.generate(captcha_text).read()
response = await make_response(img_bytes)
response.headers.set("Content-Type", "image/JPEG")
return response
import base64
base64_img = base64.b64encode(img_bytes).decode('utf-8')
data_uri = f"data:image/jpeg;base64,{base64_img}"
return get_json_result(data=data_uri)
@manager.route("/forget/otp", methods=["POST"]) # noqa: F821
@ -923,47 +926,58 @@ async def forget_send_otp():
ttl_min = OTP_TTL_SECONDS // 60
if not smtp_mail_server:
logging.warning("SMTP mail server not initialized; skip sending email.")
else:
try:
send_email_html(
subject="Your Password Reset Code",
to_email=email,
template_key="reset_code",
code=otp,
ttl_min=ttl_min,
)
except Exception:
return get_json_result(data=False, code=RetCode.SERVER_ERROR, message="failed to send email")
try:
await send_email_html(
subject="Your Password Reset Code",
to_email=email,
template_key="reset_code",
code=otp,
ttl_min=ttl_min,
)
except Exception as e:
logging.exception(e)
return get_json_result(data=False, code=RetCode.SERVER_ERROR, message="failed to send email")
return get_json_result(data=True, code=RetCode.SUCCESS, message="verification passed, email sent")
@manager.route("/forget", methods=["POST"]) # noqa: F821
async def forget():
"""
POST: Verify email + OTP and reset password, then log the user in.
Request JSON: { email, otp, new_password, confirm_new_password }
Deprecated single-step reset endpoint.
Use /forget/verify-otp then /forget/reset-password.
"""
return get_json_result(
data=False,
code=RetCode.NOT_EFFECTIVE,
message="Use /forget/verify-otp then /forget/reset-password",
)
def _verified_key(email: str) -> str:
return f"otp:verified:{email}"
@manager.route("/forget/verify-otp", methods=["POST"]) # noqa: F821
async def forget_verify_otp():
"""
Verify email + OTP only. On success:
- consume the OTP and attempt counters
- set a short-lived verified flag in Redis for the email
Request JSON: { email, otp }
"""
req = await get_request_json()
email = req.get("email") or ""
otp = (req.get("otp") or "").strip()
new_pwd = req.get("new_password")
new_pwd2 = req.get("confirm_new_password")
if not all([email, otp, new_pwd, new_pwd2]):
return get_json_result(data=False, code=RetCode.ARGUMENT_ERROR, message="email, otp and passwords are required")
# For reset, passwords are provided as-is (no decrypt needed)
if new_pwd != new_pwd2:
return get_json_result(data=False, code=RetCode.ARGUMENT_ERROR, message="passwords do not match")
if not all([email, otp]):
return get_json_result(data=False, code=RetCode.ARGUMENT_ERROR, message="email and otp are required")
users = UserService.query(email=email)
if not users:
return get_json_result(data=False, code=RetCode.DATA_ERROR, message="invalid email")
user = users[0]
# Verify OTP from Redis
k_code, k_attempts, k_last, k_lock = otp_keys(email)
if REDIS_CONN.get(k_lock):
@ -979,7 +993,6 @@ async def forget():
except Exception:
return get_json_result(data=False, code=RetCode.EXCEPTION_ERROR, message="otp storage corrupted")
# Case-insensitive verification: OTP generated uppercase
calc = hash_code(otp.upper(), salt)
if calc != stored_hash:
# bump attempts
@ -992,23 +1005,72 @@ async def forget():
REDIS_CONN.set(k_lock, int(time.time()), ATTEMPT_LOCK_SECONDS)
return get_json_result(data=False, code=RetCode.AUTHENTICATION_ERROR, message="expired otp")
# Success: consume OTP and reset password
# Success: consume OTP and attempts; mark verified
REDIS_CONN.delete(k_code)
REDIS_CONN.delete(k_attempts)
REDIS_CONN.delete(k_last)
REDIS_CONN.delete(k_lock)
# set verified flag with limited TTL, reuse OTP_TTL_SECONDS or smaller window
try:
REDIS_CONN.set(_verified_key(email), "1", OTP_TTL_SECONDS)
except Exception:
return get_json_result(data=False, code=RetCode.SERVER_ERROR, message="failed to set verification state")
return get_json_result(data=True, code=RetCode.SUCCESS, message="otp verified")
@manager.route("/forget/reset-password", methods=["POST"]) # noqa: F821
async def forget_reset_password():
"""
Reset password after successful OTP verification.
Requires: { email, new_password, confirm_new_password }
Steps:
- check verified flag in Redis
- update user password
- auto login
- clear verified flag
"""
req = await get_request_json()
email = req.get("email") or ""
new_pwd = req.get("new_password")
new_pwd2 = req.get("confirm_new_password")
if not all([email, new_pwd, new_pwd2]):
return get_json_result(data=False, code=RetCode.ARGUMENT_ERROR, message="email and passwords are required")
if new_pwd != new_pwd2:
return get_json_result(data=False, code=RetCode.ARGUMENT_ERROR, message="passwords do not match")
users = UserService.query(email=email)
if not users:
return get_json_result(data=False, code=RetCode.DATA_ERROR, message="invalid email")
user = users[0]
try:
UserService.update_user_password(user.id, new_pwd)
except Exception as e:
logging.exception(e)
return get_json_result(data=False, code=RetCode.EXCEPTION_ERROR, message="failed to reset password")
# Auto login (reuse login flow)
user.access_token = get_uuid()
login_user(user)
user.update_time = current_timestamp()
user.update_date = datetime_format(datetime.now())
user.save()
# login
try:
user.access_token = get_uuid()
login_user(user)
user.update_time = current_timestamp()
user.update_date = datetime_format(datetime.now())
user.save()
except Exception as e:
logging.exception(e)
return get_json_result(data=False, code=RetCode.EXCEPTION_ERROR, message="failed to login after reset")
# clear verified flag
try:
REDIS_CONN.delete(_verified_key(email))
except Exception:
pass
msg = "Password reset successful. Logged in."
return await construct_response(data=user.to_json(), auth=user.get_id(), message=msg)

View File

@ -30,7 +30,7 @@ import threading
import uuid
import faulthandler
from api.apps import app, smtp_mail_server
from api.apps import app
from api.db.runtime_config import RuntimeConfig
from api.db.services.document_service import DocumentService
from common.file_utils import get_project_base_directory
@ -143,18 +143,6 @@ if __name__ == '__main__':
else:
threading.Timer(1.0, delayed_start_update_progress).start()
# init smtp server
if settings.SMTP_CONF:
app.config["MAIL_SERVER"] = settings.MAIL_SERVER
app.config["MAIL_PORT"] = settings.MAIL_PORT
app.config["MAIL_USE_SSL"] = settings.MAIL_USE_SSL
app.config["MAIL_USE_TLS"] = settings.MAIL_USE_TLS
app.config["MAIL_USERNAME"] = settings.MAIL_USERNAME
app.config["MAIL_PASSWORD"] = settings.MAIL_PASSWORD
app.config["MAIL_DEFAULT_SENDER"] = settings.MAIL_DEFAULT_SENDER
smtp_mail_server.init_app(app)
# start http server
try:
logging.info("RAGFlow HTTP server start...")

View File

@ -20,18 +20,18 @@ Reusable HTML email templates and registry.
# Invitation email template
INVITE_EMAIL_TMPL = """
<p>Hi {{email}},</p>
<p>{{inviter}} has invited you to join their team (ID: {{tenant_id}}).</p>
<p>Click the link below to complete your registration:<br>
<a href="{{invite_url}}">{{invite_url}}</a></p>
<p>If you did not request this, please ignore this email.</p>
Hi {{email}},
{{inviter}} has invited you to join their team (ID: {{tenant_id}}).
Click the link below to complete your registration:
{{invite_url}}
If you did not request this, please ignore this email.
"""
# Password reset code template
RESET_CODE_EMAIL_TMPL = """
<p>Hello,</p>
<p>Your password reset code is: <b>{{ code }}</b></p>
<p>This code will expire in {{ ttl_min }} minutes.</p>
Hello,
Your password reset code is: {{ code }}
This code will expire in {{ ttl_min }} minutes.
"""
# Template registry

View File

@ -20,9 +20,10 @@ import json
import re
import socket
from urllib.parse import urlparse
from api.apps import smtp_mail_server
from flask_mail import Message
import aiosmtplib
from email.mime.text import MIMEText
from email.header import Header
from common import settings
from quart import render_template_string
from api.utils.email_templates import EMAIL_TEMPLATES
from selenium import webdriver
@ -36,10 +37,10 @@ from webdriver_manager.chrome import ChromeDriverManager
OTP_LENGTH = 8
OTP_TTL_SECONDS = 5 * 60
ATTEMPT_LIMIT = 5
ATTEMPT_LOCK_SECONDS = 30 * 60
RESEND_COOLDOWN_SECONDS = 60
OTP_TTL_SECONDS = 5 * 60 # valid for 5 minutes
ATTEMPT_LIMIT = 5 # maximum attempts
ATTEMPT_LOCK_SECONDS = 30 * 60 # lock for 30 minutes
RESEND_COOLDOWN_SECONDS = 60 # cooldown for 1 minute
CONTENT_TYPE_MAP = {
@ -183,27 +184,34 @@ def get_float(req: dict, key: str, default: float | int = 10.0) -> float:
return parsed if parsed > 0 else default
except (TypeError, ValueError):
return default
async def send_email_html(to_email: str, subject: str, template_key: str, **context):
body = await render_template_string(EMAIL_TEMPLATES.get(template_key), **context)
msg = MIMEText(body, "plain", "utf-8")
msg["Subject"] = Header(subject, "utf-8")
msg["From"] = f"{settings.MAIL_DEFAULT_SENDER[0]} <{settings.MAIL_DEFAULT_SENDER[1]}>"
msg["To"] = to_email
smtp = aiosmtplib.SMTP(
hostname=settings.MAIL_SERVER,
port=settings.MAIL_PORT,
use_tls=True,
timeout=10,
)
await smtp.connect()
await smtp.login(settings.MAIL_USERNAME, settings.MAIL_PASSWORD)
await smtp.send_message(msg)
await smtp.quit()
def send_email_html(subject: str, to_email: str, template_key: str, **context):
"""Generic HTML email sender using shared templates.
template_key must exist in EMAIL_TEMPLATES.
"""
from api.apps import app
tmpl = EMAIL_TEMPLATES.get(template_key)
if not tmpl:
raise ValueError(f"Unknown email template: {template_key}")
with app.app_context():
msg = Message(subject=subject, recipients=[to_email])
msg.html = render_template_string(tmpl, **context)
smtp_mail_server.send(msg)
def send_invite_email(to_email, invite_url, tenant_id, inviter):
async def send_invite_email(to_email, invite_url, tenant_id, inviter):
# Reuse the generic HTML sender with 'invite' template
send_email_html(
subject="RAGFlow Invitation",
await send_email_html(
to_email=to_email,
subject="RAGFlow Invitation",
template_key="invite",
email=to_email,
invite_url=invite_url,
@ -230,4 +238,4 @@ def hash_code(code: str, salt: bytes) -> str:
def captcha_key(email: str) -> str:
return f"captcha:{email}"

View File

@ -123,7 +123,7 @@ class FileSource(StrEnum):
WEBDAV = "webdav"
MOODLE = "moodle"
DROPBOX = "dropbox"
BOX = "box"
class PipelineTaskType(StrEnum):
PARSE = "Parse"

View File

@ -0,0 +1,162 @@
"""Box connector"""
import logging
from datetime import datetime, timezone
from typing import Any
from box_sdk_gen import BoxClient
from common.data_source.config import DocumentSource, INDEX_BATCH_SIZE
from common.data_source.exceptions import (
ConnectorMissingCredentialError,
ConnectorValidationError,
)
from common.data_source.interfaces import LoadConnector, PollConnector, SecondsSinceUnixEpoch
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, batch_size: int = INDEX_BATCH_SIZE, use_marker: bool = True) -> None:
self.batch_size = batch_size
self.folder_id = "0" if not folder_id else folder_id
self.use_marker = use_marker
def load_credentials(self, auth: Any):
self.box_client = BoxClient(auth=auth)
return None
def validate_connector_settings(self):
if self.box_client is None:
raise ConnectorMissingCredentialError("Box")
try:
self.box_client.users.get_user_me()
except Exception as e:
logging.exception("[Box]: Failed to validate Box credentials")
raise ConnectorValidationError(f"Unexpected error during Box settings validation: {e}")
def _yield_files_recursive(
self,
folder_id,
start: SecondsSinceUnixEpoch | None,
end: SecondsSinceUnixEpoch | None
) -> GenerateDocumentsOutput:
if self.box_client is None:
raise ConnectorMissingCredentialError("Box")
result = self.box_client.folders.get_folder_items(
folder_id=folder_id,
limit=self.batch_size,
usemarker=self.use_marker
)
while True:
batch: list[Document] = []
for entry in result.entries:
if entry.type == 'file' :
file = self.box_client.files.get_file_by_id(
entry.id
)
raw_time = (
getattr(file, "created_at", None)
or getattr(file, "content_created_at", None)
)
if raw_time:
modified_time = self._box_datetime_to_epoch_seconds(raw_time)
if start is not None and modified_time <= start:
continue
if end is not None and modified_time > end:
continue
content_bytes = self.box_client.downloads.download_file(file.id)
batch.append(
Document(
id=f"box:{file.id}",
blob=content_bytes.read(),
source=DocumentSource.BOX,
semantic_identifier=file.name,
extension=get_file_ext(file.name),
doc_updated_at=modified_time,
size_bytes=file.size,
metadata=file.metadata
)
)
elif entry.type == 'folder':
yield from self._yield_files_recursive(folder_id=entry.id, start=start, end=end)
if batch:
yield batch
if not result.next_marker:
break
result = self.box_client.folders.get_folder_items(
folder_id=folder_id,
limit=self.batch_size,
marker=result.next_marker,
usemarker=True
)
def _box_datetime_to_epoch_seconds(self, dt: datetime) -> SecondsSinceUnixEpoch:
"""Convert a Box SDK datetime to Unix epoch seconds (UTC).
Only supports datetime; any non-datetime should be filtered out by caller.
"""
if not isinstance(dt, datetime):
raise TypeError(f"box_datetime_to_epoch_seconds expects datetime, got {type(dt)}")
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
else:
dt = dt.astimezone(timezone.utc)
return SecondsSinceUnixEpoch(int(dt.timestamp()))
def poll_source(self, start, end):
return self._yield_files_recursive(folder_id=self.folder_id, start=start, end=end)
def load_from_state(self):
return self._yield_files_recursive(folder_id=self.folder_id, start=None, end=None)
# from flask import Flask, request, redirect
# from box_sdk_gen import BoxClient, BoxOAuth, OAuthConfig, GetAuthorizeUrlOptions
# app = Flask(__name__)
# AUTH = BoxOAuth(
# OAuthConfig(client_id="8suvn9ik7qezsq2dub0ye6ubox61081z", client_secret="QScvhLgBcZrb2ck1QP1ovkutpRhI2QcN")
# )
# @app.route("/")
# def get_auth():
# auth_url = AUTH.get_authorize_url(
# options=GetAuthorizeUrlOptions(redirect_uri="http://localhost:4999/oauth2callback")
# )
# return redirect(auth_url, code=302)
# @app.route("/oauth2callback")
# def callback():
# AUTH.get_tokens_authorization_code_grant(request.args.get("code"))
# box = BoxConnector()
# box.load_credentials({"auth": AUTH})
# lst = []
# for file in box.load_from_state():
# for f in file:
# lst.append(f.semantic_identifier)
# return lst
if __name__ == "__main__":
pass
# app.run(port=4999)

View File

@ -52,7 +52,7 @@ class DocumentSource(str, Enum):
MOODLE = "moodle"
S3_COMPATIBLE = "s3_compatible"
DROPBOX = "dropbox"
BOX = "box"
class FileOrigin(str, Enum):
"""File origins"""
@ -227,6 +227,7 @@ _DEFAULT_PAGINATION_LIMIT = 1000
_PROBLEMATIC_EXPANSIONS = "body.storage.value"
_REPLACEMENT_EXPANSIONS = "body.view.value"
BOX_WEB_OAUTH_REDIRECT_URI = os.environ.get("BOX_WEB_OAUTH_REDIRECT_URI", "http://localhost:9380/v1/connector/box/oauth/web/callback")
class HtmlBasedConnectorTransformLinksStrategy(str, Enum):
# remove links entirely

View File

@ -49,7 +49,7 @@ MISSING_SCOPES_ERROR_STR = "client not authorized for any of the scopes requeste
SCOPE_INSTRUCTIONS = ""
GOOGLE_WEB_OAUTH_POPUP_TEMPLATE = """<!DOCTYPE html>
WEB_OAUTH_POPUP_TEMPLATE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />

View File

@ -154,6 +154,8 @@ dependencies = [
"exceptiongroup>=1.3.0,<2.0.0",
"ffmpeg-python>=0.2.0",
"imageio-ffmpeg>=0.6.0",
"boxsdk>=10.1.0",
"aiosmtplib>=5.0.0",
]
[dependency-groups]

View File

@ -32,6 +32,8 @@ import traceback
from datetime import datetime, timezone
from typing import Any
from flask import json
from api.db.services.connector_service import ConnectorService, SyncLogsService
from api.db.services.knowledgebase_service import KnowledgebaseService
from common import settings
@ -41,11 +43,13 @@ from common.constants import FileSource, TaskStatus
from common.data_source.config import INDEX_BATCH_SIZE
from common.data_source.confluence_connector import ConfluenceConnector
from common.data_source.gmail_connector import GmailConnector
from common.data_source.box_connector import BoxConnector
from common.data_source.interfaces import CheckpointOutputWrapper
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 BoxOAuth, OAuthConfig, AccessToken
MAX_CONCURRENT_TASKS = int(os.environ.get("MAX_CONCURRENT_TASKS", "5"))
task_limiter = asyncio.Semaphore(MAX_CONCURRENT_TASKS)
@ -93,7 +97,7 @@ class SyncBase:
if task["poll_range_start"]:
next_update = task["poll_range_start"]
async for document_batch in document_batch_generator: # 如果是 async generator
for document_batch in document_batch_generator:
if not document_batch:
continue
@ -625,6 +629,47 @@ class Moodle(SyncBase):
return document_generator
class BOX(SyncBase):
SOURCE_NAME: str = FileSource.BOX
async def _generate(self, task: dict):
self.connector = BoxConnector(
folder_id=self.conf.get("folder_id", "0"),
)
credential = json.loads(self.conf['credentials']['box_tokens'])
auth = BoxOAuth(
OAuthConfig(
client_id=credential['client_id'],
client_secret=credential['client_secret'],
)
)
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"]:
document_generator = self.connector.load_from_state()
begin_info = "totally"
else:
poll_start = task["poll_range_start"]
if poll_start is None:
document_generator = self.connector.load_from_state()
begin_info = "totally"
else:
document_generator = self.connector.poll_source(
poll_start.timestamp(),
datetime.now(timezone.utc).timestamp()
)
begin_info = "from {}".format(poll_start)
logging.info("Connect to Box: folder_id({}) {}".format(self.conf["folder_id"], begin_info))
return document_generator
func_factory = {
FileSource.S3: S3,
FileSource.NOTION: Notion,
@ -639,6 +684,7 @@ func_factory = {
FileSource.MOODLE: Moodle,
FileSource.DROPBOX: Dropbox,
FileSource.WEBDAV: WebDAV,
FileSource.BOX: BOX
}

38
uv.lock generated
View File

@ -150,6 +150,15 @@ wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
]
[[package]]
name = "aiosmtplib"
version = "5.0.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a2/15/c2dc93a58d716bce64b53918d3cf667d86c96a56a9f3a239a9f104643637/aiosmtplib-5.0.0.tar.gz", hash = "sha256:514ac11c31cb767c764077eb3c2eb2ae48df6f63f1e847aeb36119c4fc42b52d", size = 61057, upload-time = "2025-10-19T19:12:31.426Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/99/42/b997c306dc54e6ac62a251787f6b5ec730797eea08e0336d8f0d7b899d5f/aiosmtplib-5.0.0-py3-none-any.whl", hash = "sha256:95eb0f81189780845363ab0627e7f130bca2d0060d46cd3eeb459f066eb7df32", size = 27048, upload-time = "2025-10-19T19:12:30.124Z" },
]
[[package]]
name = "aiosqlite"
version = "0.21.0"
@ -624,6 +633,19 @@ wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f5/23/91c8b50588470d80317f4afca93d3d542139bdc38ed5ad1b512fba416af3/botocore-1.34.140-py3-none-any.whl", hash = "sha256:43940d3a67d946ba3301631ba4078476a75f1015d4fb0fb0272d0b754b2cf9de", size = 12354845, upload-time = "2024-07-05T19:19:10.578Z" },
]
[[package]]
name = "boxsdk"
version = "10.1.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "requests" },
{ name = "requests-toolbelt" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/44/a6/b7afb8ee4745b61470322ada33c0463b26d221367bc23c8e8c7d4b7b6cbe/boxsdk-10.1.0.tar.gz", hash = "sha256:fb409b682d173eeb9a72c03ca0dddf2e66dbd79199235815a2ad61bf39c4f231", size = 265229, upload-time = "2025-11-19T11:32:01.438Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/bf/49/bf3b693025471431ab89252f287e2203394ef0965450af3fd1b995d39646/boxsdk-10.1.0-py3-none-any.whl", hash = "sha256:2770aa7111fdd6a14a6e6447ca2f3eeb306ed123b210368368e8ac938cfb7813", size = 556301, upload-time = "2025-11-19T11:32:00.168Z" },
]
[[package]]
name = "brotli"
version = "1.2.0"
@ -4221,12 +4243,12 @@ name = "onnxruntime-gpu"
version = "1.23.2"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "coloredlogs" },
{ name = "flatbuffers" },
{ name = "numpy" },
{ name = "packaging" },
{ name = "protobuf" },
{ name = "sympy" },
{ name = "coloredlogs", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
{ name = "flatbuffers", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
{ name = "numpy", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
{ name = "packaging", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
{ name = "protobuf", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
{ name = "sympy", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
]
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/6c/d9/b7140a4f1615195938c7e358c0804bb84271f0d6886b5cbf105c6cb58aae/onnxruntime_gpu-1.23.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f2d1f720685d729b5258ec1b36dee1de381b8898189908c98cbeecdb2f2b5c2", size = 300509596, upload-time = "2025-10-22T16:56:31.728Z" },
@ -5899,6 +5921,7 @@ name = "ragflow"
version = "0.22.1"
source = { virtual = "." }
dependencies = [
{ name = "aiosmtplib" },
{ name = "akshare" },
{ name = "anthropic" },
{ name = "arxiv" },
@ -5912,6 +5935,7 @@ dependencies = [
{ name = "blinker" },
{ name = "boto3" },
{ name = "botocore" },
{ name = "boxsdk" },
{ name = "cachetools" },
{ name = "captcha" },
{ name = "chardet" },
@ -6066,6 +6090,7 @@ test = [
[package.metadata]
requires-dist = [
{ name = "aiosmtplib", specifier = ">=5.0.0" },
{ name = "akshare", specifier = ">=1.15.78,<2.0.0" },
{ name = "anthropic", specifier = "==0.34.1" },
{ name = "arxiv", specifier = "==2.1.3" },
@ -6079,6 +6104,7 @@ requires-dist = [
{ name = "blinker", specifier = "==1.7.0" },
{ name = "boto3", specifier = "==1.34.140" },
{ name = "botocore", specifier = "==1.34.140" },
{ name = "boxsdk", specifier = ">=10.1.0" },
{ name = "cachetools", specifier = "==5.3.3" },
{ name = "captcha", specifier = ">=0.7.1" },
{ name = "chardet", specifier = "==5.2.0" },

View File

@ -0,0 +1 @@
<svg width="41" height="22" xmlns="http://www.w3.org/2000/svg"><path d="M39.7 19.2c.5.7.4 1.6-.2 2.1-.7.5-1.7.4-2.2-.2l-3.5-4.5-3.4 4.4c-.5.7-1.5.7-2.2.2-.7-.5-.8-1.4-.3-2.1l4-5.2-4-5.2c-.5-.7-.3-1.7.3-2.2.7-.5 1.7-.3 2.2.3l3.4 4.5L37.3 7c.5-.7 1.4-.8 2.2-.3.7.5.7 1.5.2 2.2L35.8 14l3.9 5.2zm-18.2-.6c-2.6 0-4.7-2-4.7-4.6 0-2.5 2.1-4.6 4.7-4.6s4.7 2.1 4.7 4.6c-.1 2.6-2.2 4.6-4.7 4.6zm-13.8 0c-2.6 0-4.7-2-4.7-4.6 0-2.5 2.1-4.6 4.7-4.6s4.7 2.1 4.7 4.6c0 2.6-2.1 4.6-4.7 4.6zM21.5 6.4c-2.9 0-5.5 1.6-6.8 4-1.3-2.4-3.9-4-6.9-4-1.8 0-3.4.6-4.7 1.5V1.5C3.1.7 2.4 0 1.6 0 .7 0 0 .7 0 1.5v12.6c.1 4.2 3.5 7.5 7.7 7.5 3 0 5.6-1.7 6.9-4.1 1.3 2.4 3.9 4.1 6.8 4.1 4.3 0 7.8-3.4 7.8-7.7.1-4.1-3.4-7.5-7.7-7.5z" fill="#0071F7"/></svg>

After

Width:  |  Height:  |  Size: 723 B

View File

@ -0,0 +1,448 @@
import { useCallback, useEffect, useMemo, useRef, 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';
import {
pollBoxWebAuthResult,
startBoxWebAuth,
} from '@/services/data-source-service';
import { Loader2 } from 'lucide-react';
export type BoxTokenFieldProps = {
value?: string;
onChange: (value: any) => void;
placeholder?: string;
};
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 {
const parsed = JSON.parse(content);
return {
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 }: 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]);
useEffect(() => {
if (!dialogOpen) {
setClientId(parsed?.client_id ?? '');
setClientSecret(parsed?.client_secret ?? '');
setRedirectUri(parsed?.redirect_uri ?? '');
}
}, [parsed, dialogOpen]);
useEffect(() => {
webFlowIdRef.current = webFlowId;
}, [webFlowId]);
useEffect(() => {
return () => {
if (webPollTimerRef.current) {
clearTimeout(webPollTimerRef.current);
}
};
}, []);
const hasConfigured = useMemo(
() =>
Boolean(
parsed?.client_id && parsed?.client_secret && parsed?.redirect_uri,
),
[parsed],
);
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 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.',
);
return;
}
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 {
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);
}
}, [
clearWebState,
clientId,
clientSecret,
redirectUri,
resetWebStatus,
onChange,
]);
return (
<div className="flex flex-col gap-3">
{(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">
{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>
)}
<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="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>
{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}
disabled={submitLoading}
>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={submitLoading}>
{submitLoading && (
<Loader2 className="mr-2 size-4 animate-spin" />
)}
Submit & Authorize
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
export default BoxTokenField;

View File

@ -1,10 +1,11 @@
import { FormFieldType } from '@/components/dynamic-form';
import SvgIcon from '@/components/svg-icon';
import { t } from 'i18next';
import { ControllerRenderProps } from 'react-hook-form';
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';
export enum DataSourceKey {
CONFLUENCE = 'confluence',
S3 = 's3',
@ -15,6 +16,7 @@ export enum DataSourceKey {
GMAIL = 'gmail',
JIRA = 'jira',
WEBDAV = 'webdav',
BOX = 'box',
DROPBOX = 'dropbox',
// SHAREPOINT = 'sharepoint',
// SLACK = 'slack',
@ -72,6 +74,11 @@ export const DataSourceInfo = {
description: t(`setting.${DataSourceKey.DROPBOX}Description`),
icon: <SvgIcon name={'data-source/dropbox'} width={38} />,
},
[DataSourceKey.BOX]: {
name: 'Box',
description: t(`setting.${DataSourceKey.BOX}Description`),
icon: <SvgIcon name={'data-source/box'} width={38} />,
},
};
export const DataSourceFormBaseFields = [
@ -234,11 +241,11 @@ export const DataSourceFormFields = {
{
label: 'Index Method',
name: 'config.index_mode',
type: FormFieldType.Text, // keep as text so RHF registers it
type: FormFieldType.Text,
required: false,
horizontal: true,
labelClassName: 'self-start pt-4',
render: (fieldProps: ControllerRenderProps) => (
render: (fieldProps: any) => (
<ConfluenceIndexingModeField {...fieldProps} />
),
},
@ -551,6 +558,28 @@ export const DataSourceFormFields = {
placeholder: 'Defaults to 2',
},
],
[DataSourceKey.BOX]: [
{
label: 'Box OAuth JSON',
name: 'config.credentials.box_tokens',
type: FormFieldType.Textarea,
required: true,
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 root',
},
],
};
export const DataSourceFormDefaultValues = {
@ -687,4 +716,15 @@ export const DataSourceFormDefaultValues = {
},
},
},
[DataSourceKey.BOX]: {
name: '',
source: DataSourceKey.BOX,
config: {
name: '',
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`,