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.json_encode import CustomJSONEncoder
|
||||||
from api.utils import commands
|
from api.utils import commands
|
||||||
|
|
||||||
from flask_mail import Mail
|
|
||||||
from quart_auth import Unauthorized
|
from quart_auth import Unauthorized
|
||||||
from common import settings
|
from common import settings
|
||||||
from api.utils.api_utils import server_error_response
|
from api.utils.api_utils import server_error_response
|
||||||
@ -42,7 +41,6 @@ __all__ = ["app"]
|
|||||||
|
|
||||||
app = Quart(__name__)
|
app = Quart(__name__)
|
||||||
app = cors(app, allow_origin="*")
|
app = cors(app, allow_origin="*")
|
||||||
smtp_mail_server = Mail()
|
|
||||||
|
|
||||||
# Add this at the beginning of your file to configure Swagger UI
|
# Add this at the beginning of your file to configure Swagger UI
|
||||||
swagger_config = {
|
swagger_config = {
|
||||||
|
|||||||
@ -28,11 +28,12 @@ from api.db import InputType
|
|||||||
from api.db.services.connector_service import ConnectorService, SyncLogsService
|
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 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.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.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 GOOGLE_WEB_OAUTH_POPUP_TEMPLATE, GOOGLE_SCOPES
|
from common.data_source.google_util.constant import WEB_OAUTH_POPUP_TEMPLATE, GOOGLE_SCOPES
|
||||||
from common.misc_utils import get_uuid
|
from common.misc_utils import get_uuid
|
||||||
from rag.utils.redis_conn import REDIS_CONN
|
from rag.utils.redis_conn import REDIS_CONN
|
||||||
from api.apps import login_required, current_user
|
from api.apps import login_required, current_user
|
||||||
|
from box_sdk_gen import BoxOAuth, OAuthConfig, GetAuthorizeUrlOptions
|
||||||
|
|
||||||
|
|
||||||
@manager.route("/set", methods=["POST"]) # noqa: F821
|
@manager.route("/set", methods=["POST"]) # noqa: F821
|
||||||
@ -117,8 +118,6 @@ def rm_connector(connector_id):
|
|||||||
return get_json_result(data=True)
|
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
|
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
|
When source_type == "gmail", a different prefix is used so that
|
||||||
Drive/Gmail flows don't clash in Redis.
|
Drive/Gmail flows don't clash in Redis.
|
||||||
"""
|
"""
|
||||||
if source_type == "gmail":
|
prefix = f"{source_type}_web_flow_state"
|
||||||
prefix = "gmail_web_flow_state"
|
|
||||||
else:
|
|
||||||
prefix = GOOGLE_WEB_FLOW_STATE_PREFIX
|
|
||||||
return f"{prefix}:{flow_id}"
|
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.
|
Mirrors _web_state_cache_key logic for result storage.
|
||||||
"""
|
"""
|
||||||
if source_type == "gmail":
|
prefix = f"{source_type}_web_flow_result"
|
||||||
prefix = "gmail_web_flow_result"
|
|
||||||
else:
|
|
||||||
prefix = GOOGLE_WEB_FLOW_RESULT_PREFIX
|
|
||||||
return f"{prefix}:{flow_id}"
|
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
|
# 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",
|
title=f"Google {source.capitalize()} Authorization",
|
||||||
heading="Authorization complete" if success else "Authorization failed",
|
heading="Authorization complete" if success else "Authorization failed",
|
||||||
message=escaped_message,
|
message=escaped_message,
|
||||||
@ -204,8 +197,8 @@ async def start_google_web_oauth():
|
|||||||
redirect_uri = GMAIL_WEB_OAUTH_REDIRECT_URI
|
redirect_uri = GMAIL_WEB_OAUTH_REDIRECT_URI
|
||||||
scopes = GOOGLE_SCOPES[DocumentSource.GMAIL]
|
scopes = GOOGLE_SCOPES[DocumentSource.GMAIL]
|
||||||
else:
|
else:
|
||||||
redirect_uri = GOOGLE_DRIVE_WEB_OAUTH_REDIRECT_URI if source == "google-drive" else GMAIL_WEB_OAUTH_REDIRECT_URI
|
redirect_uri = GOOGLE_DRIVE_WEB_OAUTH_REDIRECT_URI
|
||||||
scopes = GOOGLE_SCOPES[DocumentSource.GOOGLE_DRIVE if source == "google-drive" else DocumentSource.GMAIL]
|
scopes = GOOGLE_SCOPES[DocumentSource.GOOGLE_DRIVE]
|
||||||
|
|
||||||
if not redirect_uri:
|
if not redirect_uri:
|
||||||
return get_json_result(
|
return get_json_result(
|
||||||
@ -271,8 +264,6 @@ async def google_gmail_web_oauth_callback():
|
|||||||
state_id = request.args.get("state")
|
state_id = request.args.get("state")
|
||||||
error = request.args.get("error")
|
error = request.args.get("error")
|
||||||
source = "gmail"
|
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
|
error_description = request.args.get("error_description") or error
|
||||||
|
|
||||||
@ -313,9 +304,6 @@ async def google_gmail_web_oauth_callback():
|
|||||||
"credentials": creds_json,
|
"credentials": creds_json,
|
||||||
}
|
}
|
||||||
REDIS_CONN.set_obj(_web_result_cache_key(state_id, source), result_payload, WEB_FLOW_TTL_SECS)
|
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))
|
REDIS_CONN.delete(_web_state_cache_key(state_id, source))
|
||||||
|
|
||||||
return await _render_web_oauth_popup(state_id, True, "Authorization completed successfully.", 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")
|
state_id = request.args.get("state")
|
||||||
error = request.args.get("error")
|
error = request.args.get("error")
|
||||||
source = "google-drive"
|
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
|
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))
|
REDIS_CONN.delete(_web_result_cache_key(flow_id, source))
|
||||||
return get_json_result(data={"credentials": result.get("credentials")})
|
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
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
#
|
#
|
||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
from api.db import UserTenantRole
|
from api.db import UserTenantRole
|
||||||
from api.db.db_models import UserTenant
|
from api.db.db_models import UserTenant
|
||||||
from api.db.services.user_service import UserTenantService, UserService
|
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.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 api.utils.web_utils import send_invite_email
|
||||||
from common import settings
|
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
|
@manager.route("/<tenant_id>/user/list", methods=["GET"]) # noqa: F821
|
||||||
@ -80,20 +81,24 @@ async def create(tenant_id):
|
|||||||
role=UserTenantRole.INVITE,
|
role=UserTenantRole.INVITE,
|
||||||
status=StatusEnum.VALID.value)
|
status=StatusEnum.VALID.value)
|
||||||
|
|
||||||
if smtp_mail_server and settings.SMTP_CONF:
|
try:
|
||||||
from threading import Thread
|
|
||||||
|
|
||||||
user_name = ""
|
user_name = ""
|
||||||
_, user = UserService.get_by_id(current_user.id)
|
_, user = UserService.get_by_id(current_user.id)
|
||||||
if user:
|
if user:
|
||||||
user_name = user.nickname
|
user_name = user.nickname
|
||||||
|
|
||||||
Thread(
|
asyncio.create_task(
|
||||||
target=send_invite_email,
|
send_invite_email(
|
||||||
args=(invite_user_email, settings.MAIL_FRONTEND_URL, tenant_id, user_name or current_user.email),
|
to_email=invite_user_email,
|
||||||
daemon=True
|
invite_url=settings.MAIL_FRONTEND_URL,
|
||||||
).start()
|
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 = invite_users[0].to_dict()
|
||||||
usr = {k: v for k, v in usr.items() if k in ["id", "avatar", "email", "nickname"]}
|
usr = {k: v for k, v in usr.items() if k in ["id", "avatar", "email", "nickname"]}
|
||||||
|
|
||||||
|
|||||||
@ -22,7 +22,7 @@ import secrets
|
|||||||
import time
|
import time
|
||||||
from datetime import datetime
|
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 werkzeug.security import check_password_hash, generate_password_hash
|
||||||
|
|
||||||
from api.apps.auth import get_auth_client
|
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 api.utils.crypt import decrypt
|
||||||
from rag.utils.redis_conn import REDIS_CONN
|
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 (
|
from api.utils.web_utils import (
|
||||||
send_email_html,
|
send_email_html,
|
||||||
OTP_LENGTH,
|
OTP_LENGTH,
|
||||||
@ -868,9 +868,12 @@ async def forget_get_captcha():
|
|||||||
from captcha.image import ImageCaptcha
|
from captcha.image import ImageCaptcha
|
||||||
image = ImageCaptcha(width=300, height=120, font_sizes=[50, 60, 70])
|
image = ImageCaptcha(width=300, height=120, font_sizes=[50, 60, 70])
|
||||||
img_bytes = image.generate(captcha_text).read()
|
img_bytes = image.generate(captcha_text).read()
|
||||||
response = await make_response(img_bytes)
|
|
||||||
response.headers.set("Content-Type", "image/JPEG")
|
import base64
|
||||||
return response
|
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
|
@manager.route("/forget/otp", methods=["POST"]) # noqa: F821
|
||||||
@ -923,19 +926,18 @@ async def forget_send_otp():
|
|||||||
|
|
||||||
ttl_min = OTP_TTL_SECONDS // 60
|
ttl_min = OTP_TTL_SECONDS // 60
|
||||||
|
|
||||||
if not smtp_mail_server:
|
try:
|
||||||
logging.warning("SMTP mail server not initialized; skip sending email.")
|
await send_email_html(
|
||||||
else:
|
subject="Your Password Reset Code",
|
||||||
try:
|
to_email=email,
|
||||||
send_email_html(
|
template_key="reset_code",
|
||||||
subject="Your Password Reset Code",
|
code=otp,
|
||||||
to_email=email,
|
ttl_min=ttl_min,
|
||||||
template_key="reset_code",
|
)
|
||||||
code=otp,
|
|
||||||
ttl_min=ttl_min,
|
except Exception as e:
|
||||||
)
|
logging.exception(e)
|
||||||
except Exception:
|
return get_json_result(data=False, code=RetCode.SERVER_ERROR, message="failed to send email")
|
||||||
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")
|
return get_json_result(data=True, code=RetCode.SUCCESS, message="verification passed, email sent")
|
||||||
|
|
||||||
@ -943,27 +945,39 @@ async def forget_send_otp():
|
|||||||
@manager.route("/forget", methods=["POST"]) # noqa: F821
|
@manager.route("/forget", methods=["POST"]) # noqa: F821
|
||||||
async def forget():
|
async def forget():
|
||||||
"""
|
"""
|
||||||
POST: Verify email + OTP and reset password, then log the user in.
|
Deprecated single-step reset endpoint.
|
||||||
Request JSON: { email, otp, new_password, confirm_new_password }
|
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()
|
req = await get_request_json()
|
||||||
email = req.get("email") or ""
|
email = req.get("email") or ""
|
||||||
otp = (req.get("otp") or "").strip()
|
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]):
|
if not all([email, otp]):
|
||||||
return get_json_result(data=False, code=RetCode.ARGUMENT_ERROR, message="email, otp and passwords are required")
|
return get_json_result(data=False, code=RetCode.ARGUMENT_ERROR, message="email and otp 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")
|
|
||||||
|
|
||||||
users = UserService.query(email=email)
|
users = UserService.query(email=email)
|
||||||
if not users:
|
if not users:
|
||||||
return get_json_result(data=False, code=RetCode.DATA_ERROR, message="invalid email")
|
return get_json_result(data=False, code=RetCode.DATA_ERROR, message="invalid email")
|
||||||
|
|
||||||
user = users[0]
|
|
||||||
# Verify OTP from Redis
|
# Verify OTP from Redis
|
||||||
k_code, k_attempts, k_last, k_lock = otp_keys(email)
|
k_code, k_attempts, k_last, k_lock = otp_keys(email)
|
||||||
if REDIS_CONN.get(k_lock):
|
if REDIS_CONN.get(k_lock):
|
||||||
@ -979,7 +993,6 @@ async def forget():
|
|||||||
except Exception:
|
except Exception:
|
||||||
return get_json_result(data=False, code=RetCode.EXCEPTION_ERROR, message="otp storage corrupted")
|
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)
|
calc = hash_code(otp.upper(), salt)
|
||||||
if calc != stored_hash:
|
if calc != stored_hash:
|
||||||
# bump attempts
|
# bump attempts
|
||||||
@ -992,23 +1005,72 @@ async def forget():
|
|||||||
REDIS_CONN.set(k_lock, int(time.time()), ATTEMPT_LOCK_SECONDS)
|
REDIS_CONN.set(k_lock, int(time.time()), ATTEMPT_LOCK_SECONDS)
|
||||||
return get_json_result(data=False, code=RetCode.AUTHENTICATION_ERROR, message="expired otp")
|
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_code)
|
||||||
REDIS_CONN.delete(k_attempts)
|
REDIS_CONN.delete(k_attempts)
|
||||||
REDIS_CONN.delete(k_last)
|
REDIS_CONN.delete(k_last)
|
||||||
REDIS_CONN.delete(k_lock)
|
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:
|
try:
|
||||||
UserService.update_user_password(user.id, new_pwd)
|
UserService.update_user_password(user.id, new_pwd)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.exception(e)
|
logging.exception(e)
|
||||||
return get_json_result(data=False, code=RetCode.EXCEPTION_ERROR, message="failed to reset password")
|
return get_json_result(data=False, code=RetCode.EXCEPTION_ERROR, message="failed to reset password")
|
||||||
|
|
||||||
# Auto login (reuse login flow)
|
# login
|
||||||
user.access_token = get_uuid()
|
try:
|
||||||
login_user(user)
|
user.access_token = get_uuid()
|
||||||
user.update_time = current_timestamp()
|
login_user(user)
|
||||||
user.update_date = datetime_format(datetime.now())
|
user.update_time = current_timestamp()
|
||||||
user.save()
|
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."
|
msg = "Password reset successful. Logged in."
|
||||||
return await construct_response(data=user.to_json(), auth=user.get_id(), message=msg)
|
return await construct_response(data=user.to_json(), auth=user.get_id(), message=msg)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -30,7 +30,7 @@ import threading
|
|||||||
import uuid
|
import uuid
|
||||||
import faulthandler
|
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.runtime_config import RuntimeConfig
|
||||||
from api.db.services.document_service import DocumentService
|
from api.db.services.document_service import DocumentService
|
||||||
from common.file_utils import get_project_base_directory
|
from common.file_utils import get_project_base_directory
|
||||||
@ -143,18 +143,6 @@ if __name__ == '__main__':
|
|||||||
else:
|
else:
|
||||||
threading.Timer(1.0, delayed_start_update_progress).start()
|
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
|
# start http server
|
||||||
try:
|
try:
|
||||||
logging.info("RAGFlow HTTP server start...")
|
logging.info("RAGFlow HTTP server start...")
|
||||||
|
|||||||
@ -20,18 +20,18 @@ Reusable HTML email templates and registry.
|
|||||||
|
|
||||||
# Invitation email template
|
# Invitation email template
|
||||||
INVITE_EMAIL_TMPL = """
|
INVITE_EMAIL_TMPL = """
|
||||||
<p>Hi {{email}},</p>
|
Hi {{email}},
|
||||||
<p>{{inviter}} has invited you to join their team (ID: {{tenant_id}}).</p>
|
{{inviter}} has invited you to join their team (ID: {{tenant_id}}).
|
||||||
<p>Click the link below to complete your registration:<br>
|
Click the link below to complete your registration:
|
||||||
<a href="{{invite_url}}">{{invite_url}}</a></p>
|
{{invite_url}}
|
||||||
<p>If you did not request this, please ignore this email.</p>
|
If you did not request this, please ignore this email.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Password reset code template
|
# Password reset code template
|
||||||
RESET_CODE_EMAIL_TMPL = """
|
RESET_CODE_EMAIL_TMPL = """
|
||||||
<p>Hello,</p>
|
Hello,
|
||||||
<p>Your password reset code is: <b>{{ code }}</b></p>
|
Your password reset code is: {{ code }}
|
||||||
<p>This code will expire in {{ ttl_min }} minutes.</p>
|
This code will expire in {{ ttl_min }} minutes.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Template registry
|
# Template registry
|
||||||
|
|||||||
@ -20,9 +20,10 @@ import json
|
|||||||
import re
|
import re
|
||||||
import socket
|
import socket
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
import aiosmtplib
|
||||||
from api.apps import smtp_mail_server
|
from email.mime.text import MIMEText
|
||||||
from flask_mail import Message
|
from email.header import Header
|
||||||
|
from common import settings
|
||||||
from quart import render_template_string
|
from quart import render_template_string
|
||||||
from api.utils.email_templates import EMAIL_TEMPLATES
|
from api.utils.email_templates import EMAIL_TEMPLATES
|
||||||
from selenium import webdriver
|
from selenium import webdriver
|
||||||
@ -36,10 +37,10 @@ from webdriver_manager.chrome import ChromeDriverManager
|
|||||||
|
|
||||||
|
|
||||||
OTP_LENGTH = 8
|
OTP_LENGTH = 8
|
||||||
OTP_TTL_SECONDS = 5 * 60
|
OTP_TTL_SECONDS = 5 * 60 # valid for 5 minutes
|
||||||
ATTEMPT_LIMIT = 5
|
ATTEMPT_LIMIT = 5 # maximum attempts
|
||||||
ATTEMPT_LOCK_SECONDS = 30 * 60
|
ATTEMPT_LOCK_SECONDS = 30 * 60 # lock for 30 minutes
|
||||||
RESEND_COOLDOWN_SECONDS = 60
|
RESEND_COOLDOWN_SECONDS = 60 # cooldown for 1 minute
|
||||||
|
|
||||||
|
|
||||||
CONTENT_TYPE_MAP = {
|
CONTENT_TYPE_MAP = {
|
||||||
@ -185,25 +186,32 @@ def get_float(req: dict, key: str, default: float | int = 10.0) -> float:
|
|||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
def send_email_html(subject: str, to_email: str, template_key: str, **context):
|
async def send_email_html(to_email: str, subject: str, template_key: str, **context):
|
||||||
"""Generic HTML email sender using shared templates.
|
|
||||||
template_key must exist in EMAIL_TEMPLATES.
|
body = await render_template_string(EMAIL_TEMPLATES.get(template_key), **context)
|
||||||
"""
|
msg = MIMEText(body, "plain", "utf-8")
|
||||||
from api.apps import app
|
msg["Subject"] = Header(subject, "utf-8")
|
||||||
tmpl = EMAIL_TEMPLATES.get(template_key)
|
msg["From"] = f"{settings.MAIL_DEFAULT_SENDER[0]} <{settings.MAIL_DEFAULT_SENDER[1]}>"
|
||||||
if not tmpl:
|
msg["To"] = to_email
|
||||||
raise ValueError(f"Unknown email template: {template_key}")
|
|
||||||
with app.app_context():
|
smtp = aiosmtplib.SMTP(
|
||||||
msg = Message(subject=subject, recipients=[to_email])
|
hostname=settings.MAIL_SERVER,
|
||||||
msg.html = render_template_string(tmpl, **context)
|
port=settings.MAIL_PORT,
|
||||||
smtp_mail_server.send(msg)
|
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_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
|
# Reuse the generic HTML sender with 'invite' template
|
||||||
send_email_html(
|
await send_email_html(
|
||||||
subject="RAGFlow Invitation",
|
|
||||||
to_email=to_email,
|
to_email=to_email,
|
||||||
|
subject="RAGFlow Invitation",
|
||||||
template_key="invite",
|
template_key="invite",
|
||||||
email=to_email,
|
email=to_email,
|
||||||
invite_url=invite_url,
|
invite_url=invite_url,
|
||||||
|
|||||||
@ -123,7 +123,7 @@ class FileSource(StrEnum):
|
|||||||
WEBDAV = "webdav"
|
WEBDAV = "webdav"
|
||||||
MOODLE = "moodle"
|
MOODLE = "moodle"
|
||||||
DROPBOX = "dropbox"
|
DROPBOX = "dropbox"
|
||||||
|
BOX = "box"
|
||||||
|
|
||||||
class PipelineTaskType(StrEnum):
|
class PipelineTaskType(StrEnum):
|
||||||
PARSE = "Parse"
|
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"
|
MOODLE = "moodle"
|
||||||
S3_COMPATIBLE = "s3_compatible"
|
S3_COMPATIBLE = "s3_compatible"
|
||||||
DROPBOX = "dropbox"
|
DROPBOX = "dropbox"
|
||||||
|
BOX = "box"
|
||||||
|
|
||||||
class FileOrigin(str, Enum):
|
class FileOrigin(str, Enum):
|
||||||
"""File origins"""
|
"""File origins"""
|
||||||
@ -227,6 +227,7 @@ _DEFAULT_PAGINATION_LIMIT = 1000
|
|||||||
_PROBLEMATIC_EXPANSIONS = "body.storage.value"
|
_PROBLEMATIC_EXPANSIONS = "body.storage.value"
|
||||||
_REPLACEMENT_EXPANSIONS = "body.view.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):
|
class HtmlBasedConnectorTransformLinksStrategy(str, Enum):
|
||||||
# remove links entirely
|
# remove links entirely
|
||||||
|
|||||||
@ -49,7 +49,7 @@ MISSING_SCOPES_ERROR_STR = "client not authorized for any of the scopes requeste
|
|||||||
SCOPE_INSTRUCTIONS = ""
|
SCOPE_INSTRUCTIONS = ""
|
||||||
|
|
||||||
|
|
||||||
GOOGLE_WEB_OAUTH_POPUP_TEMPLATE = """<!DOCTYPE html>
|
WEB_OAUTH_POPUP_TEMPLATE = """<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
|
|||||||
@ -154,6 +154,8 @@ dependencies = [
|
|||||||
"exceptiongroup>=1.3.0,<2.0.0",
|
"exceptiongroup>=1.3.0,<2.0.0",
|
||||||
"ffmpeg-python>=0.2.0",
|
"ffmpeg-python>=0.2.0",
|
||||||
"imageio-ffmpeg>=0.6.0",
|
"imageio-ffmpeg>=0.6.0",
|
||||||
|
"boxsdk>=10.1.0",
|
||||||
|
"aiosmtplib>=5.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|||||||
@ -32,6 +32,8 @@ import traceback
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from flask import json
|
||||||
|
|
||||||
from api.db.services.connector_service import ConnectorService, SyncLogsService
|
from api.db.services.connector_service import ConnectorService, SyncLogsService
|
||||||
from api.db.services.knowledgebase_service import KnowledgebaseService
|
from api.db.services.knowledgebase_service import KnowledgebaseService
|
||||||
from common import settings
|
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.config import INDEX_BATCH_SIZE
|
||||||
from common.data_source.confluence_connector import ConfluenceConnector
|
from common.data_source.confluence_connector import ConfluenceConnector
|
||||||
from common.data_source.gmail_connector import GmailConnector
|
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.interfaces import CheckpointOutputWrapper
|
||||||
from common.data_source.utils import load_all_docs_from_checkpoint_connector
|
from common.data_source.utils import load_all_docs_from_checkpoint_connector
|
||||||
from common.log_utils import init_root_logger
|
from common.log_utils import init_root_logger
|
||||||
from common.signal_utils import start_tracemalloc_and_snapshot, stop_tracemalloc
|
from common.signal_utils import start_tracemalloc_and_snapshot, stop_tracemalloc
|
||||||
from common.versions import get_ragflow_version
|
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"))
|
MAX_CONCURRENT_TASKS = int(os.environ.get("MAX_CONCURRENT_TASKS", "5"))
|
||||||
task_limiter = asyncio.Semaphore(MAX_CONCURRENT_TASKS)
|
task_limiter = asyncio.Semaphore(MAX_CONCURRENT_TASKS)
|
||||||
@ -93,7 +97,7 @@ class SyncBase:
|
|||||||
if task["poll_range_start"]:
|
if task["poll_range_start"]:
|
||||||
next_update = 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:
|
if not document_batch:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@ -625,6 +629,47 @@ class Moodle(SyncBase):
|
|||||||
return document_generator
|
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 = {
|
func_factory = {
|
||||||
FileSource.S3: S3,
|
FileSource.S3: S3,
|
||||||
FileSource.NOTION: Notion,
|
FileSource.NOTION: Notion,
|
||||||
@ -639,6 +684,7 @@ func_factory = {
|
|||||||
FileSource.MOODLE: Moodle,
|
FileSource.MOODLE: Moodle,
|
||||||
FileSource.DROPBOX: Dropbox,
|
FileSource.DROPBOX: Dropbox,
|
||||||
FileSource.WEBDAV: WebDAV,
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "aiosqlite"
|
name = "aiosqlite"
|
||||||
version = "0.21.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "brotli"
|
name = "brotli"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@ -4221,12 +4243,12 @@ name = "onnxruntime-gpu"
|
|||||||
version = "1.23.2"
|
version = "1.23.2"
|
||||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "coloredlogs" },
|
{ name = "coloredlogs", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
||||||
{ name = "flatbuffers" },
|
{ name = "flatbuffers", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
||||||
{ name = "numpy" },
|
{ name = "numpy", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
||||||
{ name = "packaging" },
|
{ name = "packaging", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
||||||
{ name = "protobuf" },
|
{ name = "protobuf", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
||||||
{ name = "sympy" },
|
{ name = "sympy", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
||||||
]
|
]
|
||||||
wheels = [
|
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" },
|
{ 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"
|
version = "0.22.1"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "aiosmtplib" },
|
||||||
{ name = "akshare" },
|
{ name = "akshare" },
|
||||||
{ name = "anthropic" },
|
{ name = "anthropic" },
|
||||||
{ name = "arxiv" },
|
{ name = "arxiv" },
|
||||||
@ -5912,6 +5935,7 @@ dependencies = [
|
|||||||
{ name = "blinker" },
|
{ name = "blinker" },
|
||||||
{ name = "boto3" },
|
{ name = "boto3" },
|
||||||
{ name = "botocore" },
|
{ name = "botocore" },
|
||||||
|
{ name = "boxsdk" },
|
||||||
{ name = "cachetools" },
|
{ name = "cachetools" },
|
||||||
{ name = "captcha" },
|
{ name = "captcha" },
|
||||||
{ name = "chardet" },
|
{ name = "chardet" },
|
||||||
@ -6066,6 +6090,7 @@ test = [
|
|||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
|
{ name = "aiosmtplib", specifier = ">=5.0.0" },
|
||||||
{ name = "akshare", specifier = ">=1.15.78,<2.0.0" },
|
{ name = "akshare", specifier = ">=1.15.78,<2.0.0" },
|
||||||
{ name = "anthropic", specifier = "==0.34.1" },
|
{ name = "anthropic", specifier = "==0.34.1" },
|
||||||
{ name = "arxiv", specifier = "==2.1.3" },
|
{ name = "arxiv", specifier = "==2.1.3" },
|
||||||
@ -6079,6 +6104,7 @@ requires-dist = [
|
|||||||
{ name = "blinker", specifier = "==1.7.0" },
|
{ name = "blinker", specifier = "==1.7.0" },
|
||||||
{ name = "boto3", specifier = "==1.34.140" },
|
{ name = "boto3", specifier = "==1.34.140" },
|
||||||
{ name = "botocore", specifier = "==1.34.140" },
|
{ name = "botocore", specifier = "==1.34.140" },
|
||||||
|
{ name = "boxsdk", specifier = ">=10.1.0" },
|
||||||
{ name = "cachetools", specifier = "==5.3.3" },
|
{ name = "cachetools", specifier = "==5.3.3" },
|
||||||
{ name = "captcha", specifier = ">=0.7.1" },
|
{ name = "captcha", specifier = ">=0.7.1" },
|
||||||
{ name = "chardet", specifier = "==5.2.0" },
|
{ 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 { FormFieldType } from '@/components/dynamic-form';
|
||||||
import SvgIcon from '@/components/svg-icon';
|
import SvgIcon from '@/components/svg-icon';
|
||||||
import { t } from 'i18next';
|
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 { ConfluenceIndexingModeField } from './component/confluence-token-field';
|
||||||
import GmailTokenField from './component/gmail-token-field';
|
import GmailTokenField from './component/gmail-token-field';
|
||||||
import GoogleDriveTokenField from './component/google-drive-token-field';
|
import GoogleDriveTokenField from './component/google-drive-token-field';
|
||||||
|
|
||||||
export enum DataSourceKey {
|
export enum DataSourceKey {
|
||||||
CONFLUENCE = 'confluence',
|
CONFLUENCE = 'confluence',
|
||||||
S3 = 's3',
|
S3 = 's3',
|
||||||
@ -15,6 +16,7 @@ export enum DataSourceKey {
|
|||||||
GMAIL = 'gmail',
|
GMAIL = 'gmail',
|
||||||
JIRA = 'jira',
|
JIRA = 'jira',
|
||||||
WEBDAV = 'webdav',
|
WEBDAV = 'webdav',
|
||||||
|
BOX = 'box',
|
||||||
DROPBOX = 'dropbox',
|
DROPBOX = 'dropbox',
|
||||||
// SHAREPOINT = 'sharepoint',
|
// SHAREPOINT = 'sharepoint',
|
||||||
// SLACK = 'slack',
|
// SLACK = 'slack',
|
||||||
@ -72,6 +74,11 @@ export const DataSourceInfo = {
|
|||||||
description: t(`setting.${DataSourceKey.DROPBOX}Description`),
|
description: t(`setting.${DataSourceKey.DROPBOX}Description`),
|
||||||
icon: <SvgIcon name={'data-source/dropbox'} width={38} />,
|
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 = [
|
export const DataSourceFormBaseFields = [
|
||||||
@ -234,11 +241,11 @@ export const DataSourceFormFields = {
|
|||||||
{
|
{
|
||||||
label: 'Index Method',
|
label: 'Index Method',
|
||||||
name: 'config.index_mode',
|
name: 'config.index_mode',
|
||||||
type: FormFieldType.Text, // keep as text so RHF registers it
|
type: FormFieldType.Text,
|
||||||
required: false,
|
required: false,
|
||||||
horizontal: true,
|
horizontal: true,
|
||||||
labelClassName: 'self-start pt-4',
|
labelClassName: 'self-start pt-4',
|
||||||
render: (fieldProps: ControllerRenderProps) => (
|
render: (fieldProps: any) => (
|
||||||
<ConfluenceIndexingModeField {...fieldProps} />
|
<ConfluenceIndexingModeField {...fieldProps} />
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -551,6 +558,28 @@ export const DataSourceFormFields = {
|
|||||||
placeholder: 'Defaults to 2',
|
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 = {
|
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 }) =>
|
export const pollGmailWebAuthResult = (payload: { flow_id: string }) =>
|
||||||
request.post(api.googleWebAuthResult('gmail'), { data: payload });
|
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;
|
export default dataSourceService;
|
||||||
|
|||||||
@ -46,6 +46,8 @@ export default {
|
|||||||
`${api_host}/connector/google/oauth/web/start?type=${type}`,
|
`${api_host}/connector/google/oauth/web/start?type=${type}`,
|
||||||
googleWebAuthResult: (type: 'google-drive' | 'gmail') =>
|
googleWebAuthResult: (type: 'google-drive' | 'gmail') =>
|
||||||
`${api_host}/connector/google/oauth/web/result?type=${type}`,
|
`${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
|
// plugin
|
||||||
llm_tools: `${api_host}/plugin/llm_tools`,
|
llm_tools: `${api_host}/plugin/llm_tools`,
|
||||||
|
|||||||
Reference in New Issue
Block a user