From 922987454fcb14fd8ec52dd5046289a70d964c82 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Sat, 2 May 2020 18:32:58 +0100 Subject: [PATCH] Add setup wizard Added a setup wizard that appears on first run, or when no config dir is found. --- README.md | 5 +- data/static/setup.js | 234 +++++++++++++++++++++++ data/templates/setup.html | 356 +++++++++++++++++++++++++++++++++++ jellyfin_accounts/email.py | 4 + jellyfin_accounts/setup.py | 76 ++++++++ jellyfin_accounts/web.py | 2 +- jellyfin_accounts/web_api.py | 26 +-- jf-accounts | 49 +++-- setup.py | 6 +- 9 files changed, 710 insertions(+), 48 deletions(-) create mode 100644 data/static/setup.js create mode 100644 data/templates/setup.html create mode 100644 jellyfin_accounts/setup.py diff --git a/README.md b/README.md index 5375e4e..a786a0d 100644 --- a/README.md +++ b/README.md @@ -82,9 +82,12 @@ optional arguments: #### Configuration * Note: Make sure to put this behind a reverse proxy with HTTPS. +On first run, access the setup wizard at `0.0.0.0:8056`. When finished, restart the program. + +The configuration is stored at `~/.jf-accounts/config.ini`. + For detailed descriptions of each setting, see [setup](https://github.com/hrfee/jellyfin-accounts/wiki/Setup). -On first run, the default configuration is copied to `~/.jf-accounts/config.ini`. ``` [jellyfin] diff --git a/data/static/setup.js b/data/static/setup.js new file mode 100644 index 0000000..8389a46 --- /dev/null +++ b/data/static/setup.js @@ -0,0 +1,234 @@ +function checkAuthRadio() { + if (document.getElementById('manualAuthRadio').checked) { + document.getElementById('adminOnlyArea').style.display = 'none'; + document.getElementById('manualAuthArea').style.display = ''; + } else { + document.getElementById('manualAuthArea').style.display = 'none'; + document.getElementById('adminOnlyArea').style.display = ''; + }; +}; +var authRadios = ['manualAuthRadio', 'jfAuthRadio']; +for (var i = 0; i < authRadios.length; i++) { + document.getElementById(authRadios[i]).addEventListener('change', function() { + checkAuthRadio(); + }); +}; +function checkEmailRadio() { + document.getElementById('emailNextButton').href = '#page-5'; + document.getElementById('valBackButton').href = '#page-7'; + if (document.getElementById('emailSMTPRadio').checked) { + document.getElementById('emailSMTPArea').style.display = ''; + document.getElementById('emailMailgunArea').style.display = 'none'; + } else if (document.getElementById('emailMailgunRadio').checked) { + document.getElementById('emailSMTPArea').style.display = 'none'; + document.getElementById('emailMailgunArea').style.display = ''; + } else if (document.getElementById('emailDisabledRadio').checked) { + document.getElementById('emailSMTPArea').style.display = 'none'; + document.getElementById('emailMailgunArea').style.display = 'none'; + document.getElementById('emailNextButton').href = '#page-8'; + document.getElementById('valBackButton').href = '#page-4'; + }; +}; +var emailRadios = ['emailDisabledRadio', 'emailSMTPRadio', 'emailMailgunRadio']; +for (var i = 0; i < emailRadios.length; i++) { + document.getElementById(emailRadios[i]).addEventListener('change', function() { + checkEmailRadio(); + }); +}; +function checkSSL() { + var label = document.getElementById('emailSSL_TLSLabel'); + if (document.getElementById('emailSSL_TLS').checked) { + label.textContent = 'Use SSL/TLS'; + } else { + label.textContent = 'Use STARTTLS'; + }; +}; +document.getElementById('emailSSL_TLS').addEventListener('change', function() { + checkSSL(); +}); + +function checkPwrEnabled() { + if (document.getElementById('pwrEnabled').checked) { + document.getElementById('pwrArea').style.display = ''; + } else { + document.getElementById('pwrArea').style.display = 'none'; + }; +}; +var pwrEnabled = document.getElementById('pwrEnabled'); +pwrEnabled.addEventListener('change', function() { + checkPwrEnabled(); +}); + +function checkInvEnabled() { + if (document.getElementById('invEnabled').checked) { + document.getElementById('invArea').style.display = ''; + } else { + document.getElementById('invArea').style.display = 'none'; + }; +}; +document.getElementById('invEnabled').addEventListener('change', function() { + checkInvEnabled(); +}); + +function checkValEnabled() { + if (document.getElementById('valEnabled').checked) { + document.getElementById('valArea').style.display = ''; + } else { + document.getElementById('valArea').style.display = 'none'; + }; +}; +document.getElementById('valEnabled').addEventListener('change', function() { + checkValEnabled(); +}); +checkValEnabled(); +checkInvEnabled(); +checkSSL(); +checkAuthRadio(); +checkEmailRadio(); +checkPwrEnabled(); + +var jfValid = false +document.getElementById('jfTestButton').onclick = function() { + var testButton = document.getElementById('jfTestButton'); + var nextButton = document.getElementById('jfNextButton'); + testButton.disabled = true; + testButton.innerHTML = + '' + + 'Testing...'; + nextButton.classList.add('disabled'); + nextButton.setAttribute('aria-disabled', 'true'); + var jfData = {}; + jfData['jfHost'] = document.getElementById('jfHost').value; + jfData['jfUser'] = document.getElementById('jfUser').value; + jfData['jfPassword'] = document.getElementById('jfPassword').value; + $.ajax('/testJF', { + type : 'POST', + dataType : 'json', + contentType : 'application/json', + data : JSON.stringify(jfData), + complete: function(response) { + testButton.disabled = false; + testButton.className = ''; + var success = response['responseJSON']['success']; + if (success == true) { + testButton.classList.add('btn', 'btn-success'); + testButton.textContent = 'Success'; + nextButton.classList.remove('disabled'); + nextButton.setAttribute('aria-disabled', 'false'); + } else { + testButton.classList.add('btn', 'btn-danger'); + testButton.textContent = 'Failed'; + } + } + }); +}; + +document.getElementById('submitButton').onclick = function() { + var submitButton = document.getElementById('submitButton'); + submitButton.disabled = true; + submitButton.innerHTML = + '' + + 'Submitting...'; + var config = {}; + config['jellyfin'] = {}; + config['ui'] = {}; + config['password_validation'] = {}; + config['email'] = {}; + config['password_resets'] = {}; + config['invite_emails'] = {}; + config['mailgun'] = {}; + config['smtp'] = {}; + // Page 2: Auth + if (document.getElementById('jfAuthRadio').checked) { + config['ui']['jellyfin_login'] = 'true'; + if (document.getElementById('jfAuthAdminOnly').checked) { + config['ui']['admin_only'] = 'true'; + } else { + config['ui']['admin_only'] = 'false' + }; + } else { + config['ui']['username'] = document.getElementById('manualAuthUsername').value; + config['ui']['password'] = document.getElementById('manualAuthPassword').value; + }; + // Page 3: Connect to jellyfin + config['jellyfin']['server'] = document.getElementById('jfHost').value; + config['jellyfin']['username'] = document.getElementById('jfUser').value; + config['jellyfin']['password'] = document.getElementById('jfPassword').value; + // Page 4: Email (Page 5, 6, 7 are only used if this is enabled) + if (document.getElementById('emailDisabledRadio').checked) { + config['password_resets']['enabled'] = 'false'; + config['invite_emails']['enabled'] = 'false'; + } else { + if (document.getElementById('emailSMTPRadio').checked) { + if (document.getElementById('emailSSL_TLS').checked) { + config['smtp']['encryption'] = 'ssl_tls'; + } else { + config['smtp']['encryption'] = 'starttls'; + }; + config['email']['method'] = 'smtp'; + config['smtp']['server'] = document.getElementById('emailSMTPServer').value; + config['smtp']['port'] = document.getElementById('emailSMTPPort').value; + config['smtp']['password'] = document.getElementById('emailSMTPPassword').value; + config['email']['address'] = document.getElementById('emailSMTPAddress').value; + } else { + config['email']['method'] = 'mailgun'; + config['mailgun']['api_url'] = document.getElementById('emailMailgunURL').value; + config['mailgun']['api_key'] = document.getElementById('emailMailgunKey').value; + config['email']['address'] = document.getElementById('emailMailgunAddress').value; + }; + // Page 5: Email formatting + config['email']['from'] = document.getElementById('emailSender').value; + config['email']['date_format'] = document.getElementById('emailDateFormat').value; + if (document.getElementById('email24hTimeRadio').checked) { + config['email']['use_24h'] = 'true'; + } else { + config['email']['use_24h'] = 'false'; + }; + config['email']['message'] = document.getElementById('emailMessage').value; + // Page 6: Password Resets + if (document.getElementById('pwrEnabled').checked) { + config['password_resets']['enabled'] = 'true'; + config['password_resets']['watch_directory'] = document.getElementById('pwrJfPath').value; + config['password_resets']['subject'] = document.getElementById('pwrSubject').value; + } else { + config['password_resets']['enabled'] = 'false'; + }; + // Page 7: Invite Emails + if (document.getElementById('invEnabled').checked) { + config['invite_emails']['enabled'] = 'true'; + config['invite_emails']['url_base'] = document.getElementById('invURLBase').value; + config['invite_emails']['subject'] = document.getElementById('invSubject').value; + } else { + config['invite_emails']['enabled'] = 'false'; + }; + }; + // Page 8: Password Validation + if (document.getElementById('valEnabled').checked) { + config['password_validation']['enabled'] = 'true'; + config['password_validation']['min_length'] = document.getElementById('valLength').value; + config['password_validation']['upper'] = document.getElementById('valUpper').value; + config['password_validation']['lower'] = document.getElementById('valLower').value; + config['password_validation']['number'] = document.getElementById('valNumber').value; + config['password_validation']['special'] = document.getElementById('valSpecial').value; + } else { + config['password_validation']['enabled'] = 'false'; + }; + // Page 9: Messages + config['ui']['contact_message'] = document.getElementById('msgContact').value; + config['ui']['help_message'] = document.getElementById('msgHelp').value; + config['ui']['success_message'] = document.getElementById('msgSuccess').value; + console.log(config); + $.ajax('/modifyConfig', { + type : 'POST', + dataType : 'json', + contentType : 'application/json', + data : JSON.stringify(config), + complete: function(response) { + submitButton.disabled = false; + submitButton.className = ''; + submitButton.classList.add('btn', 'btn-success'); + submitButton.textContent = 'Success'; + } + }); +}; + diff --git a/data/templates/setup.html b/data/templates/setup.html new file mode 100644 index 0000000..6dc1edb --- /dev/null +++ b/data/templates/setup.html @@ -0,0 +1,356 @@ + + + + + + + + + + + + + Setup - Jellyfin Accounts + + +
+
+
+
+
+
+
+
Welcome!
+

