chore: updated sign-in workflows for cloud and self-hosted instances (#2994)

* chore: update onboarding workflow

* dev: update user count tasks

* fix: forgot password endpoint

* dev: instance and onboarding updates

* chore: update sign-in workflow for cloud and self-hosted instances (#2993)

* chore: updated auth services

* chore: new signin workflow updated

* chore: updated content

* chore: instance admin setup

* dev: update instance verification task

* dev: run the instance verification task every 4 hours

* dev: update migrations

* chore: update latest features image

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
This commit is contained in:
Aaryan Khandelwal 2023-12-06 14:22:59 +05:30 committed by sriram veeraghanta
parent f481957818
commit be2cf2e842
53 changed files with 1017 additions and 1368 deletions

View file

@ -7,6 +7,7 @@ from plane.app.views import (
# Authentication
SignInEndpoint,
SignOutEndpoint,
MagicGenerateEndpoint,
MagicSignInEndpoint,
OauthEndpoint,
EmailCheckEndpoint,
@ -30,6 +31,7 @@ urlpatterns = [
path("sign-in/", SignInEndpoint.as_view(), name="sign-in"),
path("sign-out/", SignOutEndpoint.as_view(), name="sign-out"),
# magic sign in
path("magic-generate/", MagicGenerateEndpoint.as_view(), name="magic-generate"),
path("magic-sign-in/", MagicSignInEndpoint.as_view(), name="magic-sign-in"),
path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
# Password Manipulation

View file

@ -87,6 +87,7 @@ from .auth_extended import (
ChangePasswordEndpoint,
SetUserPasswordEndpoint,
EmailCheckEndpoint,
MagicGenerateEndpoint,
)

View file

@ -37,9 +37,9 @@ from plane.bgtasks.forgot_password_task import forgot_password
from plane.license.models import Instance, InstanceConfiguration
from plane.settings.redis import redis_instance
from plane.bgtasks.magic_link_code_task import magic_link
from plane.bgtasks.user_count_task import update_user_instance_user_count
from plane.bgtasks.event_tracking_task import auth_events
def get_tokens_for_user(user):
refresh = RefreshToken.for_user(user)
return (
@ -108,13 +108,16 @@ class ForgotPasswordEndpoint(BaseAPIView):
try:
validate_email(email)
except ValidationError:
return Response({"error": "Please enter a valid email"}, status=status.HTTP_400_BAD_REQUEST)
return Response(
{"error": "Please enter a valid email"},
status=status.HTTP_400_BAD_REQUEST,
)
# Get the user
user = User.objects.filter(email=email).first()
if user:
# Get the reset token for user
uidb64, token = get_tokens_for_user(user=user)
uidb64, token = generate_password_token(user=user)
current_site = request.META.get("HTTP_ORIGIN")
# send the forgot password email
forgot_password.delay(
@ -130,7 +133,9 @@ class ForgotPasswordEndpoint(BaseAPIView):
class ResetPasswordEndpoint(BaseAPIView):
permission_classes = [AllowAny,]
permission_classes = [
AllowAny,
]
def post(self, request, uidb64, token):
try:
@ -219,6 +224,89 @@ class SetUserPasswordEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_200_OK)
class MagicGenerateEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def post(self, request):
email = request.data.get("email", False)
# Check the instance registration
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
return Response(
{"error": "Instance is not configured"},
status=status.HTTP_400_BAD_REQUEST,
)
if not email:
return Response(
{"error": "Please provide a valid email address"},
status=status.HTTP_400_BAD_REQUEST,
)
# Clean up the email
email = email.strip().lower()
validate_email(email)
# check if the email exists not
if not User.objects.filter(email=email).exists():
# Create a user
_ = User.objects.create(
email=email,
username=uuid.uuid4().hex,
password=make_password(uuid.uuid4().hex),
is_password_autoset=True,
)
## Generate a random token
token = (
"".join(random.choices(string.ascii_lowercase, k=4))
+ "-"
+ "".join(random.choices(string.ascii_lowercase, k=4))
+ "-"
+ "".join(random.choices(string.ascii_lowercase, k=4))
)
ri = redis_instance()
key = "magic_" + str(email)
# Check if the key already exists in python
if ri.exists(key):
data = json.loads(ri.get(key))
current_attempt = data["current_attempt"] + 1
if data["current_attempt"] > 2:
return Response(
{"error": "Max attempts exhausted. Please try again later."},
status=status.HTTP_400_BAD_REQUEST,
)
value = {
"current_attempt": current_attempt,
"email": email,
"token": token,
}
expiry = 600
ri.set(key, json.dumps(value), ex=expiry)
else:
value = {"current_attempt": 0, "email": email, "token": token}
expiry = 600
ri.set(key, json.dumps(value), ex=expiry)
# If the smtp is configured send through here
current_site = request.META.get("HTTP_ORIGIN")
magic_link.delay(email, key, token, current_site)
return Response({"key": key}, status=status.HTTP_200_OK)
class EmailCheckEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
@ -237,16 +325,19 @@ class EmailCheckEndpoint(BaseAPIView):
instance_configuration = InstanceConfiguration.objects.values("key", "value")
email = request.data.get("email", False)
type = request.data.get("type", "magic_code")
if not email:
return Response({"error": "Email is required"}, status=status.HTTP_400_BAD_REQUEST)
return Response(
{"error": "Email is required"}, status=status.HTTP_400_BAD_REQUEST
)
# validate the email
try:
validate_email(email)
except ValidationError:
return Response({"error": "Email is not valid"}, status=status.HTTP_400_BAD_REQUEST)
return Response(
{"error": "Email is not valid"}, status=status.HTTP_400_BAD_REQUEST
)
# Check if the user exists
user = User.objects.filter(email=email).first()
@ -281,71 +372,59 @@ class EmailCheckEndpoint(BaseAPIView):
is_password_autoset=True,
)
# Update instance user count
update_user_instance_user_count.delay()
# Case when the user selects magic code
if type == "magic_code":
if not bool(get_configuration_value(
if not bool(
get_configuration_value(
instance_configuration,
"ENABLE_MAGIC_LINK_LOGIN",
os.environ.get("ENABLE_MAGIC_LINK_LOGIN")),
):
return Response(
{"error": "Magic link sign in is disabled."},
status=status.HTTP_400_BAD_REQUEST,
)
# Send event
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium="MAGIC_LINK",
first_time=True,
)
key, token, current_attempt = generate_magic_token(email=email)
if not current_attempt:
return Response({"error": "Max attempts exhausted. Please try again later."}, status=status.HTTP_400_BAD_REQUEST)
# Trigger the email
magic_link.delay(email, "magic_" + str(email), token, current_site)
return Response({"is_password_autoset": user.is_password_autoset}, status=status.HTTP_200_OK)
else:
# Get the uidb64 and token for the user
uidb64, token = generate_password_token(user=user)
forgot_password.delay(
user.first_name, user.email, uidb64, token, current_site
os.environ.get("ENABLE_MAGIC_LINK_LOGIN"),
),
):
return Response(
{"error": "Magic link sign in is disabled."},
status=status.HTTP_400_BAD_REQUEST,
)
# Send event
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium="EMAIL",
first_time=True,
)
# Automatically send the email
return Response({"is_password_autoset": user.is_password_autoset}, status=status.HTTP_200_OK)
# Send event
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium="MAGIC_LINK",
first_time=True,
)
key, token, current_attempt = generate_magic_token(email=email)
if not current_attempt:
return Response(
{"error": "Max attempts exhausted. Please try again later."},
status=status.HTTP_400_BAD_REQUEST,
)
# Trigger the email
magic_link.delay(email, "magic_" + str(email), token, current_site)
return Response(
{"is_password_autoset": user.is_password_autoset, "is_existing": False},
status=status.HTTP_200_OK,
)
# Existing user
else:
if type == "magic_code":
if user.is_password_autoset:
## Generate a random token
if not bool(get_configuration_value(
instance_configuration,
"ENABLE_MAGIC_LINK_LOGIN",
os.environ.get("ENABLE_MAGIC_LINK_LOGIN")),
if not bool(
get_configuration_value(
instance_configuration,
"ENABLE_MAGIC_LINK_LOGIN",
os.environ.get("ENABLE_MAGIC_LINK_LOGIN"),
),
):
return Response(
{"error": "Magic link sign in is disabled."},
status=status.HTTP_400_BAD_REQUEST,
)
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
auth_events.delay(
user=user.id,
@ -356,15 +435,24 @@ class EmailCheckEndpoint(BaseAPIView):
medium="MAGIC_LINK",
first_time=False,
)
# Generate magic token
key, token, current_attempt = generate_magic_token(email=email)
if not current_attempt:
return Response({"error": "Max attempts exhausted. Please try again later."}, status=status.HTTP_400_BAD_REQUEST)
return Response(
{"error": "Max attempts exhausted. Please try again later."},
status=status.HTTP_400_BAD_REQUEST,
)
# Trigger the email
magic_link.delay(email, key, token, current_site)
return Response({"is_password_autoset": user.is_password_autoset}, status=status.HTTP_200_OK)
return Response(
{
"is_password_autoset": user.is_password_autoset,
"is_existing": True,
},
status=status.HTTP_200_OK,
)
else:
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
auth_events.delay(
@ -376,14 +464,12 @@ class EmailCheckEndpoint(BaseAPIView):
medium="EMAIL",
first_time=False,
)
if user.is_password_autoset:
# send email
uidb64, token = generate_password_token(user=user)
forgot_password.delay(
user.first_name, user.email, uidb64, token, current_site
)
return Response({"is_password_autoset": user.is_password_autoset}, status=status.HTTP_200_OK)
else:
# User should enter password to login
return Response({"is_password_autoset": user.is_password_autoset}, status=status.HTTP_200_OK)
# User should enter password to login
return Response(
{
"is_password_autoset": user.is_password_autoset,
"is_existing": True,
},
status=status.HTTP_200_OK,
)

View file

@ -1,8 +1,6 @@
# Python imports
import os
import uuid
import random
import string
import json
# Django imports
@ -10,6 +8,7 @@ from django.utils import timezone
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.conf import settings
from django.contrib.auth.hashers import make_password
# Third party imports
from rest_framework.response import Response
@ -31,7 +30,6 @@ from plane.settings.redis import redis_instance
from plane.license.models import InstanceConfiguration, Instance
from plane.license.utils.instance_value import get_configuration_value
from plane.bgtasks.event_tracking_task import auth_events
from plane.bgtasks.user_count_task import update_user_instance_user_count
def get_tokens_for_user(user):
@ -58,7 +56,6 @@ class SignUpEndpoint(BaseAPIView):
email = request.data.get("email", False)
password = request.data.get("password", False)
## Raise exception if any of the above are missing
if not email or not password:
return Response(
@ -66,8 +63,8 @@ class SignUpEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
# Validate the email
email = email.strip().lower()
try:
validate_email(email)
except ValidationError as e:
@ -106,6 +103,7 @@ class SignUpEndpoint(BaseAPIView):
user.set_password(password)
# settings last actives for the user
user.is_password_autoset = False
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR")
@ -120,9 +118,6 @@ class SignUpEndpoint(BaseAPIView):
"refresh_token": refresh_token,
}
# Update instance user count
update_user_instance_user_count.delay()
return Response(data, status=status.HTTP_200_OK)
@ -148,8 +143,8 @@ class SignInEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
# Validate email
email = email.strip().lower()
try:
validate_email(email)
except ValidationError as e:
@ -161,22 +156,45 @@ class SignInEndpoint(BaseAPIView):
# Get the user
user = User.objects.filter(email=email).first()
# User is not present in db
if user is None:
return Response(
{
"error": "Sorry, we could not find a user with the provided credentials. Please try again."
},
status=status.HTTP_403_FORBIDDEN,
)
# Existing user
if user:
# Check user password
if not user.check_password(password):
return Response(
{
"error": "Sorry, we could not find a user with the provided credentials. Please try again."
},
status=status.HTTP_403_FORBIDDEN,
)
# Check user password
if not user.check_password(password):
return Response(
{
"error": "Sorry, we could not find a user with the provided credentials. Please try again."
},
status=status.HTTP_403_FORBIDDEN,
# Create the user
else:
# Get the configurations
instance_configuration = InstanceConfiguration.objects.values("key", "value")
# Create the user
if (
get_configuration_value(
instance_configuration,
"ENABLE_SIGNUP",
os.environ.get("ENABLE_SIGNUP", "0"),
)
== "0"
and not WorkspaceMemberInvite.objects.filter(
email=email,
).exists()
):
return Response(
{
"error": "New account creation is disabled. Please contact your site administrator"
},
status=status.HTTP_400_BAD_REQUEST,
)
user = User.objects.create(
email=email,
username=uuid.uuid4().hex,
password=make_password(password),
is_password_autoset=False,
)
# settings last active for the user

View file

@ -11,7 +11,7 @@ from rest_framework.response import Response
# Module imports
from .base import BaseAPIView
from plane.license.models import Instance, InstanceConfiguration
from plane.license.models import InstanceConfiguration
from plane.license.utils.instance_value import get_configuration_value
@ -104,4 +104,6 @@ class ConfigurationEndpoint(BaseAPIView):
data["file_size_limit"] = float(os.environ.get("FILE_SIZE_LIMIT", 5242880))
data["is_self_managed"] = bool(int(os.environ.get("IS_SELF_MANAGED", "1")))
return Response(data, status=status.HTTP_200_OK)

View file

@ -32,7 +32,6 @@ from plane.bgtasks.event_tracking_task import auth_events
from .base import BaseAPIView
from plane.license.models import InstanceConfiguration, Instance
from plane.license.utils.instance_value import get_configuration_value
from plane.bgtasks.user_count_task import update_user_instance_user_count
def get_tokens_for_user(user):
@ -439,6 +438,4 @@ class OauthEndpoint(BaseAPIView):
"refresh_token": refresh_token,
}
# Update the user count
update_user_instance_user_count.delay()
return Response(data, status=status.HTTP_201_CREATED)

View file

@ -66,30 +66,6 @@ def send_export_email(email, slug, csv_buffer, rows):
EMAIL_FROM,
) = get_email_configuration(instance_configuration=instance_configuration)
# Send the email if the users don't have smtp configured
if EMAIL_HOST and EMAIL_HOST_USER and EMAIL_HOST_PASSWORD:
# Check the instance registration
instance = Instance.objects.first()
headers = {
"Content-Type": "application/json",
"x-instance-id": instance.instance_id,
"x-api-key": instance.api_key,
}
payload = {
"email": email,
"slug": slug,
"rows": rows,
}
_ = requests.post(
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/analytics/",
headers=headers,
json=payload,
)
return
connection = get_connection(
host=EMAIL_HOST,
port=int(EMAIL_PORT),

View file

@ -39,32 +39,6 @@ def forgot_password(first_name, email, uidb64, token, current_site):
EMAIL_FROM,
) = get_email_configuration(instance_configuration=instance_configuration)
# Send the email if the users don't have smtp configured
if not (EMAIL_HOST and EMAIL_HOST_USER and EMAIL_HOST_PASSWORD):
# Check the instance registration
instance = Instance.objects.first()
# headers
headers = {
"Content-Type": "application/json",
"x-instance-id": instance.instance_id,
"x-api-key": instance.api_key,
}
payload = {
"abs_url": abs_url,
"first_name": first_name,
"email": email,
}
_ = requests.post(
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/forgot-password/",
headers=headers,
data=json.dumps(payload),
)
return
subject = "A new password to your Plane account has been requested"
context = {

View file

@ -26,7 +26,6 @@ from plane.db.models import (
IssueProperty,
)
from plane.bgtasks.user_welcome_task import send_welcome_slack
from plane.bgtasks.user_count_task import update_user_instance_user_count
@shared_task
@ -121,9 +120,6 @@ def service_importer(service, importer_id):
batch_size=100,
ignore_conflicts=True,
)
# Update instance user count
update_user_instance_user_count.delay()
# Check if sync config is on for github importers
if service == "github" and importer.config.get("sync", False):

View file

@ -34,30 +34,6 @@ def magic_link(email, key, token, current_site):
EMAIL_FROM,
) = get_email_configuration(instance_configuration=instance_configuration)
# Send the email if the users don't have smtp configured
if not (EMAIL_HOST and EMAIL_HOST_USER and EMAIL_HOST_PASSWORD):
# Check the instance registration
instance = Instance.objects.first()
headers = {
"Content-Type": "application/json",
"x-instance-id": instance.instance_id,
"x-api-key": instance.api_key,
}
payload = {
"token": token,
"email": email,
}
_ = requests.post(
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/magic-code/",
headers=headers,
data=json.dumps(payload),
)
return
# Send the mail
subject = f"Your unique Plane login code is {token}"
context = {"code": token, "email": email}

View file

@ -14,7 +14,7 @@ from sentry_sdk import capture_exception
# Module imports
from plane.db.models import Project, User, ProjectMemberInvite
from plane.license.models import InstanceConfiguration
from plane.license.utils.instance_value import get_configuration_value
from plane.license.utils.instance_value import get_email_configuration
@shared_task
def project_invitation(email, project_id, token, current_site, invitor):
@ -48,42 +48,27 @@ def project_invitation(email, project_id, token, current_site, invitor):
# Configure email connection from the database
instance_configuration = InstanceConfiguration.objects.filter(key__startswith='EMAIL_').values("key", "value")
(
EMAIL_HOST,
EMAIL_HOST_USER,
EMAIL_HOST_PASSWORD,
EMAIL_PORT,
EMAIL_USE_TLS,
EMAIL_FROM,
) = get_email_configuration(instance_configuration=instance_configuration)
connection = get_connection(
host=get_configuration_value(
instance_configuration, "EMAIL_HOST", os.environ.get("EMAIL_HOST")
),
port=int(
get_configuration_value(
instance_configuration, "EMAIL_PORT", os.environ.get("EMAIL_PORT")
)
),
username=get_configuration_value(
instance_configuration,
"EMAIL_HOST_USER",
os.environ.get("EMAIL_HOST_USER"),
),
password=get_configuration_value(
instance_configuration,
"EMAIL_HOST_PASSWORD",
os.environ.get("EMAIL_HOST_PASSWORD"),
),
use_tls=bool(
get_configuration_value(
instance_configuration,
"EMAIL_USE_TLS",
os.environ.get("EMAIL_USE_TLS", "1"),
)
),
host=EMAIL_HOST,
port=int(EMAIL_PORT),
username=EMAIL_HOST_USER,
password=EMAIL_HOST_PASSWORD,
use_tls=bool(EMAIL_USE_TLS),
)
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=get_configuration_value(
instance_configuration,
"EMAIL_FROM",
os.environ.get("EMAIL_FROM", "Team Plane <team@mailer.plane.so>"),
),
from_email=EMAIL_FROM,
to=[email],
connection=connection,
)

