Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ def create_pem_from_base64(base64_string, filename):
SECRET_KEY = os.environ["SECRET_KEY"]
SENDGRID_API_KEY = os.environ["SENDGRID_API_KEY"]
MONGO_URL = os.environ["MONGO_URI"]
DISCORD_CLIENT_ID = os.environ["DISCORD_CLIENT_ID"]
DISCORD_CLIENT_SECRET = os.environ["DISCORD_CLIENT_SECRET"]
DISCORD_REDIRECT_URI = os.environ["DISCORD_REDIRECT_URI"]
DISCORD_BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
DISCORD_GUILD_ID = os.environ["DISCORD_GUILD_ID"]

if IS_PROD:
RECAPTCHA_SITE_KEY = os.environ["RECAPTCHA_SITE_KEY"]
Expand Down
5 changes: 5 additions & 0 deletions dev_env.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,8 @@ $env:MONGO_URI='mongodb://root:example@localhost:27017/'
$env:SENDGRID_API_KEY='192b9bdd22ab9ed4d12e236c78afcb9a393ec15f71bbf5dc987d54727823bcbf'
$env:SECRET_KEY='192b9bdd22ab9ed4d12e236c78afcb9a393ec15f71bbf5dc987d54727823bcbf'
$env:SERVER_PORT='8080'
$env:DISCORD_CLIENT_ID='12345'
$env:DISCORD_CLIENT_SECRET='secret'
$env:DISCORD_REDIRECT_URI='uri'
$env:DISCORD_BOT_TOKEN='bot_token'
$env:DISCORD_GUILD_ID='guild_id'
5 changes: 5 additions & 0 deletions dev_env.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,8 @@ export MONGO_URI=mongodb://root:example@localhost:27017/
export SENDGRID_API_KEY=192b9bdd22ab9ed4d12e236c78afcb9a393ec15f71bbf5dc987d54727823bcbf
export SECRET_KEY=192b9bdd22ab9ed4d12e236c78afcb9a393ec15f71bbf5dc987d54727823bcbf
export SERVER_PORT=8080
export DISCORD_CLIENT_ID=12345
export DISCORD_CLIENT_SECRET=secret
export DISCORD_REDIRECT_URI=uri
export DISCORD_BOT_TOKEN=bot_token
export DISCORD_GUILD_ID=guild_id
112 changes: 112 additions & 0 deletions discord_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import requests
import config
import discord_service

from requests.exceptions import HTTPError
from app_setup import app
from task_api import login_required
from flask import request, session, redirect, url_for, flash

DISCORD_API_BASE = 'https://discord.com/api'
CLIENT_ID = config.DISCORD_CLIENT_ID
CLIENT_SECRET = config.DISCORD_CLIENT_SECRET
REDIRECT_URI = config.DISCORD_REDIRECT_URI

@app.route("/api/v2/auth/discord/connect", methods=['GET'])
@login_required
def connect_discord():
discord_oauth_url = (
"https://discord.com/oauth2/authorize"
f"?client_id={CLIENT_ID}"
f"&redirect_uri={REDIRECT_URI}"
f"&response_type=code"
f"&scope=identify"
)
return redirect(discord_oauth_url)

@app.route("/api/v2/auth/discord/disconnect", methods=['GET'])
@login_required
def disconnect_discord():
username = session['username']
discord_service.unlink_discord_id(username)
discord_service.update_discord_name_sync(username, discord_name_sync_enabled=False)
flash('Discord authentication disconnected!')
return redirect(url_for('profile'))

@app.route('/api/v2/auth/discord/redirect', methods=['GET'])
@login_required
def authorize():
code = request.args.get('code')

try:
resp = exchange_code(code)
except HTTPError as e:
if (400 <= e.response.status_code < 500):
flash('Something went wrong, please try again...')
return redirect(url_for('profile'))

access_token = resp['access_token']

resp = retrieve_discord_id(access_token)

username = session['username']
discord_service.link_discord_id(username, resp['id'])

return redirect(url_for('profile'))

@app.route("/api/v2/discord/change-default-name/", methods=['POST'])
@login_required
def change_discord_default_name():
username = session['username']
discord_default_name = request.form['discord_username']