+ You'll need to do a few things to start using jellyfin-accounts. Click below to get started, or quit and edit the config file manually. +

+ Get Started +
+ +
+
+
+
Login
+

+ To access the admin page, you'll need to login. Choose how below. +

    +
  • Authorize through Jellyfin: Checks credentials with jellyfin, allowing you to share login details and grant multiple users access.
  • +
  • Username & Password: Set your own username and password manually.
  • +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+

+
+ Back + Next +
+
+
+
+
+
Jellyfin
+

+ jellyfin-accounts needs admin access so that it can create users. + You should create a separate account for it, checking 'Allow this user to manage the server'. You can disable everything else. Once done, enter the credentials here. +

+ + +
+
+ + +
+
+ + +
+ +
+ Back + Next +
+
+
+
+
+
Email
+

jellyfin-accounts is capable of sending a PIN code when a user tries to reset their password on Jellyfin. One can also choose to send an invite code directly to an email address. This can be done through SMTP or through Mailgun's API. +

+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + + Note: SSL/TLS usually uses port 465, whereas STARTTLS usually uses 587. +
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+
+ +
+
+ +
+
+ +
+
+

+
+ Back + Next +
+
+
+
+
+
Email
+

Just a few more things to get your emails looking great. +

+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+

+
+ Back + Next +
+
+
+
+
+
Password Resets
+