View file

@ -1,45 +0,0 @@
# Python imports
import json
import requests
import os
# django imports
from django.conf import settings
# Third party imports
from celery import shared_task
from sentry_sdk import capture_exception
# Module imports
from plane.db.models import User
from plane.license.models import Instance
@shared_task
def update_user_instance_user_count():
try:
instance_users = User.objects.filter(is_bot=False).count()
instance = Instance.objects.update(user_count=instance_users)
# Update the count in the license engine
payload = {
"user_count": User.objects.count(),
}
# Save the user in control center
headers = {
"Content-Type": "application/json",
"x-instance-id": instance.instance_id,
"x-api-key": instance.api_key,
}
# Update the license engine
_ = requests.post(
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
headers=headers,
data=json.dumps(payload),
)
except Exception as e:
if settings.DEBUG:
print(e)
capture_exception(e)

View file

@ -50,31 +50,6 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
EMAIL_FROM,
) = get_email_configuration(instance_configuration=instance_configuration)
# Send the email if the users don't have smtp configured
if not (EMAIL_HOST and EMAIL_HOST_USER and EMAIL_HOST_PASSWORD):
# Check the instance registration
instance = Instance.objects.first()
headers = {
"Content-Type": "application/json",
"x-instance-id": instance.instance_id,
"x-api-key": instance.api_key,
}
payload = {
"user": user.first_name or user.display_name or user.email,
"workspace_name": workspace.name,
"invitation_url": abs_url,
"email": email,
}
_ = requests.post(
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/workspace-invitation/",
headers=headers,
data=json.dumps(payload),
)
return
# Subject of the email
subject = f"{user.first_name or user.display_name or user.email} has invited you to join them in {workspace.name} on Plane"

