Commit 1b761988 authored by John Wang's avatar John Wang

feat: add activate link return and add activate mail send

parent f029b188
...@@ -79,6 +79,11 @@ WEAVIATE_BATCH_SIZE=100 ...@@ -79,6 +79,11 @@ WEAVIATE_BATCH_SIZE=100
QDRANT_URL=path:storage/qdrant QDRANT_URL=path:storage/qdrant
QDRANT_API_KEY=your-qdrant-api-key QDRANT_API_KEY=your-qdrant-api-key
# Mail configuration, support: resend
MAIL_TYPE=
MAIL_DEFAULT_SEND_FROM=no-reply <no-reply@dify.ai>
RESEND_API_KEY=
# Sentry configuration # Sentry configuration
SENTRY_DSN= SENTRY_DSN=
......
...@@ -15,7 +15,7 @@ import flask_login ...@@ -15,7 +15,7 @@ import flask_login
from flask_cors import CORS from flask_cors import CORS
from extensions import ext_session, ext_celery, ext_sentry, ext_redis, ext_login, ext_migrate, \ from extensions import ext_session, ext_celery, ext_sentry, ext_redis, ext_login, ext_migrate, \
ext_database, ext_storage ext_database, ext_storage, ext_mail
from extensions.ext_database import db from extensions.ext_database import db
from extensions.ext_login import login_manager from extensions.ext_login import login_manager
...@@ -83,6 +83,7 @@ def initialize_extensions(app): ...@@ -83,6 +83,7 @@ def initialize_extensions(app):
ext_celery.init_app(app) ext_celery.init_app(app)
ext_session.init_app(app) ext_session.init_app(app)
ext_login.init_app(app) ext_login.init_app(app)
ext_mail.init_app(app)
ext_sentry.init_app(app) ext_sentry.init_app(app)
......
...@@ -151,6 +151,11 @@ class Config: ...@@ -151,6 +151,11 @@ class Config:
self.WEB_API_CORS_ALLOW_ORIGINS = get_cors_allow_origins( self.WEB_API_CORS_ALLOW_ORIGINS = get_cors_allow_origins(
'WEB_API_CORS_ALLOW_ORIGINS', '*') 'WEB_API_CORS_ALLOW_ORIGINS', '*')
# mail settings
self.MAIL_TYPE = get_env('MAIL_TYPE')
self.MAIL_DEFAULT_SEND_FROM = get_env('MAIL_DEFAULT_SEND_FROM')
self.RESEND_API_KEY = get_env('RESEND_API_KEY')
# sentry settings # sentry settings
self.SENTRY_DSN = get_env('SENTRY_DSN') self.SENTRY_DSN = get_env('SENTRY_DSN')
self.SENTRY_TRACES_SAMPLE_RATE = float(get_env('SENTRY_TRACES_SAMPLE_RATE')) self.SENTRY_TRACES_SAMPLE_RATE = float(get_env('SENTRY_TRACES_SAMPLE_RATE'))
......
...@@ -12,7 +12,7 @@ from . import setup, version, apikey, admin ...@@ -12,7 +12,7 @@ from . import setup, version, apikey, admin
from .app import app, site, completion, model_config, statistic, conversation, message, generator from .app import app, site, completion, model_config, statistic, conversation, message, generator
# Import auth controllers # Import auth controllers
from .auth import login, oauth, data_source_oauth from .auth import login, oauth, data_source_oauth, activate
# Import datasets controllers # Import datasets controllers
from .datasets import datasets, datasets_document, datasets_segments, file, hit_testing, data_source from .datasets import datasets, datasets_document, datasets_segments, file, hit_testing, data_source
......
import base64
import secrets
from datetime import datetime
from flask_restful import Resource, reqparse
from controllers.console import api
from controllers.console.error import AlreadyActivateError
from extensions.ext_database import db
from libs.helper import email, str_len, supported_language, timezone
from libs.password import valid_password, hash_password
from models.account import AccountStatus, Tenant
from services.account_service import RegisterService
class ActivateCheckApi(Resource):
def get(self):
parser = reqparse.RequestParser()
parser.add_argument('workspace_id', type=str, required=True, nullable=False, location='args')
parser.add_argument('email', type=email, required=True, nullable=False, location='args')
parser.add_argument('token', type=str, required=True, nullable=False, location='args')
args = parser.parse_args()
account = RegisterService.get_account_if_token_valid(args['workspace_id'], args['email'], args['token'])
tenant = db.session.query(Tenant).filter(
Tenant.id == args['workspace_id'],
Tenant.status == 'normal'
).first()
return {'is_valid': account is not None, 'workspace_name': tenant.name}
class ActivateApi(Resource):
def post(self):
parser = reqparse.RequestParser()
parser.add_argument('workspace_id', type=str, required=True, nullable=False, location='json')
parser.add_argument('email', type=email, required=True, nullable=False, location='json')
parser.add_argument('token', type=str, required=True, nullable=False, location='json')
parser.add_argument('name', type=str_len(30), required=True, nullable=False, location='json')
parser.add_argument('password', type=valid_password, required=True, nullable=False, location='json')
parser.add_argument('interface_language', type=supported_language, required=True, nullable=False,
location='json')
parser.add_argument('timezone', type=timezone, required=True, nullable=False, location='json')
args = parser.parse_args()
account = RegisterService.get_account_if_token_valid(args['workspace_id'], args['email'], args['token'])
if account is None:
raise AlreadyActivateError()
RegisterService.revoke_token(args['workspace_id'], args['email'], args['token'])
account.name = args['name']
# generate password salt
salt = secrets.token_bytes(16)
base64_salt = base64.b64encode(salt).decode()
# encrypt password with salt
password_hashed = hash_password(args['password'], salt)
base64_password_hashed = base64.b64encode(password_hashed).decode()
account.password = base64_password_hashed
account.password_salt = base64_salt
account.interface_language = args['interface_language']
account.timezone = args['timezone']
account.interface_theme = 'light'
account.status = AccountStatus.ACTIVE.value
account.initialized_at = datetime.utcnow()
db.session.commit()
return {'result': 'success'}
api.add_resource(ActivateCheckApi, '/activate/check')
api.add_resource(ActivateApi, '/activate')
...@@ -18,3 +18,9 @@ class AccountNotLinkTenantError(BaseHTTPException): ...@@ -18,3 +18,9 @@ class AccountNotLinkTenantError(BaseHTTPException):
error_code = 'account_not_link_tenant' error_code = 'account_not_link_tenant'
description = "Account not link tenant." description = "Account not link tenant."
code = 403 code = 403
class AlreadyActivateError(BaseHTTPException):
error_code = 'already_activate'
description = "Auth Token is invalid or account already activated, please check again."
code = 403
# -*- coding:utf-8 -*- # -*- coding:utf-8 -*-
from flask import current_app
from flask_login import login_required, current_user from flask_login import login_required, current_user
from flask_restful import Resource, reqparse, marshal_with, abort, fields, marshal from flask_restful import Resource, reqparse, marshal_with, abort, fields, marshal
...@@ -60,7 +60,8 @@ class MemberInviteEmailApi(Resource): ...@@ -60,7 +60,8 @@ class MemberInviteEmailApi(Resource):
inviter = current_user inviter = current_user
try: try:
RegisterService.invite_new_member(inviter.current_tenant, invitee_email, role=invitee_role, inviter=inviter) token = RegisterService.invite_new_member(inviter.current_tenant, invitee_email, role=invitee_role,
inviter=inviter)
account = db.session.query(Account, TenantAccountJoin.role).join( account = db.session.query(Account, TenantAccountJoin.role).join(
TenantAccountJoin, Account.id == TenantAccountJoin.account_id TenantAccountJoin, Account.id == TenantAccountJoin.account_id
).filter(Account.email == args['email']).first() ).filter(Account.email == args['email']).first()
...@@ -78,7 +79,16 @@ class MemberInviteEmailApi(Resource): ...@@ -78,7 +79,16 @@ class MemberInviteEmailApi(Resource):
# todo:413 # todo:413
return {'result': 'success', 'account': account}, 201 return {
'result': 'success',
'account': account,
'invite_url': '{}/activate?workspace_id={}&email={}&token={}'.format(
current_app.config.get("CONSOLE_URL"),
str(current_user.current_tenant_id),
invitee_email,
token
)
}, 201
class MemberCancelInviteApi(Resource): class MemberCancelInviteApi(Resource):
......
from typing import Optional
import resend
from flask import Flask
class Mail:
def __init__(self):
self._client = None
self._default_send_from = None
def is_inited(self) -> bool:
return self._client is not None
def init_app(self, app: Flask):
if app.config.get('MAIL_TYPE'):
if app.config.get('MAIL_DEFAULT_SEND_FROM'):
self._default_send_from = app.config.get('MAIL_DEFAULT_SEND_FROM')
if app.config.get('MAIL_TYPE') == 'resend':
api_key = app.config.get('RESEND_API_KEY')
if not api_key:
raise ValueError('RESEND_API_KEY is not set')
resend.api_key = api_key
self._client = resend.Emails
else:
raise ValueError('Unsupported mail type {}'.format(app.config.get('MAIL_TYPE')))
def send(self, to: str, subject: str, html: str, from_: Optional[str] = None):
if not self._client:
raise ValueError('Mail client is not initialized')
if not from_ and self._default_send_from:
from_ = self._default_send_from
if not from_:
raise ValueError('mail from is not set')
if not to:
raise ValueError('mail to is not set')
if not subject:
raise ValueError('mail subject is not set')
if not html:
raise ValueError('mail html is not set')
self._client.send({
"from": from_,
"to": to,
"subject": subject,
"html": html
})
def init_app(app: Flask):
mail.init_app(app)
mail = Mail()
...@@ -21,7 +21,7 @@ Authlib==1.2.0 ...@@ -21,7 +21,7 @@ Authlib==1.2.0
boto3~=1.26.123 boto3~=1.26.123
tenacity==8.2.2 tenacity==8.2.2
cachetools~=5.3.0 cachetools~=5.3.0
weaviate-client~=3.16.2 weaviate-client~=3.21.0
qdrant_client~=1.1.6 qdrant_client~=1.1.6
mailchimp-transactional~=1.0.50 mailchimp-transactional~=1.0.50
scikit-learn==1.2.2 scikit-learn==1.2.2
...@@ -32,4 +32,5 @@ redis~=4.5.4 ...@@ -32,4 +32,5 @@ redis~=4.5.4
openpyxl==3.1.2 openpyxl==3.1.2
chardet~=5.1.0 chardet~=5.1.0
docx2txt==0.8 docx2txt==0.8
pypdfium2==4.16.0 pypdfium2==4.16.0
\ No newline at end of file resend~=0.5.1
\ No newline at end of file
...@@ -2,13 +2,16 @@ ...@@ -2,13 +2,16 @@
import base64 import base64
import logging import logging
import secrets import secrets
import uuid
from datetime import datetime from datetime import datetime
from hashlib import sha256
from typing import Optional from typing import Optional
from flask import session from flask import session
from sqlalchemy import func from sqlalchemy import func
from events.tenant_event import tenant_was_created from events.tenant_event import tenant_was_created
from extensions.ext_redis import redis_client
from services.errors.account import AccountLoginError, CurrentPasswordIncorrectError, LinkAccountIntegrateError, \ from services.errors.account import AccountLoginError, CurrentPasswordIncorrectError, LinkAccountIntegrateError, \
TenantNotFound, AccountNotLinkTenantError, InvalidActionError, CannotOperateSelfError, MemberNotInTenantError, \ TenantNotFound, AccountNotLinkTenantError, InvalidActionError, CannotOperateSelfError, MemberNotInTenantError, \
RoleAlreadyAssignedError, NoPermissionError, AccountRegisterError, AccountAlreadyInTenantError RoleAlreadyAssignedError, NoPermissionError, AccountRegisterError, AccountAlreadyInTenantError
...@@ -16,6 +19,7 @@ from libs.helper import get_remote_ip ...@@ -16,6 +19,7 @@ from libs.helper import get_remote_ip
from libs.password import compare_password, hash_password from libs.password import compare_password, hash_password
from libs.rsa import generate_key_pair from libs.rsa import generate_key_pair
from models.account import * from models.account import *
from tasks.mail_invite_member_task import send_invite_member_mail_task
class AccountService: class AccountService:
...@@ -332,8 +336,8 @@ class TenantService: ...@@ -332,8 +336,8 @@ class TenantService:
class RegisterService: class RegisterService:
@staticmethod @classmethod
def register(email, name, password: str = None, open_id: str = None, provider: str = None) -> Account: def register(cls, email, name, password: str = None, open_id: str = None, provider: str = None) -> Account:
db.session.begin_nested() db.session.begin_nested()
"""Register account""" """Register account"""
try: try:
...@@ -359,9 +363,9 @@ class RegisterService: ...@@ -359,9 +363,9 @@ class RegisterService:
return account return account
@staticmethod @classmethod
def invite_new_member(tenant: Tenant, email: str, role: str = 'normal', def invite_new_member(cls, tenant: Tenant, email: str, role: str = 'normal',
inviter: Account = None) -> TenantAccountJoin: inviter: Account = None) -> str:
"""Invite new member""" """Invite new member"""
account = Account.query.filter_by(email=email).first() account = Account.query.filter_by(email=email).first()
...@@ -380,5 +384,71 @@ class RegisterService: ...@@ -380,5 +384,71 @@ class RegisterService:
if ta: if ta:
raise AccountAlreadyInTenantError("Account already in tenant.") raise AccountAlreadyInTenantError("Account already in tenant.")
ta = TenantService.create_tenant_member(tenant, account, role) TenantService.create_tenant_member(tenant, account, role)
return ta
token = cls.generate_invite_token(tenant, account)
# send email
send_invite_member_mail_task.delay(
to=email,
token=cls.generate_invite_token(tenant, account),
inviter_name=inviter.name if inviter else 'Dify',
workspace_id=tenant.id,
workspace_name=tenant.name,
)
return token
@classmethod
def generate_invite_token(cls, tenant: Tenant, account: Account) -> str:
token = str(uuid.uuid4())
email_hash = sha256(account.email.encode()).hexdigest()
cache_key = 'member_invite_token:{}, {}:{}'.format(str(tenant.id), email_hash, token)
redis_client.setex(cache_key, 600, str(account.id))
return token
@classmethod
def revoke_token(cls, workspace_id: str, email: str, token: str):
email_hash = sha256(email.encode()).hexdigest()
cache_key = 'member_invite_token:{}, {}:{}'.format(workspace_id, email_hash, token)
redis_client.delete(cache_key)
@classmethod
def get_account_if_token_valid(cls, workspace_id: str, email: str, token: str) -> Optional[Account]:
tenant = db.session.query(Tenant).filter(
Tenant.id == workspace_id,
Tenant.status == 'normal'
).first()
if not tenant:
return None
tenant_account = db.session.query(Account, TenantAccountJoin.role).join(
TenantAccountJoin, Account.id == TenantAccountJoin.account_id
).filter(Account.email == email, TenantAccountJoin.tenant_id == tenant.id).first()
if not tenant_account:
return None
account_id = cls._get_account_id_by_invite_token(workspace_id, email, token)
if not account_id:
return None
account = tenant_account[0]
if not account:
return None
if account_id != str(account.id):
return None
return account
@classmethod
def _get_account_id_by_invite_token(cls, workspace_id: str, email: str, token: str) -> Optional[str]:
email_hash = sha256(email.encode()).hexdigest()
cache_key = 'member_invite_token:{}, {}:{}'.format(workspace_id, email_hash, token)
account_id = redis_client.get(cache_key)
if not account_id:
return None
return account_id.decode('utf-8')
import logging
import time
import click
from celery import shared_task
from flask import current_app
from extensions.ext_mail import mail
@shared_task
def send_invite_member_mail_task(to: str, token: str, inviter_name: str, workspace_id: str, workspace_name: str):
"""
Async Send invite member mail
:param to
:param token
:param inviter_name
:param workspace_id
:param workspace_name
Usage: send_invite_member_mail_task.delay(to, token, inviter_name, workspace_id, workspace_name)
"""
if not mail.is_inited():
return
logging.info(click.style('Start send invite member mail to {} in workspace {}'.format(to, workspace_name),
fg='green'))
start_at = time.perf_counter()
try:
mail.send(
to=to,
subject="{} invited you to join {}".format(inviter_name, workspace_name),
html="""<p>Hi there,</p>
<p>{inviter_name} invited you to join {workspace_name}.</p>
<p>Click <a href="{url}">here</a> to join.</p>
<p>Thanks,</p>
<p>Dify Team</p>""".format(inviter_name=inviter_name, workspace_name=workspace_name,
url='{}/activate?workspace_id={}&email={}&token={}'.format(
current_app.config.get("CONSOLE_URL"),
workspace_id,
to,
token)
)
)
end_at = time.perf_counter()
logging.info(
click.style('Send invite member mail to {} succeeded: latency: {}'.format(to, end_at - start_at),
fg='green'))
except Exception:
logging.exception("Send invite member mail to {} failed".format(to))
...@@ -93,6 +93,12 @@ services: ...@@ -93,6 +93,12 @@ services:
QDRANT_URL: 'https://your-qdrant-cluster-url.qdrant.tech/' QDRANT_URL: 'https://your-qdrant-cluster-url.qdrant.tech/'
# The Qdrant API key. # The Qdrant API key.
QDRANT_API_KEY: 'ak-difyai' QDRANT_API_KEY: 'ak-difyai'
# Mail configuration, support: resend
MAIL_TYPE: ''
# default send from email address, if not specified
MAIL_DEFAULT_SEND_FROM: 'YOUR EMAIL FROM (eg: no-reply <no-reply@dify.ai>)'
# the api-key for resend (https://resend.com)
RESEND_API_KEY: ''
# The DSN for Sentry error reporting. If not set, Sentry error reporting will be disabled. # The DSN for Sentry error reporting. If not set, Sentry error reporting will be disabled.
SENTRY_DSN: '' SENTRY_DSN: ''
# The sample rate for Sentry events. Default: `1.0` # The sample rate for Sentry events. Default: `1.0`
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment