diff --git a/backend/custom_exceptions.py b/backend/custom_exceptions.py index feee8f8..230f471 100644 --- a/backend/custom_exceptions.py +++ b/backend/custom_exceptions.py @@ -85,3 +85,7 @@ class APIKeyExpired(CustomException): def __init__(self, e=None) -> None: return + +class NewAccountsNotAllowed(CustomException): + """It's not allowed to create a new account""" + api_response = {'error': 'NewAccountsNotAllowed', 'result': {}, 'code': 403} diff --git a/backend/db.py b/backend/db.py index e694739..a7d113c 100644 --- a/backend/db.py +++ b/backend/db.py @@ -1,7 +1,7 @@ #-*- coding: utf-8 -*- -from datetime import datetime import logging +from datetime import datetime from sqlite3 import Connection, ProgrammingError, Row from threading import current_thread, main_thread from time import time @@ -219,7 +219,26 @@ def migrate_db(current_db_version: int) -> None: if current_db_version == 7: # V7 -> V8 + from backend.settings import _format_setting, default_settings from backend.users import register_user + + cursor.executescript(""" + DROP TABLE config; + CREATE TABLE IF NOT EXISTS config( + key VARCHAR(255) PRIMARY KEY, + value BLOB NOT NULL + ); + """ + ) + cursor.executemany(""" + INSERT OR IGNORE INTO config(key, value) + VALUES (?, ?); + """, + map( + lambda kv: (kv[0], _format_setting(*kv)), + default_settings.items() + ) + ) cursor.executescript(""" ALTER TABLE users @@ -243,6 +262,8 @@ def migrate_db(current_db_version: int) -> None: def setup_db() -> None: """Setup the database """ + from backend.settings import (_format_setting, default_settings, get_setting, + set_setting) cursor = get_db() cursor.execute("PRAGMA journal_mode = wal;") @@ -314,26 +335,24 @@ def setup_db() -> None: ); CREATE TABLE IF NOT EXISTS config( key VARCHAR(255) PRIMARY KEY, - value TEXT NOT NULL + value BLOB NOT NULL ); """) - cursor.execute(""" + cursor.executemany(""" INSERT OR IGNORE INTO config(key, value) - VALUES ('database_version', ?); + VALUES (?, ?); """, - (__DATABASE_VERSION__,) + map( + lambda kv: (kv[0], _format_setting(*kv)), + default_settings.items() + ) ) - current_db_version = int(cursor.execute( - "SELECT value FROM config WHERE key = 'database_version' LIMIT 1;" - ).fetchone()[0]) + current_db_version = get_setting('database_version') logging.debug(f'Current database version {current_db_version} and desired database version {__DATABASE_VERSION__}') if current_db_version < __DATABASE_VERSION__: migrate_db(current_db_version) - cursor.execute( - "UPDATE config SET value = ? WHERE key = 'database_version';", - (__DATABASE_VERSION__,) - ) + set_setting('database_version', __DATABASE_VERSION__) return diff --git a/backend/settings.py b/backend/settings.py new file mode 100644 index 0000000..06eb753 --- /dev/null +++ b/backend/settings.py @@ -0,0 +1,110 @@ +#-*- coding: utf-8 -*- + +import logging +from backend.custom_exceptions import InvalidKeyValue, KeyNotFound +from backend.db import __DATABASE_VERSION__, get_db + +default_settings = { + 'allow_new_accounts': True, + 'database_version': __DATABASE_VERSION__ +} + +def _format_setting(key: str, value): + """Turn python value in to database value + + Args: + key (str): The key of the value + value (Any): The value itself + + Raises: + InvalidKeyValue: The value is not valid + + Returns: + Any: The converted value + """ + if key == 'database_version': + try: + value = int(value) + except ValueError: + raise InvalidKeyValue(key, value) + + elif key == 'allow_new_accounts': + if not isinstance(value, bool): + raise InvalidKeyValue(key, value) + value = int(value) + return value + +def _reverse_format_setting(key: str, value): + """Turn database value in to python value + + Args: + key (str): The key of the value + value (Any): The value itself + + Returns: + Any: The converted value + """ + if key == 'allow_new_accounts': + value = value == 1 + return value + +def get_setting(key: str): + """Get a value from the config + + Args: + key (str): The key of which to get the value + + Raises: + KeyNotFound: Key is not in config + + Returns: + Any: The value of the key + """ + result = get_db().execute( + "SELECT value FROM config WHERE key = ? LIMIT 1;", + (key,) + ).fetchone() + if result is None: + raise KeyNotFound(key) + + result = _reverse_format_setting(key, result[0]) + + return result + +def get_admin_settings() -> dict: + """Get all admin settings + + Returns: + dict: The admin settings + """ + return dict(( + (key, _reverse_format_setting(key, value)) + for (key, value) in get_db().execute(""" + SELECT key, value + FROM config + WHERE key = 'allow_new_accounts'; + """ + ) + )) + +def set_setting(key: str, value) -> None: + """Set a value in the config + + Args: + key (str): The key for which to set the value + value (Any): The value to give to the key + + Raises: + KeyNotFound: The key is not in the config + InvalidKeyValue: The value is not allowed for the key + """ + if not key in (*default_settings, 'database_version'): + raise KeyNotFound(key) + + value = _format_setting(key, value) + + get_db().execute( + "UPDATE config SET value = ? WHERE key = ?;", + (value, key) + ) + return diff --git a/backend/users.py b/backend/users.py index 8e554f3..81397fc 100644 --- a/backend/users.py +++ b/backend/users.py @@ -1,12 +1,15 @@ #-*- coding: utf-8 -*- import logging -from backend.custom_exceptions import (AccessUnauthorized, UsernameInvalid, + +from backend.custom_exceptions import (AccessUnauthorized, + NewAccountsNotAllowed, UsernameInvalid, UsernameTaken, UserNotFound) from backend.db import get_db from backend.notification_service import NotificationServices from backend.reminders import Reminders from backend.security import generate_salt_hash, get_hash +from backend.settings import get_setting from backend.static_reminders import StaticReminders from backend.templates import Templates @@ -134,12 +137,17 @@ def register_user(username: str, password: str) -> int: Raises: UsernameInvalid: Username not allowed or contains invalid characters UsernameTaken: Username is already taken; usernames must be unique + NewAccountsNotAllowed: In the admin panel, new accounts are set to be + not allowed. Returns: user_id (int): The id of the new user. User registered successful """ logging.info(f'Registering user with username {username}') + if not get_setting('allow_new_accounts'): + raise NewAccountsNotAllowed + # Check if username is valid _check_username(username) diff --git a/frontend/api.py b/frontend/api.py index 3f2a53e..f67118d 100644 --- a/frontend/api.py +++ b/frontend/api.py @@ -1,5 +1,6 @@ #-*- coding: utf-8 -*- +import logging from abc import ABC, abstractmethod from os import urandom from re import compile @@ -13,6 +14,7 @@ from backend.custom_exceptions import (AccessUnauthorized, APIKeyExpired, APIKeyInvalid, InvalidKeyValue, InvalidTime, KeyNotFound, + NewAccountsNotAllowed, NotificationServiceInUse, NotificationServiceNotFound, ReminderNotFound, TemplateNotFound, @@ -22,6 +24,7 @@ NotificationServices, get_apprise_services) from backend.reminders import Reminders, reminder_handler +from backend.settings import _format_setting, get_admin_settings, set_setting from backend.static_reminders import StaticReminders from backend.templates import Template, Templates from backend.users import User, register_user @@ -111,7 +114,11 @@ class NewPasswordVariable(PasswordVariable): related_exceptions = [KeyNotFound] class UsernameCreateVariable(UsernameVariable): - related_exceptions = [KeyNotFound, UsernameInvalid, UsernameTaken] + related_exceptions = [ + KeyNotFound, + UsernameInvalid, UsernameTaken, + NewAccountsNotAllowed + ] class PasswordCreateVariable(PasswordVariable): related_exceptions = [KeyNotFound] @@ -254,7 +261,7 @@ class ColorVariable(DefaultInputVariable): default = None related_exceptions = [InvalidKeyValue] - def validate(self) -> None: + def validate(self) -> bool: return self.value is None or color_regex.search(self.value) class QueryVariable(DefaultInputVariable): @@ -262,6 +269,21 @@ class QueryVariable(DefaultInputVariable): description = 'The search term' source = DataSource.VALUES +class AdminSettingsVariable(DefaultInputVariable): + related_exceptions = [KeyNotFound, InvalidKeyValue] + + def validate(self) -> bool: + try: + _format_setting(self.name, self.value) + except InvalidKeyValue: + return False + return True + +class AllowNewAccountsVariable(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.') + def input_validation() -> Union[None, Dict[str, Any]]: """Checks, extracts and transforms inputs @@ -275,10 +297,15 @@ def input_validation() -> Union[None, Dict[str, Any]]: """ inputs = {} + input_variables: Dict[str, List[Union[List[InputVariable], str]]] if request.path.startswith(admin_api_prefix): - input_variables = api_docs[request.url_rule.rule.split(admin_api_prefix)[1]]['input_variables'] + input_variables = api_docs[ + _admin_api_prefix + request.url_rule.rule.split(admin_api_prefix)[1] + ]['input_variables'] else: - input_variables = api_docs[request.url_rule.rule.split(api_prefix)[1]]['input_variables'] + input_variables = api_docs[ + request.url_rule.rule.split(api_prefix)[1] + ]['input_variables'] if not input_variables: return @@ -296,8 +323,11 @@ def input_validation() -> Union[None, Dict[str, Any]]: ): raise KeyNotFound(input_variable.name) - input_value = given_variables[input_variable.source].get(input_variable.name, input_variable.default) - + input_value = given_variables[input_variable.source].get( + input_variable.name, + input_variable.default + ) + if not input_variable(input_value).validate(): raise InvalidKeyValue(input_variable.name, input_value) @@ -319,19 +349,35 @@ def route( **options: Any ) -> Callable[[T_route], T_route]: - api_docs[rule] = { - 'endpoint': rule, + if self == api: + processed_rule = rule + elif self == admin_api: + processed_rule = _admin_api_prefix + rule + else: + raise NotImplementedError + + api_docs[processed_rule] = { + 'endpoint': processed_rule, 'description': description, 'requires_auth': requires_auth, 'methods': options['methods'], - 'input_variables': {k: v[0] for k, v in input_variables.items() if v and v[0]}, - 'method_descriptions': {k: v[1] for k, v in input_variables.items() if v and len(v) == 2 and v[1]} + 'input_variables': { + k: v[0] + for k, v in input_variables.items() + if v and v[0] + }, + 'method_descriptions': { + k: v[1] + for k, v in input_variables.items() + if v and len(v) == 2 and v[1] + } } return super().route(rule, **options) api_prefix = "/api" -admin_api_prefix = api_prefix + "/admin" +_admin_api_prefix = '/admin' +admin_api_prefix = api_prefix + _admin_api_prefix api = APIBlueprint('api', __name__) admin_api = APIBlueprint('admin_api', __name__) api_key_map = {} @@ -376,9 +422,13 @@ def auth() -> None: def endpoint_wrapper(method: Callable) -> Callable: def wrapper(*args, **kwargs): if request.path.startswith(admin_api_prefix): - requires_auth = api_docs[request.url_rule.rule.split(admin_api_prefix)[1]]['requires_auth'] + requires_auth = api_docs[ + _admin_api_prefix + request.url_rule.rule.split(admin_api_prefix)[1] + ]['requires_auth'] else: - requires_auth = api_docs[request.url_rule.rule.split(api_prefix)[1]]['requires_auth'] + requires_auth = api_docs[ + request.url_rule.rule.split(api_prefix)[1] + ]['requires_auth'] try: if requires_auth: auth() @@ -395,7 +445,8 @@ def wrapper(*args, **kwargs): NotificationServiceInUse, InvalidTime, KeyNotFound, InvalidKeyValue, APIKeyInvalid, APIKeyExpired, - TemplateNotFound) as e: + TemplateNotFound, + NewAccountsNotAllowed) as e: return return_api(**e.api_response) wrapper.__name__ = method.__name__ @@ -805,3 +856,40 @@ def api_get_static_reminder(inputs: Dict[str, Any], s_id: int): elif request.method == 'DELETE': reminders.fetchone(s_id).delete() return return_api({}) + +#=================== +# Admin panel endpoints +#=================== + +@api.route( + '/settings', + 'Get the admin settings', + requires_auth=False, + methods=['GET'] +) +@endpoint_wrapper +def api_settings(): + return return_api(get_admin_settings()) + +@admin_api.route( + '/settings', + 'Interact with the admin settings', + {'GET': [[], + 'Get the admin settings'], + 'PUT': [[AllowNewAccountsVariable], + 'Edit the admin settings']}, + methods=['GET', 'PUT'] +) +@endpoint_wrapper +def api_admin_settings(inputs: Dict[str, Any]): + if request.method == 'GET': + return return_api(get_admin_settings()) + + elif request.method == 'PUT': + values = { + 'allow_new_accounts': inputs['allow_new_accounts'] + } + logging.info(f'Submitting admin settings: {values}') + for k, v in values.items(): + set_setting(k, v) + return return_api({}) diff --git a/frontend/static/css/admin.css b/frontend/static/css/admin.css index e69de29..6f2721f 100644 --- a/frontend/static/css/admin.css +++ b/frontend/static/css/admin.css @@ -0,0 +1,143 @@ +main { + position: relative; +} + +.action-buttons { + --spacing: .5rem; + + position: absolute; + margin: var(--spacing); + inset: 0 0 auto 0; + height: var(--nav-width); + + display: flex; + justify-content: center; + align-items: center; + gap: calc(var(--spacing) * 3); + + padding: var(--spacing); + border-radius: 4px; + background-color: var(--color-gray); +} + +.action-buttons > button { + height: 100%; + + display: flex; + justify-content: center; + align-items: center; + + padding: .5rem; + border-radius: 4px; + background-color: var(--color-dark); + color: var(--color-light); + + transition: background-color .1s ease-in-out; +} + +.action-buttons > button:hover { + background-color: var(--color-gray); +} + +.action-buttons > button > svg { + height: 1.8rem; + width: 2rem; +} + +.form-container { + height: calc(100vh - var(--header-height)); + overflow-y: auto; + + padding: .5rem; + padding-top: calc(1rem + var(--nav-width)); +} + +#settings-form { + display: flex; + flex-direction: column; + align-items: center; + gap: 1.5rem; +} + +h2 { + width: 100%; + + border-bottom: 1px solid var(--color-gray); + padding: 1rem 1rem 0rem 1rem; + + font-size: clamp(1rem, 10vw, 2rem); +} + +.table-container { + width: 100%; + overflow-x: auto; + + display: flex; + justify-content: center; +} + +.settings-table { + --max-width: 55rem; + width: 100%; + max-width: var(--max-width); + min-width: 20rem; + + border-spacing: 0px; + border: none; +} + +.settings-table td { + --middle-spacing: .75rem; + padding-bottom: 1rem; + vertical-align: top; +} + +.settings-table td:first-child { + width: 50%; + padding-right: var(--middle-spacing); + text-align: right; +} + +.settings-table td:nth-child(2) { + min-width: calc(var(--max-width) * 0.5); + padding-left: var(--middle-spacing); +} + +.settings-table td p { + color: var(--color-light-gray); + font-size: .9rem; +} + +@media (max-width: 40rem) { + #settings-form, + .table-container { + justify-content: flex-start; + } + + h2 { + text-align: center; + padding-inline: 0; + } + + .settings-table tbody { + display: flex; + flex-direction: column; + } + + .settings-table tr { + display: inline-flex; + flex-direction: column; + } + + .settings-table td { + width: 100%; + } + + .settings-table td:first-child { + text-align: left; + } + + .settings-table td:nth-child(2) { + min-width: 0; + } +} diff --git a/frontend/static/css/general.css b/frontend/static/css/general.css index 5a7d580..a332f21 100644 --- a/frontend/static/css/general.css +++ b/frontend/static/css/general.css @@ -8,6 +8,7 @@ :root { --color-light: #ffffff; + --color-light-gray: #6b6b6b; --color-gray: #3c3c3c; --color-dark: #1b1b1b; diff --git a/frontend/static/js/admin.js b/frontend/static/js/admin.js index bdffc3a..1561ea8 100644 --- a/frontend/static/js/admin.js +++ b/frontend/static/js/admin.js @@ -1,3 +1,7 @@ +const setting_inputs = { + 'allow_new_accounts': document.querySelector('#allow-new-accounts-input') +}; + function checkLogin() { fetch(`${url_prefix}/api/auth/status?api_key=${api_key}`) .then(response => { @@ -16,6 +20,39 @@ function checkLogin() { }); }; +function loadSettings() { + fetch(`${url_prefix}/api/settings`) + .then(response => response.json()) + .then(json => { + setting_inputs.allow_new_accounts.checked = json.result.allow_new_accounts; + }); +}; + +function submitSettings() { + const data = { + 'allow_new_accounts': setting_inputs.allow_new_accounts.checked + }; + console.log(data); + 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) + }) + .catch(json => { + if (['ApiKeyInvalid', 'ApiKeyExpired'].includes(json.error)) + window.location.href = `${url_prefix}/`; + }); +}; + // code run on load checkLogin(); +loadSettings(); + +document.querySelector('#logout-button').onclick = (e) => logout(); +document.querySelector('#settings-form').action = 'javascript:submitSettings();'; diff --git a/frontend/static/js/login.js b/frontend/static/js/login.js index e2d3089..965a158 100644 --- a/frontend/static/js/login.js +++ b/frontend/static/js/login.js @@ -117,6 +117,15 @@ function checkLogin() { }); }; +function checkAllowNewAccounts() { + fetch(`${url_prefix}/api/settings`) + .then(response => response.json()) + .then(json => { + if (!json.result.allow_new_accounts) + document.querySelector('.switch-button').classList.add('hidden'); + }); +}; + // code run on load if (localStorage.getItem('MIND') === null) @@ -125,6 +134,7 @@ if (localStorage.getItem('MIND') === null) const url_prefix = document.getElementById('url_prefix').dataset.value; checkLogin(); +checkAllowNewAccounts(); document.getElementById('login-form').setAttribute('action', 'javascript:login();'); document.getElementById('create-form').setAttribute('action', 'javascript:create();'); diff --git a/frontend/templates/admin.html b/frontend/templates/admin.html index 4b1e67d..4a3dfa1 100644 --- a/frontend/templates/admin.html +++ b/frontend/templates/admin.html @@ -17,12 +17,43 @@