mirror of
https://github.com/infiniflow/ragflow.git
synced 2025-12-19 20:16:49 +08:00
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:
@ -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 = {
|
||||
|
||||
@ -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})
|
||||
@ -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"]}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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...")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}"
|
||||
|
||||
|
||||
@ -123,7 +123,7 @@ class FileSource(StrEnum):
|
||||
WEBDAV = "webdav"
|
||||
MOODLE = "moodle"
|
||||
DROPBOX = "dropbox"
|
||||
|
||||
BOX = "box"
|
||||
|
||||
class PipelineTaskType(StrEnum):
|
||||
PARSE = "Parse"
|
||||
|
||||
162
common/data_source/box_connector.py
Normal file
162
common/data_source/box_connector.py
Normal 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)
|
||||
@ -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
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
38
uv.lock
generated
@ -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" },
|
||||
|
||||
1
web/src/assets/svg/data-source/box.svg
Normal file
1
web/src/assets/svg/data-source/box.svg
Normal 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 |
@ -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'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;
|
||||
@ -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: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user