+ When a user tries to reset their password in jellyfin, it informs them that a file has been created, named "passwordreset*.json" where * is a number. jellyfin-accounts will then read this file, and send the PIN to the user's email. Try it now, and put the folder that it informs you it put the file in below. Also, if enter a custom email subject if you don't like the default one. +

+
+ + +
+
+
+ + +
+
+ + +
+
+
+ Back + Next +
+
+
+
+
+
Invite Emails
+

+ Allows you to send an invite code directly to a specified email address. + Since you'll most likely being running this behind a reverse proxy, the program has no way of knowing the address it will be accessed from. This is needed for sending emails with links. Write your URL Base with the protocol and append '/invite', e.g: +

+

+
+ + +
+
+
+ + +
+
+ + +
+
+
+ Back + Next +
+
+
+
+
+
Password Validation
+

+ Enabling this will display a set of password requirements on the create account page, such as minimum length, uppercase characters, special characters, etc. +

+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ Back + Next +
+
+
+
+
+
Help Messages
+

+ Just a few little messages that will display in various places. Leave these alone if you want. +

+
+ + +
+
+ + +
+
+ + +
+
+ Back + Next +
+
+
+
+
+
Finished!
+

+ Press the button below to submit your settings. The program will quit, so run it again, then refresh this page. +

