mirror of
https://github.com/MrRobotjs/MUM.git
synced 2025-12-21 08:49:32 -06:00
Added blueprint to serve React SPA for /admin routes, refactored error handling and routing to support client-side navigation, and introduced new API v1 endpoints for frontend integration. Legacy admin blueprints for users, invites, and libraries are disabled in favor of the SPA. Dockerfile and extension updates enable WebSocket support and dynamic port configuration.
343 lines
19 KiB
Python
343 lines
19 KiB
Python
"""
|
|
OAuth callback handlers - Plex and Discord authentication callbacks
|
|
"""
|
|
|
|
import time
|
|
from flask import redirect, url_for, flash, request, current_app, session
|
|
from markupsafe import Markup
|
|
from plexapi.myplex import MyPlexAccount
|
|
from plexapi.exceptions import PlexApiException
|
|
from app.models import User, UserType, Invite, Setting, EventType
|
|
from app.utils.helpers import setup_required, log_event
|
|
from app.utils.timeout_helper import get_api_timeout
|
|
from app.services.media_service_factory import MediaServiceFactory
|
|
from . import invites_public_bp as invites_bp
|
|
import requests
|
|
|
|
DISCORD_API_BASE_URL = 'https://discord.com/api/v10'
|
|
|
|
@invites_bp.route('/plex_callback') # Path is /invites/plex_callback
|
|
@setup_required
|
|
def plex_oauth_callback():
|
|
invite_id = session.get('plex_oauth_invite_id')
|
|
pin_code_from_session = session.get('plex_pin_code_invite_flow')
|
|
pin_id_from_session = session.get('plex_pin_id_invite_flow')
|
|
client_id_from_session = session.get('plex_client_id_invite_flow')
|
|
app_name_from_session = session.get('plex_app_name_invite_flow')
|
|
|
|
current_app.logger.debug(f"Plex callback - invite_id from session: {invite_id}")
|
|
current_app.logger.debug(f"Plex callback - pin_code_from_session: {pin_code_from_session}")
|
|
current_app.logger.debug(f"Plex callback - pin_id_from_session: {pin_id_from_session}")
|
|
current_app.logger.debug(f"Plex callback - client_id_from_session: {client_id_from_session}")
|
|
|
|
invite_path_or_token_for_redirect = "error_path"
|
|
if invite_id:
|
|
temp_invite_for_redirect = Invite.query.get(invite_id)
|
|
if temp_invite_for_redirect:
|
|
invite_path_or_token_for_redirect = temp_invite_for_redirect.custom_path or temp_invite_for_redirect.token
|
|
|
|
fallback_redirect = url_for('invites.process_invite_form', invite_path_or_token=invite_path_or_token_for_redirect)
|
|
|
|
if not invite_id or not pin_code_from_session or not pin_id_from_session or not client_id_from_session:
|
|
flash('Plex login callback invalid. Try invite again.', 'danger')
|
|
# Clear all session keys related to this flow
|
|
session.pop('plex_oauth_invite_id', None)
|
|
session.pop('plex_pin_code_invite_flow', None)
|
|
session.pop('plex_pin_id_invite_flow', None)
|
|
session.pop('plex_client_id_invite_flow', None)
|
|
session.pop('plex_app_name_invite_flow', None)
|
|
return redirect(fallback_redirect)
|
|
|
|
invite = Invite.query.get(invite_id)
|
|
if not invite:
|
|
flash('Invite not found. Try again.', 'danger')
|
|
return redirect(url_for('invites.invite_landing_page'))
|
|
|
|
return_path = session.get(f'invite_{invite.id}_return_path')
|
|
if return_path:
|
|
fallback_redirect = return_path
|
|
|
|
try:
|
|
# Use direct API approach exactly like the sample code
|
|
current_app.logger.debug(f"Plex callback - Using direct API approach to check PIN ID {pin_id_from_session} (PIN code: {pin_code_from_session})")
|
|
|
|
# Retry mechanism for OAuth timing issues
|
|
max_retries = 3
|
|
retry_delay = 1 # seconds
|
|
plex_auth_token = None
|
|
|
|
for attempt in range(max_retries):
|
|
current_app.logger.debug(f"Plex callback - Authentication attempt {attempt + 1}/{max_retries}")
|
|
|
|
try:
|
|
# Make direct API call exactly like the sample code
|
|
headers = {"accept": "application/json"}
|
|
data = {"code": pin_code_from_session, "X-Plex-Client-Identifier": client_id_from_session}
|
|
|
|
check_url = f"https://plex.tv/api/v2/pins/{pin_id_from_session}"
|
|
timeout = get_api_timeout()
|
|
response = requests.get(check_url, headers=headers, data=data, timeout=timeout)
|
|
|
|
current_app.logger.debug(f"Plex callback - PIN check response status: {response.status_code}")
|
|
current_app.logger.debug(f"Plex callback - PIN check response text: {response.text[:500]}")
|
|
|
|
if response.status_code == 200:
|
|
pin_data = response.json()
|
|
current_app.logger.debug(f"Plex callback - PIN data: {pin_data}")
|
|
|
|
if pin_data.get('authToken'):
|
|
plex_auth_token = pin_data['authToken']
|
|
current_app.logger.info(f"Plex callback - Successfully retrieved auth token via direct API for PIN {pin_code_from_session}")
|
|
break
|
|
else:
|
|
current_app.logger.debug(f"Plex callback - PIN {pin_code_from_session} not yet authenticated (no authToken)")
|
|
elif response.status_code == 404:
|
|
current_app.logger.warning(f"Plex callback - PIN {pin_code_from_session} not found (404)")
|
|
else:
|
|
current_app.logger.warning(f"Plex callback - PIN check failed with status {response.status_code}: {response.text[:200]}")
|
|
|
|
except Exception as e:
|
|
current_app.logger.error(f"Plex callback - Error checking PIN via API: {e}")
|
|
|
|
if attempt < max_retries - 1: # Don't sleep on the last attempt
|
|
current_app.logger.debug(f"Plex callback - Waiting {retry_delay}s before retry...")
|
|
time.sleep(retry_delay)
|
|
|
|
if not plex_auth_token:
|
|
current_app.logger.warning(f"Plex callback - PIN {pin_code_from_session} not authenticated after {max_retries} attempts")
|
|
flash('Plex PIN not yet authenticated. Please complete the authentication on plex.tv/link', 'warning')
|
|
return redirect(fallback_redirect)
|
|
|
|
plex_account = MyPlexAccount(token=plex_auth_token)
|
|
|
|
# Check if this Plex user is already in any of the invite's Plex servers
|
|
plex_servers_in_invite = [s for s in invite.servers if s.service_type.name.upper() == 'PLEX']
|
|
plex_user_already_exists = False
|
|
existing_server_name = ""
|
|
existing_local_account = None
|
|
|
|
for plex_server in plex_servers_in_invite:
|
|
try:
|
|
service = MediaServiceFactory.create_service_from_db(plex_server)
|
|
users = service.get_users()
|
|
|
|
for user in users:
|
|
# Check if this Plex user already exists in the server
|
|
if (user.get('uuid') == plex_account.uuid or
|
|
user.get('email', '').lower() == plex_account.email.lower()):
|
|
plex_user_already_exists = True
|
|
existing_server_name = plex_server.server_nickname
|
|
|
|
# Check if this Plex user is already linked to a local account
|
|
from sqlalchemy import or_
|
|
|
|
existing_access = User.query.filter_by(userType=UserType.SERVICE).filter(
|
|
User.server_id == plex_server.id,
|
|
or_(
|
|
User.external_user_id == str(plex_account.uuid),
|
|
User.external_user_alt_id == str(plex_account.uuid),
|
|
User.external_user_id == str(plex_account.id),
|
|
User.external_user_alt_id == str(plex_account.id)
|
|
)
|
|
).first()
|
|
|
|
if existing_access and existing_access.linkedUserId and existing_access.user_app_access:
|
|
existing_local_account = existing_access.user_app_access
|
|
|
|
current_app.logger.info(f"Plex user {plex_account.localUsername} already exists in {existing_server_name}")
|
|
break
|
|
|
|
if plex_user_already_exists:
|
|
break
|
|
|
|
except Exception as e:
|
|
current_app.logger.warning(f"Could not check existing users in {plex_server.server_nickname}: {e}")
|
|
|
|
# Handle the different scenarios
|
|
if plex_user_already_exists:
|
|
allow_user_accounts = Setting.get_bool('ALLOW_USER_ACCOUNTS', False)
|
|
|
|
if existing_local_account:
|
|
# Plex user is already linked to a local account
|
|
session[f'invite_{invite.id}_plex_conflict'] = {
|
|
'type': 'already_linked',
|
|
'server_name': existing_server_name,
|
|
'linked_username': existing_local_account.localUsername,
|
|
'plex_username': plex_account.localUsername,
|
|
'plex_email': plex_account.email
|
|
}
|
|
current_app.logger.info(f"Plex user {plex_account.localUsername} is already linked to local account {existing_local_account.localUsername}")
|
|
elif allow_user_accounts:
|
|
# Plex user exists but not linked - offer to link
|
|
session[f'invite_{invite.id}_plex_conflict'] = {
|
|
'type': 'can_link',
|
|
'server_name': existing_server_name,
|
|
'plex_username': plex_account.localUsername,
|
|
'plex_email': plex_account.email
|
|
}
|
|
current_app.logger.info(f"Plex user {plex_account.localUsername} exists but not linked - offering to link")
|
|
else:
|
|
# Plex user exists but no local accounts feature
|
|
session[f'invite_{invite.id}_plex_conflict'] = {
|
|
'type': 'already_exists_no_linking',
|
|
'server_name': existing_server_name,
|
|
'plex_username': plex_account.localUsername,
|
|
'plex_email': plex_account.email
|
|
}
|
|
current_app.logger.info(f"Plex user {plex_account.localUsername} already exists and no local account linking available")
|
|
else:
|
|
# Plex user is new - proceed normally
|
|
session[f'invite_{invite.id}_plex_user'] = {
|
|
'id': getattr(plex_account, 'id', None),
|
|
'uuid': getattr(plex_account, 'uuid', None),
|
|
'username': getattr(plex_account, 'username', None),
|
|
'email': getattr(plex_account, 'email', None),
|
|
'thumb': getattr(plex_account, 'thumb', None)
|
|
}
|
|
log_event(EventType.INVITE_USED_SUCCESS_PLEX, f"Plex auth success for {plex_account.localUsername} on invite {invite.id}.", invite_id=invite.id)
|
|
current_app.logger.info(f"New Plex user {plex_account.localUsername} - proceeding with invite")
|
|
|
|
except PlexApiException as e_plex:
|
|
flash(f'Plex API error: {str(e_plex)}', 'danger')
|
|
log_event(EventType.ERROR_PLEX_API, f"Invite {invite.id}: Plex PIN check PlexApiException: {e_plex}", invite_id=invite.id)
|
|
except Exception as e:
|
|
flash(f"Error during Plex login for invite: {str(e)[:150]}", "danger")
|
|
log_event(EventType.ERROR_PLEX_API, f"Invite {invite.id}: Plex callback error: {e}", invite_id=invite.id)
|
|
finally:
|
|
session.pop('plex_oauth_invite_id', None)
|
|
session.pop('plex_pin_code_invite_flow', None)
|
|
session.pop('plex_headers_invite_flow', None)
|
|
|
|
return redirect(fallback_redirect)
|
|
|
|
@invites_bp.route('/discord_callback')
|
|
@setup_required
|
|
def discord_oauth_callback():
|
|
invite_id_from_session = session.get('discord_oauth_invite_id')
|
|
returned_state = request.args.get('state')
|
|
|
|
invite_path_for_redirect_on_error = "unknown_invite_path"
|
|
invite_object_for_redirect = None
|
|
if invite_id_from_session:
|
|
invite_object_for_redirect = Invite.query.get(invite_id_from_session)
|
|
if invite_object_for_redirect:
|
|
invite_path_for_redirect_on_error = invite_object_for_redirect.custom_path or invite_object_for_redirect.token
|
|
|
|
public_invite_page_url_with_path = url_for('invites.process_invite_form', invite_path_or_token=invite_path_for_redirect_on_error)
|
|
generic_invite_landing_url = url_for('invites.invite_landing_page')
|
|
|
|
if not invite_id_from_session or not returned_state or returned_state != session.pop('discord_oauth_state_invite', None):
|
|
flash('Discord login failed: Invalid session or state. Please try the invite link again.', 'danger')
|
|
current_app.logger.warning("Discord OAuth Callback: Invalid state or missing invite_id in session.")
|
|
return redirect(public_invite_page_url_with_path if invite_object_for_redirect else generic_invite_landing_url)
|
|
|
|
if not invite_object_for_redirect:
|
|
flash('Discord login failed: Invite information is no longer available. Please try a fresh invite link.', 'danger')
|
|
current_app.logger.warning(f"Discord OAuth Callback: Invite ID {invite_id_from_session} not found in DB after state check.")
|
|
return redirect(generic_invite_landing_url)
|
|
|
|
return_path = session.get(f'invite_{invite_object_for_redirect.id}_return_path')
|
|
if return_path:
|
|
public_invite_page_url_with_path = return_path
|
|
|
|
code = request.args.get('code')
|
|
if not code:
|
|
error_description = request.args.get("error_description", "Authentication with Discord failed. No authorization code received.")
|
|
flash(f'Discord login failed: {error_description}', 'danger')
|
|
log_event(EventType.ERROR_DISCORD_API, f"Discord OAuth callback failed (no code): {error_description}", invite_id=invite_id_from_session)
|
|
return redirect(public_invite_page_url_with_path)
|
|
|
|
client_id = Setting.get('DISCORD_CLIENT_ID')
|
|
client_secret = Setting.get('DISCORD_CLIENT_SECRET')
|
|
redirect_uri_for_token_exchange = Setting.get('DISCORD_REDIRECT_URI_INVITE')
|
|
|
|
if not (client_id and client_secret and redirect_uri_for_token_exchange):
|
|
flash('Discord integration is not properly configured by the admin. Cannot complete login.', 'danger')
|
|
log_event(EventType.ERROR_DISCORD_API, "Discord OAuth callback failed: MUM settings (client_id/secret/redirect_uri_invite) missing.", invite_id=invite_id_from_session)
|
|
return redirect(public_invite_page_url_with_path)
|
|
|
|
token_url = f"{DISCORD_API_BASE_URL}/oauth2/token"
|
|
payload = {
|
|
'client_id': client_id,
|
|
'client_secret': client_secret,
|
|
'grant_type': 'authorization_code',
|
|
'code': code,
|
|
'redirect_uri': redirect_uri_for_token_exchange
|
|
}
|
|
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
|
|
|
|
try:
|
|
timeout = get_api_timeout()
|
|
token_response = requests.post(token_url, data=payload, headers=headers, timeout=timeout)
|
|
token_response.raise_for_status()
|
|
token_data = token_response.json()
|
|
access_token = token_data['access_token']
|
|
|
|
user_info_url = f"{DISCORD_API_BASE_URL}/users/@me"
|
|
auth_headers = {'Authorization': f'Bearer {access_token}'}
|
|
user_response = requests.get(user_info_url, headers=auth_headers, timeout=timeout)
|
|
user_response.raise_for_status()
|
|
discord_user_data = user_response.json()
|
|
|
|
discord_username_from_oauth = f"{discord_user_data['username']}#{discord_user_data['discriminator']}" if discord_user_data.get('discriminator') and discord_user_data.get('discriminator') != '0' else discord_user_data['username']
|
|
|
|
# Determine the effective "Require Guild Membership" setting for this specific invite
|
|
if invite_object_for_redirect.require_discord_guild_membership:
|
|
effective_require_guild = invite_object_for_redirect.require_discord_guild_membership
|
|
else:
|
|
effective_require_guild = Setting.get_bool('DISCORD_REQUIRE_GUILD_MEMBERSHIP', False)
|
|
|
|
if effective_require_guild:
|
|
current_app.logger.info(f"Discord OAuth Callback: Guild membership is required for invite {invite_object_for_redirect.id}.")
|
|
configured_guild_id_str = Setting.get('DISCORD_GUILD_ID')
|
|
if not configured_guild_id_str or not configured_guild_id_str.isdigit():
|
|
flash('Server configuration error: Target Discord Server ID for membership check is not set or invalid. Please contact admin.', 'danger')
|
|
session.pop('discord_oauth_invite_id', None)
|
|
return redirect(public_invite_page_url_with_path)
|
|
|
|
configured_guild_id = int(configured_guild_id_str)
|
|
user_guilds_url = f"{DISCORD_API_BASE_URL}/users/@me/guilds"
|
|
guilds_response = requests.get(user_guilds_url, headers=auth_headers, timeout=timeout)
|
|
guilds_response.raise_for_status()
|
|
user_guilds_list = guilds_response.json()
|
|
is_member = any(str(g.get('id')) == str(configured_guild_id) for g in user_guilds_list)
|
|
|
|
if not is_member:
|
|
server_invite_link = Setting.get('DISCORD_SERVER_INVITE_URL')
|
|
error_html = "To accept this invite, you must be a member of our Discord server."
|
|
if server_invite_link: error_html += f" Please join using the button below and then attempt to link your Discord account again on the invite page."
|
|
else: error_html += " Please contact an administrator for an invite to the server."
|
|
flash(Markup(error_html), 'warning')
|
|
log_event(EventType.DISCORD_BOT_GUILD_MEMBER_CHECK_FAIL, f"User {discord_username_from_oauth} (ID: {discord_user_data['id']}) failed guild membership check for guild {configured_guild_id}.", invite_id=invite_object_for_redirect.id)
|
|
session.pop('discord_oauth_invite_id', None)
|
|
return redirect(public_invite_page_url_with_path)
|
|
|
|
# If all checks pass, store all relevant info in the session
|
|
discord_user_info_for_session = {
|
|
'id': discord_user_data.get('id'),
|
|
'username': discord_username_from_oauth,
|
|
'avatar': discord_user_data.get('avatar'),
|
|
'email': discord_user_data.get('email'),
|
|
'verified': discord_user_data.get('verified')
|
|
}
|
|
session[f'invite_{invite_object_for_redirect.id}_discord_user'] = discord_user_info_for_session
|
|
log_event(EventType.INVITE_USED_SUCCESS_DISCORD, f"Discord auth success for {discord_username_from_oauth} on invite {invite_object_for_redirect.id}.", invite_id=invite_object_for_redirect.id)
|
|
|
|
except requests.exceptions.HTTPError as e_http:
|
|
error_message = f"Discord API Error ({e_http.response.status_code})"
|
|
try:
|
|
error_json = e_http.response.json()
|
|
error_message = error_json.get('error_description', error_json.get('message', error_message))
|
|
except ValueError:
|
|
error_message = e_http.response.text[:200] if e_http.response.text else error_message
|
|
flash(f'Failed to link Discord: {error_message}', 'danger')
|
|
log_event(EventType.ERROR_DISCORD_API, f"Invite {invite_id_from_session}: Discord callback HTTPError: {error_message}", invite_id=invite_id_from_session, details={'status_code': e_http.response.status_code})
|
|
|
|
except Exception as e_gen:
|
|
flash('An unexpected error occurred during Discord login. Please try again.', 'danger')
|
|
log_event(EventType.ERROR_DISCORD_API, f"Invite {invite_id_from_session}: Unexpected Discord callback error: {e_gen}", invite_id=invite_id_from_session, details={'error': str(e_gen)})
|
|
finally:
|
|
session.pop('discord_oauth_invite_id', None)
|
|
|
|
return redirect(public_invite_page_url_with_path)
|