if (2 <= len(discord_default_name) <= 12):
discord_service.update_discord_default_name(username, discord_default_name=discord_default_name)
return {'success' : True}
else:
return {'success' : False, 'error' : "Password does not meet requirements"}

@app.route("/api/v2/discord/change-name-sync/", methods=['POST'])
@login_required
def change_discord_name_sync():
username = session['username']
data = request.form['discord_name_sync_enabled']
data = False if data == 'false' else True

discord_service.update_discord_name_sync(username, data)

return {'success' : True}

def exchange_code(code):
headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept-Encoding': 'application/x-www-form-urlencoded'
}
data = {
'grant_type': 'authorization_code',
'redirect_uri': REDIRECT_URI,
'code': code
}
r = requests.post(
'%s/oauth2/token' % DISCORD_API_BASE,
data=data,
headers=headers,
auth=(CLIENT_ID, CLIENT_SECRET)
)

r.raise_for_status()

return r.json()

def retrieve_discord_id(access_token):
headers = {
'Authorization': 'Bearer ' + access_token
}
r = requests.get(
f'{DISCORD_API_BASE}/users/@me',
headers=headers,
)

r.raise_for_status()

return r.json()
121 changes: 121 additions & 0 deletions discord_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@

import config
import requests

from app_setup import app
from dataclasses import dataclass, asdict

user_info_db = config.MONGO_CLIENT["TaskAppLoginDB"]
task_list_db = config.MONGO_CLIENT["TaskApp"]

DISCORD_API_BASE = 'https://discord.com/api/v10'
BOT_TOKEN = config.DISCORD_BOT_TOKEN
GUILD_ID = config.DISCORD_GUILD_ID

@dataclass
class DiscordAuthInfo:
discord_user_id: int
discord_username_default: str

def get_discord_auth_info(username: str) -> DiscordAuthInfo:
uidb = user_info_db['users']
users = list(uidb.find({'username': username}))
if len(users) == 0:
raise Exception("No user found with username " + username)
user_data = users[0]
return convert_database_auth_info(user_data)

def convert_database_auth_info(user_data: dict) -> DiscordAuthInfo:
if 'discord_auth_info' in user_data.keys():
if 'discord_user_id' in user_data['discord_auth_info'].keys():
return DiscordAuthInfo(
discord_user_id=user_data['discord_auth_info']['discord_user_id'],
discord_username_default=user_data['discord_auth_info']['discord_username_default'],
)

return get_new_discord_auth_info(user_data['username'])

def get_new_discord_auth_info(username: str) -> DiscordAuthInfo:
return DiscordAuthInfo(
discord_user_id='',
discord_username_default=username[:12],
)

def save_discord_auth_info(username: str, auth_info: DiscordAuthInfo):
uidb = user_info_db['users']
uidb.update_one({'username': username}, {'$set': {'discord_auth_info': asdict(auth_info)}})

def save_discord_link_status(username: str, discordLinked: bool):
uidb = task_list_db['taskLists']
uidb.update_one({'username': username}, {'$set': {'discordLinked': discordLinked}})

def save_discord_name_sync_enabled_status(username: str, discord_name_sync_enabled: bool):
tldb = task_list_db['taskLists']
tldb.update_one({'username': username}, {'$set': {'discordNameSyncEnabled': discord_name_sync_enabled}})

def link_discord_id(username: str, id: int):
auth_info: DiscordAuthInfo = get_discord_auth_info(username)
auth_info.discord_user_id = id
save_discord_auth_info(username, auth_info)
save_discord_link_status(username, True)

def unlink_discord_id(username: str):
auth_info: DiscordAuthInfo = get_discord_auth_info(username)
auth_info.discord_user_id = None
save_discord_auth_info(username, auth_info)
save_discord_link_status(username, False)

def update_discord_default_name(username: str, discord_default_name: str):
auth_info: DiscordAuthInfo = get_discord_auth_info(username)
auth_info.discord_username_default = discord_default_name
save_discord_auth_info(username, auth_info)
update_nickname_DISCORD(username, discord_default_name)