View file

@ -28,9 +28,13 @@ app.conf.beat_schedule = {
"task": "plane.bgtasks.file_asset_task.delete_file_asset",
"schedule": crontab(hour=0, minute=0),
},
"check-instance-verification": {
"task": "plane.license.bgtasks.instance_verification_task.instance_verification_task",
"schedule": crontab(minute=0, hour='*/4'),
},
}
# Load task modules from all registered Django app configs.
app.autodiscover_tasks()
app.conf.beat_scheduler = 'django_celery_beat.schedulers.DatabaseScheduler'
app.conf.beat_scheduler = "django_celery_beat.schedulers.DatabaseScheduler"

View file

@ -43,7 +43,7 @@ class InstanceConfigurationSerializer(BaseSerializer):
def to_representation(self, instance):
data = super().to_representation(instance)
# Decrypt secrets value
if instance.key in ["OPENAI_API_KEY", "GITHUB_CLIENT_SECRET", "EMAIL_HOST_PASSWORD", "UNSPLASH_ACESS_KEY"] and instance.value is not None:
if instance.is_encrypted and instance.value is not None:
data["value"] = decrypt_data(instance.value)
return data
return data

View file

@ -2,8 +2,6 @@ from .instance import (
InstanceEndpoint,
InstanceAdminEndpoint,
InstanceConfigurationEndpoint,
AdminSetupMagicSignInEndpoint,
InstanceAdminSignInEndpoint,
SignUpScreenVisitedEndpoint,
AdminMagicSignInGenerateEndpoint,
AdminSetUserPasswordEndpoint,
)

