From 4073f60539681b63c9716688448a47c0df8ae7ff Mon Sep 17 00:00:00 2001 From: CasVT Date: Wed, 28 Feb 2024 22:11:12 +0100 Subject: [PATCH] Added hosting settings to admin panel --- MIND.py | 52 +++++++++++++--- backend/db.py | 15 ++++- backend/helpers.py | 1 + backend/settings.py | 111 ++++++++++++++++++++++++++++------ frontend/api.py | 58 +++++++++++++----- frontend/input_validation.py | 21 ++++++- frontend/static/css/admin.css | 29 ++++----- frontend/static/js/admin.js | 40 ++++++++++++ frontend/templates/admin.html | 34 ++++++++++- 9 files changed, 299 insertions(+), 62 deletions(-) diff --git a/MIND.py b/MIND.py index 05b395d..1c4d918 100644 --- a/MIND.py +++ b/MIND.py @@ -20,18 +20,25 @@ from backend.db import (DBConnection, ThreadedTaskDispatcher, close_db, revert_db_import, setup_db) from backend.helpers import RestartVars, check_python_version, folder_path from backend.reminders import ReminderHandler +from backend.settings import get_setting, restore_hosting_settings from frontend.api import (APIVariables, admin_api, admin_api_prefix, api, - api_prefix, revert_db_thread) + api_prefix, revert_db_thread, revert_hosting_thread) from frontend.ui import UIVariables, ui +#============================= +# WARNING: +# These settings have moved into the admin panel. Their current value has been +# taken over. The values will from now on be ignored, and the variables will +# be deleted next version. HOST = '0.0.0.0' PORT = '8080' URL_PREFIX = '' # Must either be empty or start with '/' e.g. '/mind' +#============================= + LOGGING_LEVEL = logging.INFO THREADS = 10 DB_FILENAME = 'db', 'MIND.db' -UIVariables.url_prefix = URL_PREFIX logging.basicConfig( level=LOGGING_LEVEL, format='[%(asctime)s][%(threadName)s][%(levelname)s] %(message)s', @@ -53,11 +60,6 @@ def _create_app() -> Flask: app.config['SECRET_KEY'] = urandom(32) app.config['JSONIFY_PRETTYPRINT_REGULAR'] = True app.config['JSON_SORT_KEYS'] = False - app.config['APPLICATION_ROOT'] = URL_PREFIX - app.wsgi_app = DispatcherMiddleware( - Flask(__name__), - {URL_PREFIX: app.wsgi_app} - ) # Add error handlers @app.errorhandler(400) @@ -87,6 +89,21 @@ def _create_app() -> Flask: return app +def _set_url_prefix(app: Flask, url_prefix: str) -> None: + """Change the URL prefix of the server. + + Args: + app (Flask): The `Flask` instance to change the URL prefix of. + url_prefix (str): The desired URL prefix to set it to. + """ + app.config["APPLICATION_ROOT"] = url_prefix + app.wsgi_app = DispatcherMiddleware( + Flask(__name__), + {url_prefix: app.wsgi_app} + ) + UIVariables.url_prefix = url_prefix + return + def _handle_flags(flag: Union[None, str]) -> None: """Run flag specific actions on startup. @@ -97,6 +114,10 @@ def _handle_flags(flag: Union[None, str]) -> None: logging.info('Starting timer for database import') revert_db_thread.start() + elif flag == RestartVars.HOST_CHANGE: + logging.info('Starting timer for hosting changes') + revert_hosting_thread.start() + return def _handle_flags_pre_restart(flag: Union[None, str]) -> None: @@ -108,6 +129,11 @@ def _handle_flags_pre_restart(flag: Union[None, str]) -> None: if flag == RestartVars.DB_IMPORT: revert_db_import(swap=True) + elif flag == RestartVars.HOST_CHANGE: + with Flask(__name__).app_context(): + restore_hosting_settings() + close_db() + return def MIND() -> None: @@ -134,6 +160,12 @@ def MIND() -> None: reminder_handler = ReminderHandler(app.app_context) with app.app_context(): setup_db() + + host = get_setting("host") + port = get_setting("port") + url_prefix = get_setting("url_prefix") + _set_url_prefix(app, url_prefix) + reminder_handler.find_next_reminder() # Create waitress server and run @@ -142,12 +174,12 @@ def MIND() -> None: server = create_server( app, _dispatcher=dispatcher, - host=HOST, - port=PORT, + host=host, + port=port, threads=THREADS ) APIVariables.server_instance = server - logging.info(f'MIND running on http://{HOST}:{PORT}{URL_PREFIX}') + logging.info(f'MIND running on http://{host}:{port}{url_prefix}') # ================= server.run() # ================= diff --git a/backend/db.py b/backend/db.py index b0bf70e..3d1bb71 100644 --- a/backend/db.py +++ b/backend/db.py @@ -21,7 +21,7 @@ from backend.custom_exceptions import (AccessUnauthorized, InvalidDatabaseFile, UserNotFound) from backend.helpers import RestartVars -__DATABASE_VERSION__ = 8 +__DATABASE_VERSION__ = 9 __DATEBASE_NAME_ORIGINAL__ = "MIND_original.db" class DB_Singleton(type): @@ -271,6 +271,19 @@ def migrate_db(current_db_version: int) -> None: WHERE username = 'admin'; """) + current_db_version = 8 + + if current_db_version == 8: + # V8 -> V9 + from backend.settings import set_setting + from MIND import HOST, PORT, URL_PREFIX + + set_setting('host', HOST) + set_setting('port', int(PORT)) + set_setting('url_prefix', URL_PREFIX) + + current_db_version = 9 + return def setup_db() -> None: diff --git a/backend/helpers.py b/backend/helpers.py index 34b7985..b2568f9 100644 --- a/backend/helpers.py +++ b/backend/helpers.py @@ -112,3 +112,4 @@ class RepeatQuantity(BaseEnum): class RestartVars(BaseEnum): DB_IMPORT = "db_import" + HOST_CHANGE = "host_change" diff --git a/backend/settings.py b/backend/settings.py index 89c4649..a32d484 100644 --- a/backend/settings.py +++ b/backend/settings.py @@ -4,6 +4,8 @@ Getting and setting settings """ +from typing import Any + from backend.custom_exceptions import InvalidKeyValue, KeyNotFound from backend.db import __DATABASE_VERSION__, get_db @@ -11,21 +13,24 @@ default_settings = { 'allow_new_accounts': True, 'login_time': 3600, 'login_time_reset': True, - 'database_version': __DATABASE_VERSION__ + 'database_version': __DATABASE_VERSION__, + 'host': '0.0.0.0', + 'port': 8080, + 'url_prefix': '' } def _format_setting(key: str, value): - """Turn python value in to database value + """Turn python value in to database value. Args: - key (str): The key of the value - value (Any): The value itself + key (str): The key of the value. + value (Any): The value itself. Raises: - InvalidKeyValue: The value is not valid + InvalidKeyValue: The value is not valid. Returns: - Any: The converted value + Any: The converted value. """ if key == 'database_version': try: @@ -42,33 +47,48 @@ def _format_setting(key: str, value): if not isinstance(value, int) or not 60 <= value <= 2592000: raise InvalidKeyValue(key, value) + elif key == 'host': + if not isinstance(value, str): + raise InvalidKeyValue(key, value) + + elif key == 'port': + if not isinstance(value, int) or not 1 <= value <= 65535: + raise InvalidKeyValue(key, value) + + elif key == 'url_prefix': + if not isinstance(value, str): + raise InvalidKeyValue(key, value) + + if value: + value = '/' + value.strip('/') + return value -def _reverse_format_setting(key: str, value): - """Turn database value in to python value +def _reverse_format_setting(key: str, value: Any) -> Any: + """Turn database value in to python value. Args: - key (str): The key of the value - value (Any): The value itself + key (str): The key of the value. + value (Any): The value itself. Returns: - Any: The converted value + Any: The converted value. """ if key in ('allow_new_accounts', 'login_time_reset'): value = value == 1 return value -def get_setting(key: str): - """Get a value from the config +def get_setting(key: str) -> Any: + """Get a value from the config. Args: - key (str): The key of which to get the value + key (str): The key of which to get the value. Raises: - KeyNotFound: Key is not in config + KeyNotFound: Key is not in config. Returns: - Any: The value of the key + Any: The value of the key. """ result = get_db().execute( "SELECT value FROM config WHERE key = ? LIMIT 1;", @@ -95,12 +115,15 @@ def get_admin_settings() -> dict: WHERE key = 'allow_new_accounts' OR key = 'login_time' - OR key = 'login_time_reset'; + OR key = 'login_time_reset' + OR key = 'host' + OR key = 'port' + OR key = 'url_prefix'; """ ) )) -def set_setting(key: str, value) -> None: +def set_setting(key: str, value: Any) -> None: """Set a value in the config Args: @@ -121,3 +144,55 @@ def set_setting(key: str, value) -> None: (value, key) ) return + +def backup_hosting_settings() -> None: + """Copy current hosting settings to backup values. + """ + cursor = get_db() + hosting_settings = dict(cursor.execute(""" + SELECT key, value + FROM config + WHERE key = 'host' + OR key = 'port' + OR key = 'url_prefix' + LIMIT 3; + """ + )) + hosting_settings = {f'{k}_backup': v for k, v in hosting_settings.items()} + + cursor.executemany(""" + INSERT INTO config(key, value) + VALUES (?, ?) + ON CONFLICT(key) DO + UPDATE + SET value = ?; + """, + ((k, v, v) for k, v in hosting_settings.items()) + ) + + return + +def restore_hosting_settings() -> None: + """Copy the hosting settings from the backup over to the main keys. + """ + cursor = get_db() + hosting_settings = dict(cursor.execute(""" + SELECT key, value + FROM config + WHERE key = 'host_backup' + OR key = 'port_backup' + OR key = 'url_prefix_backup' + LIMIT 3; + """ + )) + if len(hosting_settings) < 3: + return + + hosting_settings = {k.split('_backup')[0]: v for k, v in hosting_settings.items()} + + cursor.executemany( + "UPDATE config SET value = ? WHERE key = ?", + ((v, k) for k, v in hosting_settings.items()) + ) + + return diff --git a/frontend/api.py b/frontend/api.py index 027a685..a7775d0 100644 --- a/frontend/api.py +++ b/frontend/api.py @@ -24,25 +24,28 @@ from backend.custom_exceptions import (AccessUnauthorized, APIKeyExpired, UsernameInvalid, UsernameTaken, UserNotFound) from backend.db import get_db, import_db, revert_db_import -from backend.helpers import folder_path +from backend.helpers import RestartVars, folder_path from backend.notification_service import get_apprise_services -from backend.settings import get_admin_settings, get_setting, set_setting +from backend.settings import (backup_hosting_settings, get_admin_settings, + get_setting, set_setting) from backend.users import Users from frontend.input_validation import (AllowNewAccountsVariable, ColorVariable, DatabaseFileVariable, EditNotificationServicesVariable, EditTimeVariable, EditTitleVariable, - EditURLVariable, LoginTimeResetVariable, + EditURLVariable, HostVariable, + LoginTimeResetVariable, LoginTimeVariable, Method, Methods, NewPasswordVariable, NotificationServicesVariable, PasswordCreateVariable, - PasswordVariable, QueryVariable, - RepeatIntervalVariable, + PasswordVariable, PortVariable, + QueryVariable, RepeatIntervalVariable, RepeatQuantityVariable, SortByVariable, TextVariable, TimelessSortByVariable, TimeVariable, TitleVariable, - URLVariable, UsernameCreateVariable, + UrlPrefixVariable, URLVariable, + UsernameCreateVariable, UsernameVariable, WeekDaysVariable, admin_api, admin_api_prefix, api, api_prefix, get_api_docs, @@ -93,12 +96,22 @@ def revert_db() -> None: restart_server() return +def revert_hosting() -> None: + """Revert the hosting changes. + """ + logging.warning(f'Timer for hosting changes expired; reverting back to original settings') + APIVariables.handle_flags = True + restart_server() + return + shutdown_server_thread = Timer(1.0, shutdown_server) shutdown_server_thread.name = "InternalStateHandler" restart_server_thread = Timer(1.0, restart_server) restart_server_thread.name = "InternalStateHandler" revert_db_thread = Timer(60.0, revert_db) revert_db_thread.name = "DatabaseImportHandler" +revert_hosting_thread = Timer(60.0, revert_hosting) +revert_hosting_thread.name = "HostingHandler" @dataclass class ApiKeyEntry: @@ -211,6 +224,10 @@ def api_login(inputs: Dict[str, str]): revert_db_thread.cancel() revert_db_import(swap=False) + elif user.admin and revert_hosting_thread.is_alive(): + logging.info('Timer for hosting changes diffused') + revert_hosting_thread.cancel() + # Generate an API key until one # is generated that isn't used already while True: @@ -737,7 +754,8 @@ def api_settings(): ), put=Method( vars=[AllowNewAccountsVariable, LoginTimeVariable, - LoginTimeResetVariable], + LoginTimeResetVariable, HostVariable, PortVariable, + UrlPrefixVariable], description='Edit the admin settings' ) ), @@ -749,14 +767,24 @@ def api_admin_settings(inputs: Dict[str, Any]): return return_api(get_admin_settings()) elif request.method == 'PUT': - values = { - 'allow_new_accounts': inputs['allow_new_accounts'], - 'login_time': inputs['login_time'], - 'login_time_reset': inputs['login_time_reset'] - } - logging.info(f'Submitting admin settings: {values}') - for k, v in values.items(): - set_setting(k, v) + logging.info(f'Submitting admin settings: {inputs}') + + hosting_changes = any( + inputs[s] is not None + for s in ('host', 'port', 'url_prefix') + ) + + if hosting_changes: + backup_hosting_settings() + + for k, v in inputs.items(): + if v is not None: + set_setting(k, v) + + if hosting_changes: + APIVariables.restart_args = [RestartVars.HOST_CHANGE.value] + restart_server_thread.start() + return return_api({}) @admin_api.route( diff --git a/frontend/input_validation.py b/frontend/input_validation.py index 6985efc..beedb6f 100644 --- a/frontend/input_validation.py +++ b/frontend/input_validation.py @@ -378,23 +378,38 @@ class AdminSettingsVariable(BaseInputVariable): return True -class AllowNewAccountsVariable(AdminSettingsVariable): +class AllowNewAccountsVariable(NonRequiredVersion, AdminSettingsVariable): name = 'allow_new_accounts' description = ('Whether or not to allow users to register a new account. ' + 'The admin can always add a new account.') -class LoginTimeVariable(AdminSettingsVariable): +class LoginTimeVariable(NonRequiredVersion, AdminSettingsVariable): name = 'login_time' description = ('How long a user stays logged in, in seconds. ' + 'Between 1 min and 1 month (60 <= sec <= 2592000)') -class LoginTimeResetVariable(AdminSettingsVariable): +class LoginTimeResetVariable(NonRequiredVersion, AdminSettingsVariable): name = 'login_time_reset' description = 'If the Login Time timer should reset with each API request.' +class HostVariable(NonRequiredVersion, AdminSettingsVariable): + name = 'host' + description = 'The IP to bind to. Use 0.0.0.0 to bind to all addresses.' + + +class PortVariable(NonRequiredVersion, AdminSettingsVariable): + name = 'port' + description = 'The port to listen on.' + + +class UrlPrefixVariable(NonRequiredVersion, AdminSettingsVariable): + name = 'url_prefix' + description = 'The base url to run on. Useful for reverse proxies. Empty string to disable.' + + class DatabaseFileVariable(BaseInputVariable): name = 'file' description = 'The MIND database file' diff --git a/frontend/static/css/admin.css b/frontend/static/css/admin.css index 5bb34b7..d3ad90c 100644 --- a/frontend/static/css/admin.css +++ b/frontend/static/css/admin.css @@ -52,7 +52,8 @@ main { padding-top: var(--nav-width); } -#settings-form { +#settings-form, +#hosting-form { display: flex; flex-direction: column; align-items: center; @@ -74,7 +75,8 @@ h2 { overflow-x: auto; display: flex; - justify-content: center; + flex-direction: column; + align-items: center; } .settings-table { @@ -96,6 +98,7 @@ h2 { .settings-table td:first-child { width: 50%; padding-right: var(--middle-spacing); + padding-top: .55rem; text-align: right; } @@ -155,14 +158,14 @@ h2 { flex-wrap: wrap; } +#save-hosting-button, #add-user-button, #download-db-button, #upload-db-button, #restart-button, #shutdown-button { width: min(15rem, 100%); - height: 2rem; - + padding: .5rem 1rem; border-radius: 4px; background-color: var(--color-gray); @@ -170,11 +173,12 @@ h2 { box-shadow: var(--default-shadow); } -#download-db-button, -#upload-db-button, -#restart-button, -#shutdown-button { - height: unset; +#save-hosting-button { + align-self: center; +} + +#add-user-button { + height: 2rem; } #add-user-button > svg { @@ -230,6 +234,7 @@ h2 { padding: .25rem; } +#hosting-form > p, #upload-database-form p { max-width: 50rem; margin-inline: auto; @@ -238,10 +243,6 @@ h2 { } @media (max-width: 40rem) { - #settings-form { - justify-content: flex-start; - } - h2 { text-align: center; padding-inline: 0; @@ -249,7 +250,7 @@ h2 { .settings-table-container, .user-table-container { - justify-content: left; + align-items: flex-start; } .settings-table tbody { diff --git a/frontend/static/js/admin.js b/frontend/static/js/admin.js index 333e691..442281a 100644 --- a/frontend/static/js/admin.js +++ b/frontend/static/js/admin.js @@ -4,6 +4,14 @@ const setting_inputs = { login_time_reset: document.querySelector('#login-time-reset-input') }; +const hosting_inputs = { + form: document.querySelector('#hosting-form'), + host: document.querySelector('#host-input'), + port: document.querySelector('#port-input'), + url_prefix: document.querySelector('#url-prefix-input'), + submit: document.querySelector('#save-hosting-button') +}; + const user_inputs = { username: document.querySelector('#new-username-input'), password: document.querySelector('#new-password-input') @@ -44,6 +52,9 @@ function loadSettings() { setting_inputs.allow_new_accounts.checked = json.result.allow_new_accounts; setting_inputs.login_time.value = Math.round(json.result.login_time / 60); setting_inputs.login_time_reset.value = json.result.login_time_reset.toString(); + hosting_inputs.host.value = json.result.host; + hosting_inputs.port.value = json.result.port; + hosting_inputs.url_prefix.value = json.result.url_prefix; }); }; @@ -69,6 +80,34 @@ function submitSettings() { }); }; +function submitHostingSettings() { + hosting_inputs.submit.innerText = 'Restarting'; + const data = { + host: hosting_inputs.host.value, + port: parseInt(hosting_inputs.port.value), + url_prefix: hosting_inputs.url_prefix.value + }; + fetch(`${url_prefix}/api/admin/settings?api_key=${api_key}`, { + 'method': 'PUT', + 'headers': {'Content-Type': 'application/json'}, + 'body': JSON.stringify(data) + }) + .then(response => response.json()) + .then(json => { + if (json.error !== null) + return Promise.reject(json) + + setTimeout( + () => window.location.reload(), + 1000 + ) + }) + .catch(json => { + if (['ApiKeyInvalid', 'ApiKeyExpired'].includes(json.error)) + window.location.href = `${url_prefix}/`; + }); +}; + function toggleAddUser() { const el = document.querySelector('#add-user-row'); if (el.classList.contains('hidden')) { @@ -244,6 +283,7 @@ loadUsers(); document.querySelector('#logout-button').onclick = e => logout(); document.querySelector('#settings-form').action = 'javascript:submitSettings();'; +hosting_inputs.form.action = 'javascript:submitHostingSettings();'; document.querySelector('#add-user-button').onclick = e => toggleAddUser(); document.querySelector('#add-user-form').action = 'javascript:addUser()'; document.querySelector('#download-db-button').onclick = e => diff --git a/frontend/templates/admin.html b/frontend/templates/admin.html index 571f399..c84e95b 100644 --- a/frontend/templates/admin.html +++ b/frontend/templates/admin.html @@ -53,7 +53,7 @@
- +

Min

For how long users stay logged in before having to authenticate again. Between 1 minute and 1 month.

@@ -74,6 +74,38 @@ +
+

Hosting

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

Valid IPv4 address (default is '0.0.0.0' for all available interfaces)

+
+ +

The port used to access the web UI (default is '8080')

+
+ +

For reverse proxy support (default is empty).

+
+ +
+

IMPORTANT: After saving the hosting settings, it is required to log into the admin panel within 1 minute (60 seconds) in order to keep the new hosting settings. Otherwise, MIND will revert the changes and go back to the old hosting settings.

+

User Management