Skip to content

Commit 7db9045

Browse files
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)
1 parent a6bd765 commit 7db9045

File tree

19 files changed

+1017
-129
lines changed

19 files changed

+1017
-129
lines changed

api/apps/__init__.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828
from api.utils.json_encode import CustomJSONEncoder
2929
from api.utils import commands
3030

31-
from flask_mail import Mail
3231
from quart_auth import Unauthorized
3332
from common import settings
3433
from api.utils.api_utils import server_error_response
@@ -42,7 +41,6 @@
4241

4342
app = Quart(__name__)
4443
app = cors(app, allow_origin="*")
45-
smtp_mail_server = Mail()
4644

4745
# Add this at the beginning of your file to configure Swagger UI
4846
swagger_config = {

api/apps/connector_app.py

Lines changed: 112 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,12 @@
2828
from api.db.services.connector_service import ConnectorService, SyncLogsService
2929
from api.utils.api_utils import get_data_error_result, get_json_result, get_request_json, validate_request
3030
from common.constants import RetCode, TaskStatus
31-
from common.data_source.config import GOOGLE_DRIVE_WEB_OAUTH_REDIRECT_URI, GMAIL_WEB_OAUTH_REDIRECT_URI, DocumentSource
32-
from common.data_source.google_util.constant import GOOGLE_WEB_OAUTH_POPUP_TEMPLATE, GOOGLE_SCOPES
31+
from common.data_source.config import GOOGLE_DRIVE_WEB_OAUTH_REDIRECT_URI, GMAIL_WEB_OAUTH_REDIRECT_URI, BOX_WEB_OAUTH_REDIRECT_URI, DocumentSource
32+
from common.data_source.google_util.constant import WEB_OAUTH_POPUP_TEMPLATE, GOOGLE_SCOPES
3333
from common.misc_utils import get_uuid
3434
from rag.utils.redis_conn import REDIS_CONN
3535
from api.apps import login_required, current_user
36+
from box_sdk_gen import BoxOAuth, OAuthConfig, GetAuthorizeUrlOptions
3637

3738

3839
@manager.route("/set", methods=["POST"]) # noqa: F821
@@ -117,8 +118,6 @@ def rm_connector(connector_id):
117118
return get_json_result(data=True)
118119

119120

120-
GOOGLE_WEB_FLOW_STATE_PREFIX = "google_drive_web_flow_state"
121-
GOOGLE_WEB_FLOW_RESULT_PREFIX = "google_drive_web_flow_result"
122121
WEB_FLOW_TTL_SECS = 15 * 60
123122

124123

@@ -129,10 +128,7 @@ def _web_state_cache_key(flow_id: str, source_type: str | None = None) -> str:
129128
When source_type == "gmail", a different prefix is used so that
130129
Drive/Gmail flows don't clash in Redis.
131130
"""
132-
if source_type == "gmail":
133-
prefix = "gmail_web_flow_state"
134-
else:
135-
prefix = GOOGLE_WEB_FLOW_STATE_PREFIX
131+
prefix = f"{source_type}_web_flow_state"
136132
return f"{prefix}:{flow_id}"
137133

138134

@@ -141,10 +137,7 @@ def _web_result_cache_key(flow_id: str, source_type: str | None = None) -> str:
141137
142138
Mirrors _web_state_cache_key logic for result storage.
143139
"""
144-
if source_type == "gmail":
145-
prefix = "gmail_web_flow_result"
146-
else:
147-
prefix = GOOGLE_WEB_FLOW_RESULT_PREFIX
140+
prefix = f"{source_type}_web_flow_result"
148141
return f"{prefix}:{flow_id}"
149142

150143

@@ -180,7 +173,7 @@ async def _render_web_oauth_popup(flow_id: str, success: bool, message: str, sou
180173
}
181174
)
182175
# TODO(google-oauth): title/heading/message may need to reflect drive/gmail based on cached type
183-
html = GOOGLE_WEB_OAUTH_POPUP_TEMPLATE.format(
176+
html = WEB_OAUTH_POPUP_TEMPLATE.format(
184177
title=f"Google {source.capitalize()} Authorization",
185178
heading="Authorization complete" if success else "Authorization failed",
186179
message=escaped_message,
@@ -204,8 +197,8 @@ async def start_google_web_oauth():
204197
redirect_uri = GMAIL_WEB_OAUTH_REDIRECT_URI
205198
scopes = GOOGLE_SCOPES[DocumentSource.GMAIL]
206199
else:
207-
redirect_uri = GOOGLE_DRIVE_WEB_OAUTH_REDIRECT_URI if source == "google-drive" else GMAIL_WEB_OAUTH_REDIRECT_URI
208-
scopes = GOOGLE_SCOPES[DocumentSource.GOOGLE_DRIVE if source == "google-drive" else DocumentSource.GMAIL]
200+
redirect_uri = GOOGLE_DRIVE_WEB_OAUTH_REDIRECT_URI
201+
scopes = GOOGLE_SCOPES[DocumentSource.GOOGLE_DRIVE]
209202

210203
if not redirect_uri:
211204
return get_json_result(
@@ -271,8 +264,6 @@ async def google_gmail_web_oauth_callback():
271264
state_id = request.args.get("state")
272265
error = request.args.get("error")
273266
source = "gmail"
274-
if source != 'gmail':
275-
return await _render_web_oauth_popup("", False, "Invalid Google OAuth type.", source)
276267

277268
error_description = request.args.get("error_description") or error
278269

@@ -313,9 +304,6 @@ async def google_gmail_web_oauth_callback():
313304
"credentials": creds_json,
314305
}
315306
REDIS_CONN.set_obj(_web_result_cache_key(state_id, source), result_payload, WEB_FLOW_TTL_SECS)
316-
317-
print("\n\n", _web_result_cache_key(state_id, source), "\n\n")
318-
319307
REDIS_CONN.delete(_web_state_cache_key(state_id, source))
320308

321309
return await _render_web_oauth_popup(state_id, True, "Authorization completed successfully.", source)
@@ -326,8 +314,6 @@ async def google_drive_web_oauth_callback():
326314
state_id = request.args.get("state")
327315
error = request.args.get("error")
328316
source = "google-drive"
329-
if source not in ("google-drive", "gmail"):
330-
return await _render_web_oauth_popup("", False, "Invalid Google OAuth type.", source)
331317

332318
error_description = request.args.get("error_description") or error
333319

@@ -391,3 +377,107 @@ async def poll_google_web_result():
391377

392378
REDIS_CONN.delete(_web_result_cache_key(flow_id, source))
393379
return get_json_result(data={"credentials": result.get("credentials")})
380+
381+
@manager.route("/box/oauth/web/start", methods=["POST"]) # noqa: F821
382+
@login_required
383+
async def start_box_web_oauth():
384+
req = await get_request_json()
385+
386+
client_id = req.get("client_id")
387+
client_secret = req.get("client_secret")
388+
redirect_uri = req.get("redirect_uri", BOX_WEB_OAUTH_REDIRECT_URI)
389+
390+
if not client_id or not client_secret:
391+
return get_json_result(code=RetCode.ARGUMENT_ERROR, message="Box client_id and client_secret are required.")
392+
393+
flow_id = str(uuid.uuid4())
394+
395+
box_auth = BoxOAuth(
396+
OAuthConfig(
397+
client_id=client_id,
398+
client_secret=client_secret,
399+
)
400+
)
401+
402+
auth_url = box_auth.get_authorize_url(
403+
options=GetAuthorizeUrlOptions(
404+
redirect_uri=redirect_uri,
405+
state=flow_id,
406+
)
407+
)
408+
409+
cache_payload = {
410+
"user_id": current_user.id,
411+
"auth_url": auth_url,
412+
"client_id": client_id,
413+
"client_secret": client_secret,
414+
"created_at": int(time.time()),
415+
}
416+
REDIS_CONN.set_obj(_web_state_cache_key(flow_id, "box"), cache_payload, WEB_FLOW_TTL_SECS)
417+
return get_json_result(
418+
data = {
419+
"flow_id": flow_id,
420+
"authorization_url": auth_url,
421+
"expires_in": WEB_FLOW_TTL_SECS,}
422+
)
423+
424+
@manager.route("/box/oauth/web/callback", methods=["GET"]) # noqa: F821
425+
async def box_web_oauth_callback():
426+
flow_id = request.args.get("state")
427+
if not flow_id:
428+
return await _render_web_oauth_popup("", False, "Missing OAuth parameters.", "box")
429+
430+
code = request.args.get("code")
431+
if not code:
432+
return await _render_web_oauth_popup(flow_id, False, "Missing authorization code from Box.", "box")
433+
434+
cache_payload = json.loads(REDIS_CONN.get(_web_state_cache_key(flow_id, "box")))
435+
if not cache_payload:
436+
return get_json_result(code=RetCode.ARGUMENT_ERROR, message="Box OAuth session expired or invalid.")
437+
438+
error = request.args.get("error")
439+
error_description = request.args.get("error_description") or error
440+
if error:
441+
REDIS_CONN.delete(_web_state_cache_key(flow_id, "box"))
442+
return await _render_web_oauth_popup(flow_id, False, error_description or "Authorization failed.", "box")
443+
444+
auth = BoxOAuth(
445+
OAuthConfig(
446+
client_id=cache_payload.get("client_id"),
447+
client_secret=cache_payload.get("client_secret"),
448+
)
449+
)
450+
451+
auth.get_tokens_authorization_code_grant(code)
452+
token = auth.retrieve_token()
453+
result_payload = {
454+
"user_id": cache_payload.get("user_id"),
455+
"client_id": cache_payload.get("client_id"),
456+
"client_secret": cache_payload.get("client_secret"),
457+
"access_token": token.access_token,
458+
"refresh_token": token.refresh_token,
459+
}
460+
461+
REDIS_CONN.set_obj(_web_result_cache_key(flow_id, "box"), result_payload, WEB_FLOW_TTL_SECS)
462+
REDIS_CONN.delete(_web_state_cache_key(flow_id, "box"))
463+
464+
return await _render_web_oauth_popup(flow_id, True, "Authorization completed successfully.", "box")
465+
466+
@manager.route("/box/oauth/web/result", methods=["POST"]) # noqa: F821
467+
@login_required
468+
@validate_request("flow_id")
469+
async def poll_box_web_result():
470+
req = await get_request_json()
471+
flow_id = req.get("flow_id")
472+
473+
cache_blob = REDIS_CONN.get(_web_result_cache_key(flow_id, "box"))
474+
if not cache_blob:
475+
return get_json_result(code=RetCode.RUNNING, message="Authorization is still pending.")
476+
477+
cache_raw = json.loads(cache_blob)
478+
if cache_raw.get("user_id") != current_user.id:
479+
return get_json_result(code=RetCode.PERMISSION_ERROR, message="You are not allowed to access this authorization result.")
480+
481+
REDIS_CONN.delete(_web_result_cache_key(flow_id, "box"))
482+
483+
return get_json_result(data={"credentials": cache_raw})

api/apps/tenant_app.py

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
# See the License for the specific language governing permissions and
1414
# limitations under the License.
1515
#
16-
16+
import logging
17+
import asyncio
1718
from api.db import UserTenantRole
1819
from api.db.db_models import UserTenant
1920
from api.db.services.user_service import UserTenantService, UserService
@@ -24,7 +25,7 @@
2425
from api.utils.api_utils import get_data_error_result, get_json_result, get_request_json, server_error_response, validate_request
2526
from api.utils.web_utils import send_invite_email
2627
from common import settings
27-
from api.apps import smtp_mail_server, login_required, current_user
28+
from api.apps import login_required, current_user
2829

2930

3031
@manager.route("/<tenant_id>/user/list", methods=["GET"]) # noqa: F821
@@ -80,20 +81,24 @@ async def create(tenant_id):
8081
role=UserTenantRole.INVITE,
8182
status=StatusEnum.VALID.value)
8283

83-
if smtp_mail_server and settings.SMTP_CONF:
84-
from threading import Thread
84+
try:
8585

8686
user_name = ""
8787
_, user = UserService.get_by_id(current_user.id)
8888
if user:
8989
user_name = user.nickname
9090

91-
Thread(
92-
target=send_invite_email,
93-
args=(invite_user_email, settings.MAIL_FRONTEND_URL, tenant_id, user_name or current_user.email),
94-
daemon=True
95-
).start()
96-
91+
asyncio.create_task(
92+
send_invite_email(
93+
to_email=invite_user_email,
94+
invite_url=settings.MAIL_FRONTEND_URL,
95+
tenant_id=tenant_id,
96+
inviter=user_name or current_user.email
97+
)
98+
)
99+
except Exception as e:
100+
logging.exception(f"Failed to send invite email to {invite_user_email}: {e}")
101+
return get_json_result(data=False, message="Failed to send invite email.", code=RetCode.SERVER_ERROR)
97102
usr = invite_users[0].to_dict()
98103
usr = {k: v for k, v in usr.items() if k in ["id", "avatar", "email", "nickname"]}
99104

0 commit comments

Comments
 (0)