View file

@ -27,14 +27,11 @@ from plane.license.api.serializers import (
InstanceAdminSerializer,
InstanceConfigurationSerializer,
)
from plane.app.serializers import UserSerializer
from plane.license.api.permissions import (
InstanceAdminPermission,
)
from plane.db.models import User
from plane.license.utils.encryption import encrypt_data
from plane.settings.redis import redis_instance
from plane.bgtasks.magic_link_code_task import magic_link
class InstanceEndpoint(BaseAPIView):
@ -47,61 +44,6 @@ class InstanceEndpoint(BaseAPIView):
AllowAny(),
]
def post(self, request):
# Check if the instance is registered
instance = Instance.objects.first()
# If instance is None then register this instance
if instance is None:
with open("package.json", "r") as file:
# Load JSON content from the file
data = json.load(file)
headers = {"Content-Type": "application/json"}
payload = {
"instance_key":settings.INSTANCE_KEY,
"version": data.get("version", 0.1),
"machine_signature": os.environ.get("MACHINE_SIGNATURE"),
"user_count": User.objects.filter(is_bot=False).count(),
}
response = requests.post(
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
headers=headers,
data=json.dumps(payload),
)
if response.status_code == 201:
data = response.json()
# Create instance
instance = Instance.objects.create(
instance_name="Plane Free",
instance_id=data.get("id"),
license_key=data.get("license_key"),
api_key=data.get("api_key"),
version=data.get("version"),
last_checked_at=timezone.now(),
user_count=data.get("user_count", 0),
)
serializer = InstanceSerializer(instance)
data = serializer.data
data["is_activated"] = True
return Response(
data,
status=status.HTTP_201_CREATED,
)
return Response(
{"error": "Instance could not be registered"},
status=status.HTTP_400_BAD_REQUEST,
)
else:
return Response(
{"message": "Instance already registered"},
status=status.HTTP_200_OK,
)
def get(self, request):
instance = Instance.objects.first()
# get the instance
@ -122,24 +64,6 @@ class InstanceEndpoint(BaseAPIView):
serializer = InstanceSerializer(instance, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
# Save the user in control center
headers = {
"Content-Type": "application/json",
"x-instance-id": instance.instance_id,
"x-api-key": instance.api_key,
}
# Update instance settings in the license engine
_ = requests.patch(
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
headers=headers,
data=json.dumps(
{
"is_support_required": serializer.data["is_support_required"],
"is_telemetry_enabled": serializer.data["is_telemetry_enabled"],
"version": serializer.data["version"],
}
),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -212,12 +136,7 @@ class InstanceConfigurationEndpoint(BaseAPIView):
bulk_configurations = []
for configuration in configurations:
value = request.data.get(configuration.key, configuration.value)
if value is not None and configuration.key in [
"OPENAI_API_KEY",
"GITHUB_CLIENT_SECRET",
"EMAIL_HOST_PASSWORD",
"UNSPLASH_ACESS_KEY",
]:
if configuration.is_encrypted:
configuration.value = encrypt_data(value)
else:
configuration.value = value
@ -239,15 +158,13 @@ def get_tokens_for_user(user):
)
class AdminMagicSignInGenerateEndpoint(BaseAPIView):
class InstanceAdminSignInEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def post(self, request):
email = request.data.get("email", False)
# Check the instance registration
# Check instance first
instance = Instance.objects.first()
if instance is None:
return Response(
@ -255,193 +172,63 @@ class AdminMagicSignInGenerateEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
# check if the instance is already activated
if InstanceAdmin.objects.first():
return Response(
{"error": "Admin for this instance is already registered"},
status=status.HTTP_400_BAD_REQUEST,
)
if not email:
return Response(
{"error": "Please provide a valid email address"},
status=status.HTTP_400_BAD_REQUEST,
)
# Clean up
email = email.strip().lower()
validate_email(email)
# check if the email exists
if not User.objects.filter(email=email).exists():
# Create a user
_ = User.objects.create(
email=email,
username=uuid.uuid4().hex,
password=make_password(uuid.uuid4().hex),
is_password_autoset=True,
)
## Generate a random token
token = (
"".join(random.choices(string.ascii_lowercase, k=4))
+ "-"
+ "".join(random.choices(string.ascii_lowercase, k=4))
+ "-"
+ "".join(random.choices(string.ascii_lowercase, k=4))
)
ri = redis_instance()
key = "magic_" + str(email)
# Check if the key already exists in python
if ri.exists(key):
data = json.loads(ri.get(key))
current_attempt = data["current_attempt"] + 1
if data["current_attempt"] > 2:
return Response(
{"error": "Max attempts exhausted. Please try again later."},
status=status.HTTP_400_BAD_REQUEST,
)
value = {
"current_attempt": current_attempt,
"email": email,
"token": token,
}
expiry = 600
ri.set(key, json.dumps(value), ex=expiry)
else:
value = {"current_attempt": 0, "email": email, "token": token}
expiry = 600
ri.set(key, json.dumps(value), ex=expiry)
# If the smtp is configured send through here
current_site = request.META.get("HTTP_ORIGIN")
magic_link.delay(email, key, token, current_site)
return Response({"key": key}, status=status.HTTP_200_OK)
class AdminSetupMagicSignInEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def post(self, request):
user_token = request.data.get("token", "").strip()
key = request.data.get("key", "").strip().lower()
if not key or user_token == "":
return Response(
{"error": "User token and key are required"},
status=status.HTTP_400_BAD_REQUEST,
)
if InstanceAdmin.objects.first():
return Response(
{"error": "Admin for this instance is already registered"},
status=status.HTTP_400_BAD_REQUEST,
)
ri = redis_instance()
if ri.exists(key):
data = json.loads(ri.get(key))
token = data["token"]
email = data["email"]
if str(token) == str(user_token):
# get the user
user = User.objects.get(email=email)
# get the email
user.is_active = True
user.is_email_verified = True
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR")
user.last_login_uagent = request.META.get("HTTP_USER_AGENT")
user.token_updated_at = timezone.now()
user.save()
access_token, refresh_token = get_tokens_for_user(user)
data = {
"access_token": access_token,
"refresh_token": refresh_token,
}
return Response(data, status=status.HTTP_200_OK)
else:
return Response(
{"error": "Your login code was incorrect. Please try again."},
status=status.HTTP_400_BAD_REQUEST,
)
else:
return Response(
{"error": "The magic code/link has expired please try again"},
status=status.HTTP_400_BAD_REQUEST,
)
class AdminSetUserPasswordEndpoint(BaseAPIView):
def post(self, request):
user = User.objects.get(pk=request.user.id)
# Get the email and password from all the user
email = request.data.get("email", False)
password = request.data.get("password", False)
# If the user password is not autoset then return error
if not user.is_password_autoset:
# return error if the email and password is not present
if not email or not password:
return Response(
{
"error": "Your password is already set please change your password from profile"
},
{"error": "Email and password are required"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check password validation
if not password and len(str(password)) < 8:
# Validate the email
email = email.strip().lower()
try:
validate_email(email)
except ValidationError as e:
return Response(
{"error": "Password is not valid"}, status=status.HTTP_400_BAD_REQUEST
)
instance = Instance.objects.first()
if instance is None:
return Response(
{"error": "Instance is not configured"},
{"error": "Please provide a valid email address."},
status=status.HTTP_400_BAD_REQUEST,
)
# Save the user in control center
headers = {
"Content-Type": "application/json",
"x-instance-id": instance.instance_id,
"x-api-key": instance.api_key,
}
_ = requests.patch(
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
headers=headers,
data=json.dumps({"is_setup_done": True}),
)
# Check if already a user exists or not
user = User.objects.filter(email=email).first()
# Also register the user as admin
_ = requests.post(
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/register/",
headers=headers,
data=json.dumps(
{
"email": str(user.email),
"signup_mode": "MAGIC_CODE",
"is_admin": True,
}
),
)
# Existing user
if user:
# Check user password
if not user.check_password(password):
return Response(
{
"error": "Sorry, we could not find a user with the provided credentials. Please try again."
},
status=status.HTTP_403_FORBIDDEN,
)
else:
user = User.objects.create(
email=email,
username=uuid.uuid4().hex,
password=make_password(password),
is_password_autoset=False,
)
# settings last active for the user
user.is_active = True
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR")
user.last_login_uagent = request.META.get("HTTP_USER_AGENT")
user.token_updated_at = timezone.now()
user.save()
# Register the user as an instance admin
_ = InstanceAdmin.objects.create(
@ -452,12 +239,13 @@ class AdminSetUserPasswordEndpoint(BaseAPIView):
instance.is_setup_done = True
instance.save()
# Set the user password
user.set_password(password)
user.is_password_autoset = False
user.save()
serializer = UserSerializer(user)
return Response(serializer.data, status=status.HTTP_200_OK)
# get tokens for user
access_token, refresh_token = get_tokens_for_user(user)
data = {
"access_token": access_token,
"refresh_token": refresh_token,
}
return Response(data, status=status.HTTP_200_OK)
class SignUpScreenVisitedEndpoint(BaseAPIView):
@ -467,27 +255,11 @@ class SignUpScreenVisitedEndpoint(BaseAPIView):
def post(self, request):
instance = Instance.objects.first()
if instance is None:
return Response(
{"error": "Instance is not configured"},
status=status.HTTP_400_BAD_REQUEST,
)
if not instance.is_signup_screen_visited:
instance.is_signup_screen_visited = True
instance.save()
# set the headers
headers = {
"Content-Type": "application/json",
"x-instance-id": instance.instance_id,
"x-api-key": instance.api_key,
}
# create the payload
payload = {"is_signup_screen_visited": True}
_ = requests.patch(
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
headers=headers,
data=json.dumps(payload),
)
instance.is_signup_screen_visited = True
instance.save()
return Response(status=status.HTTP_204_NO_CONTENT)

View file

@ -0,0 +1,136 @@
# Python imports
import os
import json
import requests
# Django imports
from django.conf import settings
# Third party imports
from celery import shared_task
# Module imports
from plane.db.models import User
from plane.license.models import Instance, InstanceAdmin
def instance_verification(instance):
with open("package.json", "r") as file:
# Load JSON content from the file
data = json.load(file)
headers = {"Content-Type": "application/json"}
payload = {
"instance_key": settings.INSTANCE_KEY,
"version": data.get("version", 0.1),
"machine_signature": os.environ.get("MACHINE_SIGNATURE", "machine-signature"),
"user_count": User.objects.filter(is_bot=False).count(),
}
# Register the instance
response = requests.post(
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
headers=headers,
data=json.dumps(payload),
timeout=30,
)
# check response status
if response.status_code == 201:
data = response.json()
# Update instance
instance.instance_id = data.get("id")
instance.license_key = data.get("license_key")
instance.api_key = data.get("api_key")
instance.version = data.get("version")
instance.user_count = data.get("user_count", 0)
instance.is_verified = True
instance.save()
else:
return
def admin_verification(instance):
# Save the user in control center
headers = {
"Content-Type": "application/json",
"x-instance-id": instance.instance_id,
"x-api-key": instance.api_key,
}
# Get all the unverified instance admins
instance_admins = InstanceAdmin.objects.filter(is_verified=False).select_related(
"user"
)
updated_instance_admin = []
# Verify the instance admin
for instance_admin in instance_admins:
instance_admin.is_verified = True
# Create the admin
response = requests.post(
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/register/",
headers=headers,
data=json.dumps(
{
"email": str(instance_admin.user.email),
"signup_mode": "EMAIL",
"is_admin": True,
}
),
timeout=30,
)
updated_instance_admin.append(instance_admin)
# update all the instance admins
InstanceAdmin.objects.bulk_update(
updated_instance_admin, ["is_verified"], batch_size=10
)
return
def instance_user_count(instance):
try:
instance_users = User.objects.filter(is_bot=False).count()
# Update the count in the license engine
payload = {
"user_count": instance_users,
}
# Save the user in control center
headers = {
"Content-Type": "application/json",
"x-instance-id": instance.instance_id,
"x-api-key": instance.api_key,
}
# Update the license engine
_ = requests.post(
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
headers=headers,
data=json.dumps(payload),
timeout=30,
)
return
except requests.RequestException:
return
@shared_task
def instance_verification_task():
try:
# Get the first instance
instance = Instance.objects.first()
# Only register instance if it is not verified
if not instance.is_verified:
instance_verification(instance=instance)
# Admin verifications
admin_verification(instance=instance)
# Update user count
instance_user_count(instance=instance)
return
except requests.RequestException:
return

View file

@ -21,84 +21,91 @@ class Command(BaseCommand):
"key": "ENABLE_SIGNUP",
"value": os.environ.get("ENABLE_SIGNUP", "1"),
"category": "AUTHENTICATION",
"is_encrypted": False,
},
{
"key": "ENABLE_EMAIL_PASSWORD",
"value": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"),
"category": "AUTHENTICATION",
"is_encrypted": False,
},
{
"key": "ENABLE_MAGIC_LINK_LOGIN",
"value": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "0"),
"category": "AUTHENTICATION",
"is_encrypted": False,
},
{
"key": "GOOGLE_CLIENT_ID",
"value": os.environ.get("GOOGLE_CLIENT_ID"),
"category": "GOOGLE",
"is_encrypted": False,
},
{
"key": "GITHUB_CLIENT_ID",
"value": os.environ.get("GITHUB_CLIENT_ID"),
"category": "GITHUB",
"is_encrypted": False,
},
{
"key": "GITHUB_CLIENT_SECRET",
"value": encrypt_data(os.environ.get("GITHUB_CLIENT_SECRET"))
if os.environ.get("GITHUB_CLIENT_SECRET")
else None,
"value": os.environ.get("GITHUB_CLIENT_SECRET"),
"category": "GITHUB",
"is_encrypted": True,
},
{
"key": "EMAIL_HOST",
"value": os.environ.get("EMAIL_HOST", ""),
"category": "SMTP",
"is_encrypted": False,
},
{
"key": "EMAIL_HOST_USER",
"value": os.environ.get("EMAIL_HOST_USER", ""),
"category": "SMTP",
"is_encrypted": False,
},
{
"key": "EMAIL_HOST_PASSWORD",
"value": encrypt_data(os.environ.get("EMAIL_HOST_PASSWORD"))
if os.environ.get("EMAIL_HOST_PASSWORD")
else None,
"value": os.environ.get("EMAIL_HOST_PASSWORD", ""),
"category": "SMTP",
"is_encrypted": True,
},
{
"key": "EMAIL_PORT",
"value": os.environ.get("EMAIL_PORT", "587"),
"category": "SMTP",
"is_encrypted": False,
},
{
"key": "EMAIL_FROM",
"value": os.environ.get("EMAIL_FROM", ""),
"category": "SMTP",
"is_encrypted": False,
},
{
"key": "EMAIL_USE_TLS",
"value": os.environ.get("EMAIL_USE_TLS", "1"),
"category": "SMTP",
"is_encrypted": False,
},
{
"key": "OPENAI_API_KEY",
"value": encrypt_data(os.environ.get("OPENAI_API_KEY"))
if os.environ.get("OPENAI_API_KEY")
else None,
"value": os.environ.get("OPENAI_API_KEY"),
"category": "OPENAI",
"is_encrypted": True,
},
{
"key": "GPT_ENGINE",
"value": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"),
"category": "SMTP",
"is_encrypted": False,
},
{
"key": "UNSPLASH_ACCESS_KEY",
"value": encrypt_data(os.environ.get("UNSPLASH_ACESS_KEY", ""))
if os.environ.get("UNSPLASH_ACESS_KEY")
else None,
"value": os.environ.get("UNSPLASH_ACESS_KEY", ""),
"category": "UNSPLASH",
"is_encrypted": True,
},
]
@ -107,8 +114,12 @@ class Command(BaseCommand):
key=item.get("key")
)
if created:
obj.value = item.get("value")
obj.category = item.get("category")
obj.is_encrypted = item.get("is_encrypted", False)
if item.get("is_encrypted", False):
obj.value = encrypt_data(item.get("value"))
else:
obj.value = item.get("value")
obj.save()
self.stdout.write(
self.style.SUCCESS(

View file

@ -1,7 +1,7 @@
# Python imports
import json
import os
import requests
import secrets
# Django imports
from django.core.management.base import BaseCommand, CommandError
@ -31,13 +31,12 @@ class Command(BaseCommand):
data = json.load(file)
machine_signature = options.get("machine_signature", False)
if not machine_signature:
raise CommandError("Machine signature is required")
# Check if machine is online
headers = {"Content-Type": "application/json"}
payload = {
"instance_key": settings.INSTANCE_KEY,
"version": data.get("version", 0.1),
@ -45,25 +44,44 @@ class Command(BaseCommand):
"user_count": User.objects.filter(is_bot=False).count(),
}
response = requests.post(
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
headers=headers,
data=json.dumps(payload),
)
if response.status_code == 201:
data = response.json()
# Create instance
instance = Instance.objects.create(
instance_name="Plane Free",
instance_id=data.get("id"),
license_key=data.get("license_key"),
api_key=data.get("api_key"),
version=data.get("version"),
last_checked_at=timezone.now(),
user_count=data.get("user_count", 0),
try:
response = requests.post(
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
headers=headers,
data=json.dumps(payload),
timeout=30
)
if response.status_code == 201:
data = response.json()
# Create instance
instance = Instance.objects.create(
instance_name="Plane Free",
instance_id=data.get("id"),
license_key=data.get("license_key"),
api_key=data.get("api_key"),
version=data.get("version"),
last_checked_at=timezone.now(),
user_count=data.get("user_count", 0),
is_verified=True,
)
self.stdout.write(
self.style.SUCCESS(
f"Instance successfully registered and verified"
)
)
return
except requests.RequestException as _e:
instance = Instance.objects.create(
instance_name="Plane Free",
instance_id=secrets.token_hex(12),
license_key=None,
api_key=secrets.token_hex(8),
version=payload.get("version"),
last_checked_at=timezone.now(),
user_count=payload.get("user_count", 0),
)
self.stdout.write(
self.style.SUCCESS(
f"Instance successfully registered"

View file

@ -1,4 +1,4 @@
# Generated by Django 4.2.7 on 2023-11-29 14:39
# Generated by Django 4.2.7 on 2023-12-06 06:49
from django.conf import settings
from django.db import migrations, models
@ -34,6 +34,7 @@ class Migration(migrations.Migration):
('is_setup_done', models.BooleanField(default=False)),
('is_signup_screen_visited', models.BooleanField(default=False)),
('user_count', models.PositiveBigIntegerField(default=0)),
('is_verified', models.BooleanField(default=False)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
],
@ -53,6 +54,7 @@ class Migration(migrations.Migration):
('key', models.CharField(max_length=100, unique=True)),
('value', models.TextField(blank=True, default=None, null=True)),
('category', models.TextField()),
('is_encrypted', models.BooleanField(default=False)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
],
@ -70,6 +72,7 @@ class Migration(migrations.Migration):
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('role', models.PositiveIntegerField(choices=[(20, 'Admin')], default=20)),
('is_verified', models.BooleanField(default=False)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='admins', to='license.instance')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),

View file

@ -30,6 +30,7 @@ class Instance(BaseModel):
is_signup_screen_visited = models.BooleanField(default=False)
# users
user_count = models.PositiveBigIntegerField(default=0)
is_verified = models.BooleanField(default=False)
class Meta:
verbose_name = "Instance"
@ -47,6 +48,7 @@ class InstanceAdmin(BaseModel):
)
instance = models.ForeignKey(Instance, on_delete=models.CASCADE, related_name="admins")
role = models.PositiveIntegerField(choices=ROLE_CHOICES, default=20)
is_verified = models.BooleanField(default=False)
class Meta:
unique_together = ["instance", "user"]
@ -61,6 +63,7 @@ class InstanceConfiguration(BaseModel):
key = models.CharField(max_length=100, unique=True)
value = models.TextField(null=True, blank=True, default=None)
category = models.TextField()
is_encrypted = models.BooleanField(default=False)
class Meta:
verbose_name = "Instance Configuration"

View file

@ -4,9 +4,7 @@ from plane.license.api.views import (
InstanceEndpoint,
InstanceAdminEndpoint,
InstanceConfigurationEndpoint,
AdminMagicSignInGenerateEndpoint,
AdminSetupMagicSignInEndpoint,
AdminSetUserPasswordEndpoint,
InstanceAdminSignInEndpoint,
SignUpScreenVisitedEndpoint,
)
@ -32,19 +30,9 @@ urlpatterns = [
name="instance-configuration",
),
path(
"instances/admins/magic-generate/",
AdminMagicSignInGenerateEndpoint.as_view(),
name="instance-admins",
),
path(
"instances/admins/magic-sign-in/",
AdminSetupMagicSignInEndpoint.as_view(),
name="instance-admins",
),
path(
"instances/admins/set-password/",
AdminSetUserPasswordEndpoint.as_view(),
name="instance-admins",
"instances/admins/sign-in/",
InstanceAdminSignInEndpoint.as_view(),
name="instance-admin-sign-in",
),
path(
"instances/admins/sign-up-screen-visited/",

View file

@ -110,7 +110,9 @@ CSRF_COOKIE_SECURE = True
CORS_ALLOW_CREDENTIALS = True
cors_origins_raw = os.environ.get("CORS_ALLOWED_ORIGINS", "")
# filter out empty strings
cors_allowed_origins = [origin.strip() for origin in cors_origins_raw.split(",") if origin.strip()]
cors_allowed_origins = [
origin.strip() for origin in cors_origins_raw.split(",") if origin.strip()
]
if cors_allowed_origins:
CORS_ALLOWED_ORIGINS = cors_allowed_origins
else:
@ -286,6 +288,7 @@ CELERY_IMPORTS = (
"plane.bgtasks.issue_automation_task",
"plane.bgtasks.exporter_expired_task",
"plane.bgtasks.file_asset_task",
"plane.license.bgtasks.instance_verification_task",
)
# Sentry Settings
@ -327,7 +330,11 @@ POSTHOG_API_KEY = os.environ.get("POSTHOG_API_KEY", False)
POSTHOG_HOST = os.environ.get("POSTHOG_HOST", False)
# License engine base url
LICENSE_ENGINE_BASE_URL = os.environ.get("LICENSE_ENGINE_BASE_URL", "https://control-center.plane.so")
LICENSE_ENGINE_BASE_URL = os.environ.get(
"LICENSE_ENGINE_BASE_URL", "https://control-center.plane.so"
)
# instance key
INSTANCE_KEY = os.environ.get("INSTANCE_KEY", "ae6517d563dfc13d8270bd45cf17b08f70b37d989128a9dab46ff687603333c3")
INSTANCE_KEY = os.environ.get(
"INSTANCE_KEY", "ae6517d563dfc13d8270bd45cf17b08f70b37d989128a9dab46ff687603333c3"
)