+ +
+
+
+
+
+
+
+ + + diff --git a/jellyfin_accounts/email.py b/jellyfin_accounts/email.py index a75c1b7..62059d9 100644 --- a/jellyfin_accounts/email.py +++ b/jellyfin_accounts/email.py @@ -177,3 +177,7 @@ class Smtp(Email): err = f'{self.address}: Failed to send via smtp: ' err += type(e).__name__ log.error(err) + try: + log.error(e.smtp_error) + except: + pass diff --git a/jellyfin_accounts/setup.py b/jellyfin_accounts/setup.py new file mode 100644 index 0000000..9a2c273 --- /dev/null +++ b/jellyfin_accounts/setup.py @@ -0,0 +1,76 @@ +from flask import request, jsonify, render_template +from configparser import RawConfigParser +from jellyfin_accounts.jf_api import Jellyfin +from __main__ import config, config_path, app, first_run +from __main__ import web_log as log +import os + +if first_run: + def resp(success=True, code=500): + if success: + r = jsonify({'success': True}) + r.status_code = 200 + else: + r = jsonify({'success': False}) + r.status_code = code + return r + + def tempJF(server): + return Jellyfin(server, + config['jellyfin']['client'], + config['jellyfin']['version'], + config['jellyfin']['device'] + '_temp', + config['jellyfin']['device_id'] + '_temp') + + @app.errorhandler(404) + def page_not_found(e): + return render_template('404.html'), 404 + + @app.route('/', methods=['GET', 'POST']) + def setup(): + return render_template('setup.html') + + + @app.route('/') + def static_proxy(path): + if 'html' not in path: + return app.send_static_file(path) + else: + return render_template('404.html'), 404 + + + @app.route('/modifyConfig', methods=['POST']) + def modifyConfig(): + log.info('Config modification requested') + data = request.get_json() + temp_config = RawConfigParser(comment_prefixes='/', + allow_no_value=True) + temp_config.read(config_path) + for section in data: + if section in temp_config: + for item in data[section]: + if item in temp_config[section]: + temp_config[section][item] = data[section][item] + data[section][item] = True + log.debug(f'{section}/{item} modified') + else: + data[section][item] = False + log.debug(f'{section}/{item} does not exist in config') + with open(config_path, 'w') as config_file: + temp_config.write(config_file) + log.debug('Config written') + os._exit(1) + return resp() + + + @app.route('/testJF', methods=['GET', 'POST']) + def testJF(): + data = request.get_json() + tempjf = tempJF(data['jfHost']) + try: + tempjf.authenticate(data['jfUser'], + data['jfPassword']) + tempjf.getUsers(public=False) + return resp() + except: + return resp(False) diff --git a/jellyfin_accounts/web.py b/jellyfin_accounts/web.py index 8dc0c1d..92c414d 100644 --- a/jellyfin_accounts/web.py +++ b/jellyfin_accounts/web.py @@ -20,7 +20,7 @@ def admin(): @app.route('/') def static_proxy(path): - if 'form.html' not in path and 'admin.html' not in path: + if 'html' not in path: return app.send_static_file(path) return render_template('404.html', contactMessage=config['ui']['contact_message']), 404 diff --git a/jellyfin_accounts/web_api.py b/jellyfin_accounts/web_api.py index d3dae98..415c8ac 100644 --- a/jellyfin_accounts/web_api.py +++ b/jellyfin_accounts/web_api.py @@ -266,30 +266,6 @@ def get_token(): return jsonify({'token': token.decode('ascii')}) -@app.route('/modifyConfig', methods=['POST']) -@auth.login_required -def modifyConfig(): - log.info('Config modification requested') - data = request.get_json() - temp_config = RawConfigParser(comment_prefixes='/', - allow_no_value=True) - temp_config.read(config_path) - for section in data: - if section in temp_config: - for item in data[section]: - if item in temp_config[section]: - temp_config[section][item] = data[section][item] - data[section][item] = True - log.debug(f'{section}/{item} modified') - else: - data[section][item] = False - log.debug(f'{section}/{item} does not exist in config') - with open(config_path, 'w') as config_file: - temp_config.write(config_file) - log.debug('Config written') - return jsonify(data) - - @app.route('/getUsers', methods=['GET', 'POST']) @auth.login_required def getUsers(): @@ -333,4 +309,4 @@ def modifyUsers(): except: return resp(success=False) - +import jellyfin_accounts.setup diff --git a/jf-accounts b/jf-accounts index 34f7603..e51ba7d 100755 --- a/jf-accounts +++ b/jf-accounts @@ -36,6 +36,7 @@ else: local_dir = (Path(__file__).parents[2] / 'data').resolve() +first_run = False if not data_dir.exists(): Path.mkdir(data_dir) print(f'Config dir not found, so created at {str(data_dir)}') @@ -43,8 +44,8 @@ if not data_dir.exists(): config_path = data_dir / 'config.ini' shutil.copy(str(local_dir / 'config-default.ini'), str(config_path)) - print("Edit the configuration and restart.") - raise SystemExit + print("Setup through the web UI, or quit and edit the configuration manually.") + first_run = True else: config_path = Path(args.config) print(f'config.ini can be found at {str(config_path)}') @@ -71,9 +72,10 @@ def create_log(name): return log log = create_log('main') -email_log = create_log('emails') web_log = create_log('waitress') -auth_log = create_log('auth') +if not first_run: + email_log = create_log('emails') + auth_log = create_log('auth') if args.host is not None: log.debug(f'Using specified host {args.host}') @@ -144,20 +146,29 @@ else: app.config['SECRET_KEY'] = secrets.token_urlsafe(16) if __name__ == '__main__': - import jellyfin_accounts.web_api - import jellyfin_accounts.web from waitress import serve - host = config['ui']['host'] - port = config['ui']['port'] - log.info(f'Starting web UI on {host}:{port}') - if config.getboolean('password_resets', 'enabled'): - def start_pwr(): - import jellyfin_accounts.pw_reset - jellyfin_accounts.pw_reset.start() - pwr = threading.Thread(target=start_pwr, daemon=True) - log.info('Starting email thread') - pwr.start() + if first_run: + import jellyfin_accounts.setup + host = '0.0.0.0' + port = 8056 + log.info('Starting web UI for first run setup...') + serve(app, + host=host, + port=port) + else: + import jellyfin_accounts.web_api + import jellyfin_accounts.web + host = config['ui']['host'] + port = config['ui']['port'] + log.info(f'Starting web UI on {host}:{port}') + if config.getboolean('password_resets', 'enabled'): + def start_pwr(): + import jellyfin_accounts.pw_reset + jellyfin_accounts.pw_reset.start() + pwr = threading.Thread(target=start_pwr, daemon=True) + log.info('Starting email thread') + pwr.start() - serve(app, - host=host, - port=int(port)) + serve(app, + host=host, + port=int(port)) diff --git a/setup.py b/setup.py index cd89b24..b52bada 100644 --- a/setup.py +++ b/setup.py @@ -27,12 +27,14 @@ setup( 'data/invite-email.html', 'data/invite-email.mjml', 'data/invite-email.txt']), - ('data/static', ['data/static/admin.js']), + ('data/static', ['data/static/admin.js', + 'data/static/setup.js']), ('data/templates', [ 'data/templates/404.html', 'data/templates/invalidCode.html', 'data/templates/admin.html', - 'data/templates/form.html'])], + 'data/templates/form.html', + 'data/templates/setup.html'])], zip_safe=False, install_requires=[ 'pyOpenSSL',