Added hosting settings to admin panel

This commit is contained in:
CasVT
2024-02-28 22:11:12 +01:00
parent ae56927d0f
commit 4073f60539
9 changed files with 299 additions and 62 deletions

52
MIND.py
View File

@@ -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()
# =================

View File

@@ -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:

View File

@@ -112,3 +112,4 @@ class RepeatQuantity(BaseEnum):
class RestartVars(BaseEnum):
DB_IMPORT = "db_import"
HOST_CHANGE = "host_change"

View File

@@ -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

View File

@@ -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(

View File

@@ -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'

View File

@@ -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 {

View File

@@ -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 =>

View File

@@ -53,7 +53,7 @@
<td><label for="login-time-input">Login Time</label></td>
<td>
<div class="number-input">
<input type="number" id="login-time-input" min="1" max="43200">
<input type="number" id="login-time-input" min="1" max="43200" required>
<p>Min</p>
</div>
<p>For how long users stay logged in before having to authenticate again. Between 1 minute and 1 month.</p>
@@ -74,6 +74,38 @@
</table>
</div>
</form>
<form id="hosting-form">
<h2>Hosting</h2>
<div class="settings-table-container">
<table class="settings-table">
<tbody>
<tr>
<td><label for="host-input">Host</label></td>
<td>
<input type="text" id="host-input" required>
<p>Valid IPv4 address (default is '0.0.0.0' for all available interfaces)</p>
</td>
</tr>
<tr>
<td><label for="port-input">Port</label></td>
<td>
<input type="number" id="port-input" min="1" max="65535" required>
<p>The port used to access the web UI (default is '8080')</p>
</td>
</tr>
<tr>
<td><label for="url-prefix-input">URL Prefix</label></td>
<td>
<input type="text" id="url-prefix-input">
<p>For reverse proxy support (default is empty).</p>
</td>
</tr>
</tbody>
</table>
<button type="submit" id="save-hosting-button">Save and Restart</button>
</div>
<p>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.</p>
</form>
<h2>User Management</h2>
<div class="add-user-container">
<button id="add-user-button">