diff --git a/config.py b/config.py index ccb6b41..ae3f3c0 100644 --- a/config.py +++ b/config.py @@ -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"] diff --git a/dev_env.ps1 b/dev_env.ps1 index 1a7a866..ef17e43 100644 --- a/dev_env.ps1 +++ b/dev_env.ps1 @@ -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' diff --git a/dev_env.sh b/dev_env.sh index 45cd66a..c63eeb6 100644 --- a/dev_env.sh +++ b/dev_env.sh @@ -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 diff --git a/discord_api.py b/discord_api.py new file mode 100644 index 0000000..3f25bc2 --- /dev/null +++ b/discord_api.py @@ -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() \ No newline at end of file diff --git a/discord_service.py b/discord_service.py new file mode 100644 index 0000000..6cdc5ec --- /dev/null +++ b/discord_service.py @@ -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}" diff --git a/static/styles.css b/static/styles.css index fd43a41..9b8341b 100644 --- a/static/styles.css +++ b/static/styles.css @@ -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; @@ -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; @@ -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); @@ -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; } @@ -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; @@ -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; diff --git a/task_api.py b/task_api.py index bdfc9a2..48b68aa 100644 --- a/task_api.py +++ b/task_api.py @@ -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 @@ -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(): diff --git a/task_database.py b/task_database.py index 803dd9a..65f14c3 100644 --- a/task_database.py +++ b/task_database.py @@ -8,6 +8,7 @@ import user_dao from user_dao import UserDatabaseObject, convert_database_user from task_types import TaskData, LeaderboardEntry, TaskData +import discord_service mydb = config.MONGO_CLIENT["TaskApp"] @@ -94,6 +95,8 @@ def add_task_account(username, is_official, lms_enabled): "username": str(username), "isOfficial": bool(is_official), "lmsEnabled": bool(lms_enabled), + "discordLinked": False, + "discordNameSyncEnabled": False, "completedTasks": [], "recordedItemIdsByTask": {}, 'tiers': { @@ -247,6 +250,13 @@ def __set_current_task(username: str, tier: str, task_id: str, current: bool): {"$set": {f"tiers.{cleaned_tier}.currentTask": {"id": task_id}}}, ) + if (get_discord_name_sync_enabled(username)): + percentage = get_task_progress(username)[cleaned_tier]['percent_complete'] + discord_username = discord_service.get_discord_auth_info(username).discord_username_default + short_name = "" if not current else [n for n in tasklists.list_for_tier(cleaned_tier) if n.id == task_id][0].short_name + new_name = discord_service.get_discord_nickname(discord_username, cleaned_tier, short_name, percentage) + discord_service.update_nickname_DISCORD(username, new_name) + def __parse_completed_iso(value: str | None) -> datetime | None: if not value: @@ -1128,6 +1138,11 @@ def get_lms_status(username): lms_enabled = coll.find_one({"username": username}, {'lmsEnabled' : 1, '_id': 0}) return lms_enabled["lmsEnabled"] +def get_discord_name_sync_enabled(username): + tldb = mydb["taskLists"] + discord_name_sync_enabled = tldb.find_one({"username": username}, {'discordNameSyncEnabled' : 1, '_id': 0}) + return discord_name_sync_enabled["discordNameSyncEnabled"] + ''' lms_status_change: diff --git a/task_login.py b/task_login.py index 164a969..0f25f41 100644 --- a/task_login.py +++ b/task_login.py @@ -4,6 +4,8 @@ from itsdangerous import TimedJSONWebSignatureSerializer as Serializer from datetime import datetime import config +from dataclasses import asdict +from discord_service import get_new_discord_auth_info # specifies database to use. db = config.MONGO_CLIENT["TaskAppLoginDB"] @@ -190,9 +192,9 @@ def add_user(username, password, email, isOfficial, lmsEnabled): doc_count = coll.count_documents(user_querydb) if doc_count == 0: hashed_pass = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()) - user_input = {"username": username, "hashed_password": hashed_pass, "user_email": email, 'email_verified': False} + user_input = {"username": username, "hashed_password": hashed_pass, "user_email": email, 'email_verified': False, 'discord_auth_info': asdict(get_new_discord_auth_info(username))} coll.insert_one(user_input) - add_task_account(username, isOfficial, lmsEnabled ) + add_task_account(username, isOfficial, lmsEnabled) success = True return success, error diff --git a/task_types.py b/task_types.py index 61c9327..ef55410 100644 --- a/task_types.py +++ b/task_types.py @@ -27,6 +27,7 @@ class SkillVerificationData(VerificationData): class TaskData: id: str name: str + short_name: str tip: str wiki_link: str image_link: str diff --git a/taskapp.py b/taskapp.py index 89883dc..b58d6f5 100644 --- a/taskapp.py +++ b/taskapp.py @@ -7,6 +7,7 @@ from functools import wraps import task_login import tasklists +import discord_service from task_database import (get_taskCurrent, generate_task, complete_task, get_task_progress, manual_complete_tasks, manual_revert_tasks, get_lms_status, lms_status_change, update_imported_tasks, @@ -16,7 +17,10 @@ import send_grid_email from templesync import check_logs, temple_player_data, import_logs from task_types import CollectionLogVerificationData +from task_api import login_required + import task_api +import discord_api ''' @@ -46,36 +50,10 @@ def before_request(): code = 301 return redirect(redirect_url, code=code) - - -''' -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 - - class APIUser: def __init__(self, username): self.username = username - ''' Class to hold data that is used in many templates ''' @@ -1191,6 +1169,7 @@ def reset_token(token): @login_required def profile(): user_info = BasePageInfo() + discord_default_username = discord_service.get_discord_auth_info(user_info.username).discord_username_default # if not user_info.email_bool: # return render_template('email-verify.html') progress = get_task_progress(user_info.username) @@ -1212,6 +1191,9 @@ def profile(): email_val=user_info.email_val, official=user_info.official, lms_status=user_info.user.lms_enabled, + discord_status=user_info.user.discord_linked, + discord_default_username=discord_default_username, + discord_name_sync_enabled=user_info.user.discord_name_sync_enabled, **context) diff --git a/tasklists.py b/tasklists.py index e1b3b49..a691500 100644 --- a/tasklists.py +++ b/tasklists.py @@ -30,6 +30,7 @@ def to_verification_data(data: dict) -> VerificationData | None: def to_task_class(data: dict) -> TaskData: return TaskData(id=data['id'], name=data['name'], + short_name=data.get('shortName', ""), tip=data.get('tip'), wiki_link=data['wikiLink'], image_link=data['imageLink'], diff --git a/templates/profile.html b/templates/profile.html index 2deca30..0071b5e 100644 --- a/templates/profile.html +++ b/templates/profile.html @@ -53,49 +53,120 @@

{{username}} Profile

-
- - - -
-
-

LMS Status

+
+ +
    +
  • Password must have at least one number (0-9)
  • +
  • Password must have at least one uppercase letters (A-Z)
  • +
  • Password must have at least one lowercase letters (a-z)
  • +
  • Password must have at least one special character. (e.g. !@#$%^&*())
  • +
  • Password must have at least 8 characters.
  • +
  • Password and confirm password must match.
  • +
+
+
+
+

LMS Status

+ +

Official Status

-

Official Status

+ {% if official %} + + {% else %} + + {% endif %} + + +

Skip Roll Animation

-

Skip Roll Animation

- +
+
+ + +
+
+ + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +

Discord Settings

+ +
+ {% if discord_status == True %} +

Unlink your Discord Account

+ + disconnect + +
+
+
+
+ Change Discord Default Name + + +
-
- - +
+
+
+ +
    +
  • Username must be between 2 and 12 characters.
  • +
+
+ + {% else %} +

Link your Discord Account

+ + connect + + {% endif %} + {% with messages = get_flashed_messages() %} + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} + {% endwith %}
@@ -170,6 +241,17 @@

{{username}} Profile

}); }); +$(document).ready(function(){ + $("#discordUsername").bind("keyup", function(){ + const len = $(this).val().length; + if (len >= 2 && len <= 12){ + $('#discord-length').addClass('complete'); + } + else { + $('#discord-length').removeClass('complete'); + } + }); +});