def update_discord_name_sync(username: str, discord_name_sync_enabled: bool):
save_discord_name_sync_enabled_status(username, discord_name_sync_enabled)

def update_nickname_DISCORD(username: str, nickname: str) -> bool:
discord_user_id = get_discord_auth_info(username).discord_user_id

nickname = nickname[:32]

url = (
f"{DISCORD_API_BASE}/guilds/"
f"{GUILD_ID}/members/{discord_user_id}"
)
headers = {
"Authorization": f"Bot {BOT_TOKEN}",
"Content-Type": "application/json",
}

try:
response = requests.patch(
url,
headers=headers,
json={"nick": nickname},
timeout=10,
)

if response.status_code == 200:
print(f"Updated nickname for Discord user {discord_user_id}")
return True

if response.status_code == 404:
print(f"Discord user {discord_user_id} is not in the guild.")
save_discord_name_sync_enabled_status(username, discord_name_sync_enabled=False)
return False

if response.status_code == 403:
print("Bot lacks permission to update nicknames.")
return False

print(f"Discord API returned {response.status_code}: {response.text}")
return False

except requests.RequestException as e:
print(f"Failed to contact Discord: {e}")
return False

def get_discord_nickname(discord_username_default: str, tier: str, short_name: str, percentage: int) -> str:
short_tier = "" if len(tier) == 0 else tier[0].capitalize()
return f"{discord_username_default} {percentage}%{short_tier} {short_name}"
21 changes: 13 additions & 8 deletions static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,16 @@ body {
box-shadow: 0 0 5px var(--current-task-color);
}

.container {
.container-discord-settings, .container {
display: flex;
align-items: center;
justify-content: center;
}

.container-discord-settings {
padding-top: 3rem;
padding-bottom: 3rem;
}

.faq-container {
display: flex;
Expand Down Expand Up @@ -1445,7 +1450,7 @@ nav svg {
outline: 2px solid var(--current-task-color);
}

.profile-content {
.discord-options, .profile-content {
display: flex;
flex-direction: column;
align-items: center;
Expand All @@ -1454,7 +1459,7 @@ nav svg {
row-gap: 20px;
}

.profile-content input {
.discord-options input, .profile-content input {
background-color: var(--main-bg-color);
color: var(--current-task-color);
border: 2px solid var(--main-accent-color);
Expand All @@ -1466,17 +1471,17 @@ nav svg {
transition: border-color 0.3s ease, box-shadow 0.3s ease;
}

.profile-content input:hover {
.discord-options input:hover, .profile-content input:hover {
border-color: var(--current-task-color);
cursor: pointer;
}

.profile-content input:focus {
.discord-options input:focus, .profile-content input:focus {
border-color: var(--current-task-color);
box-shadow: 0 0 5px var(--current-task-color);
}

.email-details {
.discord-name-details, .email-details {
display: flex;
}

Expand Down Expand Up @@ -1550,7 +1555,7 @@ input:checked + .slider:before {

}

.email-input-box form {
.discord-name-input-box form, .email-input-box form {
display: flex;
flex-direction: column;
gap: 5px;
Expand All @@ -1562,7 +1567,7 @@ input:checked + .slider:before {
gap: 5px;
}

.password-details form {
.discord-name-details form, .password-details form {
display: flex;
flex-direction: column;
gap: 5px;
Expand Down
22 changes: 21 additions & 1 deletion task_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import datetime
import jwt

from flask import Response, request
from flask import Response, request, redirect, url_for, flash, session
from functools import wraps
from http import HTTPStatus

Expand Down Expand Up @@ -33,6 +33,26 @@ def decorated(*args, **kwargs):
return f(user, *args, **kwargs)
return decorated

'''
login_required function:

The login_requried function is used to ensure that only logged in users can access certain pages.

Args:
Wrap(*args, **kwargs):
Returns:
redirect request: to login page if the user is not logged in.
'''
def login_required(f):
@wraps(f)
def wrap(*args, **kwargs):
if 'logged_in' in session:
return f(*args, **kwargs)
else:
flash("You must be logged in to access this page!")
return redirect(url_for('login'))
return wrap


@app.route('/api/v2/login', methods=['POST'])
def apiv2_login():
Expand Down
Loading