Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
D
dify
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ai-tech
dify
Commits
1b761988
Commit
1b761988
authored
Jun 30, 2023
by
John Wang
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: add activate link return and add activate mail send
parent
f029b188
Changes
12
Hide whitespace changes
Inline
Side-by-side
Showing
12 changed files
with
306 additions
and
14 deletions
+306
-14
.env.example
api/.env.example
+5
-0
app.py
api/app.py
+2
-1
config.py
api/config.py
+5
-0
__init__.py
api/controllers/console/__init__.py
+1
-1
activate.py
api/controllers/console/auth/activate.py
+75
-0
error.py
api/controllers/console/error.py
+6
-0
members.py
api/controllers/console/workspace/members.py
+13
-3
ext_mail.py
api/extensions/ext_mail.py
+61
-0
requirements.txt
api/requirements.txt
+3
-2
account_service.py
api/services/account_service.py
+77
-7
mail_invite_member_task.py
api/tasks/mail_invite_member_task.py
+52
-0
docker-compose.yaml
docker/docker-compose.yaml
+6
-0
No files found.
api/.env.example
View file @
1b761988
...
...
@@ -79,6 +79,11 @@ WEAVIATE_BATCH_SIZE=100
QDRANT_URL=path:storage/qdrant
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_DSN=
...
...
api/app.py
View file @
1b761988
...
...
@@ -15,7 +15,7 @@ import flask_login
from
flask_cors
import
CORS
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_login
import
login_manager
...
...
@@ -83,6 +83,7 @@ def initialize_extensions(app):
ext_celery
.
init_app
(
app
)
ext_session
.
init_app
(
app
)
ext_login
.
init_app
(
app
)
ext_mail
.
init_app
(
app
)
ext_sentry
.
init_app
(
app
)
...
...
api/config.py
View file @
1b761988
...
...
@@ -151,6 +151,11 @@ class Config:
self
.
WEB_API_CORS_ALLOW_ORIGINS
=
get_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
self
.
SENTRY_DSN
=
get_env
(
'SENTRY_DSN'
)
self
.
SENTRY_TRACES_SAMPLE_RATE
=
float
(
get_env
(
'SENTRY_TRACES_SAMPLE_RATE'
))
...
...
api/controllers/console/__init__.py
View file @
1b761988
...
...
@@ -12,7 +12,7 @@ from . import setup, version, apikey, admin
from
.app
import
app
,
site
,
completion
,
model_config
,
statistic
,
conversation
,
message
,
generator
# Import auth controllers
from
.auth
import
login
,
oauth
,
data_source_oauth
from
.auth
import
login
,
oauth
,
data_source_oauth
,
activate
# Import datasets controllers
from
.datasets
import
datasets
,
datasets_document
,
datasets_segments
,
file
,
hit_testing
,
data_source
...
...
api/controllers/console/auth/activate.py
0 → 100644
View file @
1b761988
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'
)
api/controllers/console/error.py
View file @
1b761988
...
...
@@ -18,3 +18,9 @@ class AccountNotLinkTenantError(BaseHTTPException):
error_code
=
'account_not_link_tenant'
description
=
"Account not link tenant."
code
=
403
class
AlreadyActivateError
(
BaseHTTPException
):
error_code
=
'already_activate'
description
=
"Auth Token is invalid or account already activated, please check again."
code
=
403
api/controllers/console/workspace/members.py
View file @
1b761988
# -*- coding:utf-8 -*-
from
flask
import
current_app
from
flask_login
import
login_required
,
current_user
from
flask_restful
import
Resource
,
reqparse
,
marshal_with
,
abort
,
fields
,
marshal
...
...
@@ -60,7 +60,8 @@ class MemberInviteEmailApi(Resource):
inviter
=
current_user
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
(
TenantAccountJoin
,
Account
.
id
==
TenantAccountJoin
.
account_id
)
.
filter
(
Account
.
email
==
args
[
'email'
])
.
first
()
...
...
@@ -78,7 +79,16 @@ class MemberInviteEmailApi(Resource):
# 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
):
...
...
api/extensions/ext_mail.py
0 → 100644
View file @
1b761988
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
()
api/requirements.txt
View file @
1b761988
...
...
@@ -21,7 +21,7 @@ Authlib==1.2.0
boto3~=1.26.123
tenacity==8.2.2
cachetools~=5.3.0
weaviate-client~=3.
16.2
weaviate-client~=3.
21.0
qdrant_client~=1.1.6
mailchimp-transactional~=1.0.50
scikit-learn==1.2.2
...
...
@@ -32,4 +32,5 @@ redis~=4.5.4
openpyxl==3.1.2
chardet~=5.1.0
docx2txt==0.8
pypdfium2==4.16.0
\ No newline at end of file
pypdfium2==4.16.0
resend~=0.5.1
\ No newline at end of file
api/services/account_service.py
View file @
1b761988
...
...
@@ -2,13 +2,16 @@
import
base64
import
logging
import
secrets
import
uuid
from
datetime
import
datetime
from
hashlib
import
sha256
from
typing
import
Optional
from
flask
import
session
from
sqlalchemy
import
func
from
events.tenant_event
import
tenant_was_created
from
extensions.ext_redis
import
redis_client
from
services.errors.account
import
AccountLoginError
,
CurrentPasswordIncorrectError
,
LinkAccountIntegrateError
,
\
TenantNotFound
,
AccountNotLinkTenantError
,
InvalidActionError
,
CannotOperateSelfError
,
MemberNotInTenantError
,
\
RoleAlreadyAssignedError
,
NoPermissionError
,
AccountRegisterError
,
AccountAlreadyInTenantError
...
...
@@ -16,6 +19,7 @@ from libs.helper import get_remote_ip
from
libs.password
import
compare_password
,
hash_password
from
libs.rsa
import
generate_key_pair
from
models.account
import
*
from
tasks.mail_invite_member_task
import
send_invite_member_mail_task
class
AccountService
:
...
...
@@ -332,8 +336,8 @@ class TenantService:
class
RegisterService
:
@
static
method
def
register
(
email
,
name
,
password
:
str
=
None
,
open_id
:
str
=
None
,
provider
:
str
=
None
)
->
Account
:
@
class
method
def
register
(
cls
,
email
,
name
,
password
:
str
=
None
,
open_id
:
str
=
None
,
provider
:
str
=
None
)
->
Account
:
db
.
session
.
begin_nested
()
"""Register account"""
try
:
...
...
@@ -359,9 +363,9 @@ class RegisterService:
return
account
@
static
method
def
invite_new_member
(
tenant
:
Tenant
,
email
:
str
,
role
:
str
=
'normal'
,
inviter
:
Account
=
None
)
->
TenantAccountJoin
:
@
class
method
def
invite_new_member
(
cls
,
tenant
:
Tenant
,
email
:
str
,
role
:
str
=
'normal'
,
inviter
:
Account
=
None
)
->
str
:
"""Invite new member"""
account
=
Account
.
query
.
filter_by
(
email
=
email
)
.
first
()
...
...
@@ -380,5 +384,71 @@ class RegisterService:
if
ta
:
raise
AccountAlreadyInTenantError
(
"Account already in tenant."
)
ta
=
TenantService
.
create_tenant_member
(
tenant
,
account
,
role
)
return
ta
TenantService
.
create_tenant_member
(
tenant
,
account
,
role
)
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'
)
api/tasks/mail_invite_member_task.py
0 → 100644
View file @
1b761988
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
))
docker/docker-compose.yaml
View file @
1b761988
...
...
@@ -93,6 +93,12 @@ services:
QDRANT_URL
:
'
https://your-qdrant-cluster-url.qdrant.tech/'
# The Qdrant API key.
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.
SENTRY_DSN
:
'
'
# The sample rate for Sentry events. Default: `1.0`
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment