diff --git a/api/apps/__init__.py b/api/apps/__init__.py index 9ef2f97d9..63de1fbf3 100644 --- a/api/apps/__init__.py +++ b/api/apps/__init__.py @@ -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 = { diff --git a/api/apps/connector_app.py b/api/apps/connector_app.py index 44d3a3344..fb074419b 100644 --- a/api/apps/connector_app.py +++ b/api/apps/connector_app.py @@ -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}) \ No newline at end of file diff --git a/api/apps/tenant_app.py b/api/apps/tenant_app.py index fdb764e65..be6305e89 100644 --- a/api/apps/tenant_app.py +++ b/api/apps/tenant_app.py @@ -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("//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"]} diff --git a/api/apps/user_app.py b/api/apps/user_app.py index 78407b242..fde8a7af5 100644 --- a/api/apps/user_app.py +++ b/api/apps/user_app.py @@ -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) + + diff --git a/api/ragflow_server.py b/api/ragflow_server.py index 59622fe68..26cd045c4 100644 --- a/api/ragflow_server.py +++ b/api/ragflow_server.py @@ -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...") diff --git a/api/utils/email_templates.py b/api/utils/email_templates.py index 34201ee38..767eb7b92 100644 --- a/api/utils/email_templates.py +++ b/api/utils/email_templates.py @@ -20,18 +20,18 @@ Reusable HTML email templates and registry. # Invitation email template INVITE_EMAIL_TMPL = """ -

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.

+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 = """ -

Hello,

-

Your password reset code is: {{ code }}

-

This code will expire in {{ ttl_min }} minutes.

+Hello, +Your password reset code is: {{ code }} +This code will expire in {{ ttl_min }} minutes. """ # Template registry diff --git a/api/utils/web_utils.py b/api/utils/web_utils.py index 0f2484c56..cba87535f 100644 --- a/api/utils/web_utils.py +++ b/api/utils/web_utils.py @@ -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}" - + \ No newline at end of file diff --git a/common/constants.py b/common/constants.py index 98e9faf36..498ca284e 100644 --- a/common/constants.py +++ b/common/constants.py @@ -123,7 +123,7 @@ class FileSource(StrEnum): WEBDAV = "webdav" MOODLE = "moodle" DROPBOX = "dropbox" - + BOX = "box" class PipelineTaskType(StrEnum): PARSE = "Parse" diff --git a/common/data_source/box_connector.py b/common/data_source/box_connector.py new file mode 100644 index 000000000..3006e709c --- /dev/null +++ b/common/data_source/box_connector.py @@ -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) \ No newline at end of file diff --git a/common/data_source/config.py b/common/data_source/config.py index a3d86720c..04aa71901 100644 --- a/common/data_source/config.py +++ b/common/data_source/config.py @@ -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 diff --git a/common/data_source/google_util/constant.py b/common/data_source/google_util/constant.py index 858ee31c8..89c9afaf5 100644 --- a/common/data_source/google_util/constant.py +++ b/common/data_source/google_util/constant.py @@ -49,7 +49,7 @@ MISSING_SCOPES_ERROR_STR = "client not authorized for any of the scopes requeste SCOPE_INSTRUCTIONS = "" -GOOGLE_WEB_OAUTH_POPUP_TEMPLATE = """ +WEB_OAUTH_POPUP_TEMPLATE = """ diff --git a/pyproject.toml b/pyproject.toml index dbe407937..cb714db6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/rag/svr/sync_data_source.py b/rag/svr/sync_data_source.py index d6603f68f..1f94aeec0 100644 --- a/rag/svr/sync_data_source.py +++ b/rag/svr/sync_data_source.py @@ -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 } diff --git a/uv.lock b/uv.lock index 06132ed51..d8d837f17 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, diff --git a/web/src/assets/svg/data-source/box.svg b/web/src/assets/svg/data-source/box.svg new file mode 100644 index 000000000..5eec50e55 --- /dev/null +++ b/web/src/assets/svg/data-source/box.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/pages/user-setting/data-source/component/box-token-field.tsx b/web/src/pages/user-setting/data-source/component/box-token-field.tsx new file mode 100644 index 000000000..7151ebea8 --- /dev/null +++ b/web/src/pages/user-setting/data-source/component/box-token-field.tsx @@ -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(null); + const webFlowIdRef = useRef(null); + const webPollTimerRef = useRef | null>(null); + const [webStatus, setWebStatus] = useState('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 = { + ...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 ( +
+ {(hasConfigured || hasAuthorized) && ( +
+
+ {hasAuthorized ? ( + + Authorized + + ) : null} + {hasConfigured ? ( + + Configured + + ) : null} +
+

+ {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.'} +

+
+ )} + + + + + !open ? handleCloseDialog() : setDialogOpen(true) + } + > + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + onEscapeKeyDown={(e) => e.preventDefault()} + > + + Configure Box OAuth credentials + + 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. + + + +
+
+ + setClientId(e.target.value)} + /> +
+
+ + setClientSecret(e.target.value)} + /> +
+
+ + setRedirectUri(e.target.value)} + /> +
+ {webStatus !== 'idle' && ( +
+
+ Browser authorization +
+

+ {webStatusMessage} +

+ {webStatus === 'waiting' && webFlowId ? ( +
+ +
+ ) : null} +
+ )} +
+ + + + + +
+
+
+ ); +}; + +export default BoxTokenField; diff --git a/web/src/pages/user-setting/data-source/contant.tsx b/web/src/pages/user-setting/data-source/contant.tsx index b3e86e118..ba80c2a55 100644 --- a/web/src/pages/user-setting/data-source/contant.tsx +++ b/web/src/pages/user-setting/data-source/contant.tsx @@ -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: , }, + [DataSourceKey.BOX]: { + name: 'Box', + description: t(`setting.${DataSourceKey.BOX}Description`), + icon: , + }, }; 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) => ( ), }, @@ -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) => ( + + ), + }, + { + 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: '', + }, + }, + }, }; diff --git a/web/src/services/data-source-service.ts b/web/src/services/data-source-service.ts index 036c156ad..bfc54b27c 100644 --- a/web/src/services/data-source-service.ts +++ b/web/src/services/data-source-service.ts @@ -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; diff --git a/web/src/utils/api.ts b/web/src/utils/api.ts index 953016abf..afb657294 100644 --- a/web/src/utils/api.ts +++ b/web/src/utils/api.ts @@ -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`,