Added DB backup system

This commit is contained in:
CasVT
2025-04-30 13:52:31 +02:00
parent cd400bb559
commit 59ac864c16
18 changed files with 937 additions and 275 deletions

View File

@@ -15,6 +15,7 @@ from backend.base.helpers import check_python_version, get_python_exe
from backend.base.logging import LOGGER, setup_logging
from backend.features.reminder_handler import ReminderHandler
from backend.internals.db import set_db_location, setup_db
from backend.internals.db_backup_import import DatabaseBackupHandler
from backend.internals.server import Server, handle_start_type
from backend.internals.settings import Settings
@@ -92,6 +93,9 @@ def _main(
reminder_handler = ReminderHandler()
reminder_handler.find_next_reminder()
backup_handler = DatabaseBackupHandler()
backup_handler.set_backup_timer()
try:
# =================
SERVER.run(settings.host, settings.port)
@@ -99,6 +103,7 @@ def _main(
finally:
reminder_handler.stop_handling()
backup_handler.stop_backup_timer()
if SERVER.start_type is not None:
LOGGER.info("Restarting MIND")

View File

@@ -39,7 +39,7 @@ class InvalidKeyValue(MindException):
self.value = value
LOGGER.warning(
"This key in the API request has an invalid value: "
"%s = %",
"%s = %s",
key, value
)
@@ -169,6 +169,28 @@ class InvalidDatabaseFile(MindException):
}
class DatabaseFileNotFound(MindException):
"The index of the database backup is invalid"
def __init__(self, backup_index: int) -> None:
self.backup_index = backup_index
LOGGER.warning(
"The given database backup index is invalid: %d",
backup_index
)
return
@property
def api_response(self) -> ApiResponse:
return {
'code': 400,
'error': self.__class__.__name__,
'result': {
'index': self.backup_index
}
}
class LogFileNotFound(MindException):
"The log file was not found"

View File

@@ -7,10 +7,10 @@ Definitions of basic types, abstract classes, enums, etc.
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from dataclasses import dataclass
from enum import Enum
from typing import (TYPE_CHECKING, Any, Dict, List, Literal,
Tuple, Type, TypedDict, TypeVar, Union, cast)
Sequence, Tuple, TypedDict, TypeVar, Union, cast)
if TYPE_CHECKING:
from backend.implementations.users import User
@@ -20,20 +20,17 @@ if TYPE_CHECKING:
T = TypeVar('T')
U = TypeVar('U')
WEEKDAY_NUMBER = Literal[0, 1, 2, 3, 4, 5, 6]
BaseSerialisable = Union[
int, float, bool, str, None
int, float, bool, str, None, TypedDict
]
Serialisable = Union[
List[Union[
BaseSerialisable,
List[BaseSerialisable],
Dict[str, BaseSerialisable]
]],
Dict[str, Union[
BaseSerialisable,
List[BaseSerialisable],
Dict[str, BaseSerialisable]
]],
TypedDict,
Sequence[BaseSerialisable],
Sequence[Dict[str, BaseSerialisable]],
Dict[str, BaseSerialisable],
Dict[str, Sequence[BaseSerialisable]],
Dict[str, Dict[str, BaseSerialisable]]
]
@@ -162,6 +159,13 @@ class ApiResponse(TypedDict):
code: int
class DatabaseBackupEntry(TypedDict):
index: int
creation_date: int
filepath: str
filename: str
# region Abstract Classes
class DBMigrator(ABC):
start_version: int

View File

@@ -8,13 +8,13 @@ from base64 import urlsafe_b64encode
from datetime import datetime
from hashlib import pbkdf2_hmac
from logging import WARNING
from os import makedirs, symlink
from os.path import abspath, dirname, exists, join
from os import makedirs, scandir, symlink
from os.path import abspath, dirname, exists, join, splitext
from secrets import token_bytes
from shutil import copy2, move
from sys import base_exec_prefix, executable, platform, version_info
from typing import (Any, Callable, Generator, Iterable,
List, Sequence, Tuple, Union, cast)
List, Sequence, Set, Tuple, Union, cast)
from apprise import Apprise, LogCapture
from dateutil.relativedelta import relativedelta
@@ -23,6 +23,7 @@ from backend.base.definitions import (WEEKDAY_NUMBER, GeneralReminderData,
RepeatQuantity, SendResult, T, U)
# region Python
def get_python_version() -> str:
"""Get python version as string
@@ -76,6 +77,7 @@ def get_python_exe() -> str:
return executable
# region Generic
def reversed_tuples(
i: Iterable[Tuple[T, U]]
) -> Generator[Tuple[U, T], Any, Any]:
@@ -143,6 +145,7 @@ def search_filter(query: str, result: GeneralReminderData) -> bool:
)
# region Security
def get_hash(salt: bytes, data: str) -> bytes:
"""Hash a string using the supplied salt
@@ -172,6 +175,7 @@ def generate_salt_hash(password: str) -> Tuple[bytes, bytes]:
return salt, hashed_password
# region Apprise
def send_apprise_notification(
urls: List[str],
title: str,
@@ -211,6 +215,7 @@ def send_apprise_notification(
return SendResult.SUCCESS
# region Time
def next_selected_day(
weekdays: List[WEEKDAY_NUMBER],
weekday: WEEKDAY_NUMBER
@@ -318,6 +323,7 @@ def find_next_time(
return result
# region Files
def folder_path(*folders: str) -> str:
"""Turn filepaths relative to the project folder into absolute paths.
@@ -330,6 +336,49 @@ def folder_path(*folders: str) -> str:
)
def list_files(folder: str, ext: Iterable[str] = []) -> List[str]:
"""List all files in a folder recursively with absolute paths. Hidden files
(files starting with `.`) are ignored.
Args:
folder (str): The base folder to search through.
ext (Iterable[str], optional): File extensions to only include.
Dot-prefix not necessary.
Defaults to [].
Returns:
List[str]: The paths of the files in the folder.
"""
files: List[str] = []
def _list_files(folder: str, ext: Set[str] = set()):
"""Internal function to add all files in a folder to the files list.
Args:
folder (str): The base folder to search through.
ext (Set[str], optional): A set of lowercase, dot-prefixed,
extensions to filter for or empty for no filter. Defaults to set().
"""
for f in scandir(folder):
if f.is_dir():
_list_files(f.path, ext)
elif (
f.is_file()
and not f.name.startswith('.')
and (
not ext
or (splitext(f.name)[1].lower() in ext)
)
):
files.append(f.path)
ext = {'.' + e.lower().lstrip('.') for e in ext}
_list_files(folder, ext)
return files
def create_folder(
folder: str
) -> None:
@@ -342,7 +391,23 @@ def create_folder(
return
def __copy2(src, dst, *, follow_symlinks=True):
def copy(
src: str,
dst: str,
*,
follow_symlinks: bool = True
) -> str:
"""Copy a file or folder.
Args:
src (str): The source file or folder.
dst (str): The destination of the copy.
follow_symlinks (bool, optional): Whether to follow symlinks.
Defaults to True.
Returns:
str: The destination.
"""
try:
return copy2(src, dst, follow_symlinks=follow_symlinks)
@@ -378,11 +443,12 @@ def rename_file(
"""
create_folder(dirname(after))
move(before, after, copy_function=__copy2)
move(before, after, copy_function=copy)
return
# region Classes
class Singleton(type):
_instances = {}

View File

@@ -1,8 +1,9 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from datetime import datetime
from threading import Timer
from typing import Union
from typing import TYPE_CHECKING, Union
from backend.base.definitions import Constants, RepeatQuantity, SendResult
from backend.base.helpers import (Singleton, find_next_time,
@@ -12,6 +13,9 @@ from backend.implementations.notification_services import NotificationService
from backend.internals.db_models import UserlessRemindersDB
from backend.internals.server import Server
if TYPE_CHECKING:
from threading import Timer
class ReminderHandler(metaclass=Singleton):
"""
@@ -31,63 +35,63 @@ class ReminderHandler(metaclass=Singleton):
Args:
time (int): The time of the reminders to trigger.
"""
with Server().app.app_context():
for reminder in self.reminder_db.fetch(time):
try:
user_id = self.reminder_db.reminder_id_to_user_id(
reminder.id)
result = send_apprise_notification(
[
NotificationService(user_id, ns).get().url
for ns in reminder.notification_services
],
reminder.title,
reminder.text
for reminder in self.reminder_db.fetch(time):
try:
user_id = self.reminder_db.reminder_id_to_user_id(
reminder.id
)
result = send_apprise_notification(
[
NotificationService(user_id, ns).get().url
for ns in reminder.notification_services
],
reminder.title,
reminder.text
)
self.thread = None
self.time = None
if result == SendResult.CONNECTION_ERROR:
# Retry sending the notification in a few minutes
self.reminder_db.update(
reminder.id,
time + Constants.CONNECTION_ERROR_TIMEOUT
)
self.thread = None
self.time = None
elif (
reminder.repeat_quantity,
reminder.weekdays
) == (None, None):
# Delete the reminder from the database
self.reminder_db.delete(reminder.id)
if result == SendResult.CONNECTION_ERROR:
# Retry sending the notification in a few minutes
self.reminder_db.update(
reminder.id,
time + Constants.CONNECTION_ERROR_TIMEOUT
)
elif (
reminder.repeat_quantity,
else:
# Set next time
new_time = find_next_time(
reminder.original_time or -1,
when_not_none(
reminder.repeat_quantity,
lambda q: RepeatQuantity(q)
),
reminder.repeat_interval,
reminder.weekdays
) == (None, None):
# Delete the reminder from the database
self.reminder_db.delete(reminder.id)
else:
# Set next time
new_time = find_next_time(
reminder.original_time or -1,
when_not_none(
reminder.repeat_quantity,
lambda q: RepeatQuantity(q)
),
reminder.repeat_interval,
reminder.weekdays
)
self.reminder_db.update(reminder.id, new_time)
except Exception:
# If the notification fails, we don't want to crash the whole program
# Just log the error and continue
LOGGER.exception(
"Failed to send notification for reminder %s: ",
reminder.id
)
finally:
self.find_next_reminder()
self.reminder_db.update(reminder.id, new_time)
return
except Exception:
# If the notification fails, we don't want to crash the whole program
# Just log the error and continue
LOGGER.exception(
"Failed to send notification for reminder %s: ",
reminder.id
)
finally:
self.find_next_reminder()
return
def find_next_reminder(self, time: Union[int, None] = None) -> None:
"""Determine when the soonest reminder is and set the timer to that time.
@@ -113,12 +117,12 @@ class ReminderHandler(metaclass=Singleton):
self.thread.cancel()
delta_t = time - datetime.utcnow().timestamp()
self.thread = Timer(
self.thread = Server().get_db_timer_thread(
delta_t,
self.__trigger_reminders,
(time,)
"ReminderHandler",
args=(time,)
)
self.thread.name = "ReminderHandler"
self.thread.start()
self.time = time

View File

@@ -6,22 +6,18 @@ Setting up the database and handling connections
from __future__ import annotations
from os import remove
from os.path import dirname, exists, isdir, isfile, join
from shutil import move
from sqlite3 import (PARSE_DECLTYPES, Connection, Cursor,
OperationalError, ProgrammingError, Row,
register_adapter, register_converter)
from sqlite3 import (PARSE_DECLTYPES, Connection, Cursor, ProgrammingError,
Row, register_adapter, register_converter)
from threading import current_thread
from typing import Any, Dict, Generator, Iterable, List, Type, Union
from flask import g
from backend.base.custom_exceptions import InvalidDatabaseFile
from backend.base.definitions import Constants, ReminderType, StartType, T
from backend.base.definitions import Constants, ReminderType, T
from backend.base.helpers import create_folder, folder_path, rename_file
from backend.base.logging import LOGGER, set_log_level
from backend.internals.db_migration import get_latest_db_version, migrate_db
from backend.internals.db_migration import migrate_db
REMINDER_TO_KEY = {
ReminderType.REMINDER: "reminder_id",
@@ -171,6 +167,8 @@ def set_db_location(
Raises:
ValueError: Value of `db_folder` exists but is not a folder.
"""
from backend.internals.settings import SettingsValues
if db_folder:
if exists(db_folder) and not isdir(db_folder):
raise ValueError('Database location is not a folder')
@@ -191,6 +189,7 @@ def set_db_location(
)
DBConnection.file = db_file_location
SettingsValues.db_backup_folder = dirname(db_file_location)
return
@@ -378,117 +377,3 @@ def setup_db() -> None:
)
return
def revert_db_import(
swap: bool,
imported_db_file: str = ''
) -> None:
"""Revert the database import process. The original_db_file is the file
currently used (`DBConnection.file`).
Args:
swap (bool): Whether or not to keep the imported_db_file or not,
instead of the original_db_file.
imported_db_file (str, optional): The other database file. Keep empty
to use `Constants.DB_ORIGINAL_FILENAME`.
Defaults to ''.
"""
original_db_file = DBConnection.file
if not imported_db_file:
imported_db_file = join(
dirname(DBConnection.file),
Constants.DB_ORIGINAL_NAME
)
if swap:
remove(original_db_file)
move(
imported_db_file,
original_db_file
)
else:
remove(imported_db_file)
return
def import_db(
new_db_file: str,
copy_hosting_settings: bool
) -> None:
"""Replace the current database with a new one.
Args:
new_db_file (str): The path to the new database file.
copy_hosting_settings (bool): Keep the hosting settings from the current
database.
Raises:
InvalidDatabaseFile: The new database file is invalid or unsupported.
"""
LOGGER.info(f'Importing new database; {copy_hosting_settings=}')
cursor = Connection(new_db_file, timeout=20.0).cursor()
try:
database_version = cursor.execute(
"SELECT value FROM config WHERE key = 'database_version' LIMIT 1;"
).fetchone()[0]
if not isinstance(database_version, int):
raise InvalidDatabaseFile(new_db_file)
except (OperationalError, InvalidDatabaseFile):
LOGGER.error('Uploaded database is not a MIND database file')
cursor.connection.close()
revert_db_import(
swap=False,
imported_db_file=new_db_file
)
raise InvalidDatabaseFile(new_db_file)
if database_version > get_latest_db_version():
LOGGER.error(
'Uploaded database is higher version than this MIND installation can support')
revert_db_import(
swap=False,
imported_db_file=new_db_file
)
raise InvalidDatabaseFile(new_db_file)
if copy_hosting_settings:
hosting_settings = get_db().execute("""
SELECT key, value
FROM config
WHERE key = 'host'
OR key = 'port'
OR key = 'url_prefix'
LIMIT 3;
"""
).fetchalldict()
cursor.executemany("""
INSERT INTO config(key, value)
VALUES (:key, :value)
ON CONFLICT(key) DO
UPDATE
SET value = :value;
""",
hosting_settings
)
cursor.connection.commit()
cursor.connection.close()
move(
DBConnection.file,
join(dirname(DBConnection.file), Constants.DB_ORIGINAL_NAME)
)
move(
new_db_file,
DBConnection.file
)
from backend.internals.server import Server
Server().restart(StartType.RESTART_DB_CHANGES)
return

View File

@@ -0,0 +1,298 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from datetime import datetime
from os import remove
from os.path import basename, dirname, join
from re import compile
from shutil import move
from sqlite3 import Connection, OperationalError
from time import time
from typing import TYPE_CHECKING, List, Union
from backend.base.custom_exceptions import (DatabaseFileNotFound,
InvalidDatabaseFile)
from backend.base.definitions import Constants, DatabaseBackupEntry, StartType
from backend.base.helpers import Singleton, copy, folder_path, list_files
from backend.base.logging import LOGGER
from backend.internals.db import DBConnection, get_db
from backend.internals.db_migration import get_latest_db_version
from backend.internals.settings import Settings
if TYPE_CHECKING:
from threading import Timer
# ===================
# region Backup
# ===================
DB_FILE_REGEX = compile(
r'MIND_(?P<year>\d{4})_(?P<month>\d{2})_(?P<day>\d{2})_(?P<hour>\d{2})_(?P<minute>\d{2}).db'
)
def get_backups() -> List[DatabaseBackupEntry]:
"""Get a list of currently existing backups, as found in the backup folder.
Returns:
List[DatabaseBackupEntry]: The backups found.
"""
db_files = list_files(Settings().sv.db_backup_folder, ('.db',))
result: List[DatabaseBackupEntry] = []
for file in db_files:
file_match = DB_FILE_REGEX.match(basename(file))
if file_match is None:
continue
time_els = file_match.groupdict()
timestamp = datetime(
year=int(time_els['year']),
month=int(time_els['month']),
day=int(time_els['day']),
hour=int(time_els['hour']),
minute=int(time_els['minute'])
).timestamp()
result.append({
"index": len(result),
"creation_date": int(timestamp),
"filepath": file,
"filename": basename(file)
})
result.sort(key=lambda f: f["creation_date"], reverse=True)
return result
def get_backup(index: int) -> DatabaseBackupEntry:
"""Get info on a specific database backup.
Args:
index (int): The index (supplied by `get_backups()`) of the backup.
Raises:
DatabaseFileNotFound: No backup entry with the given index.
Returns:
DatabaseBackupEntry: The info on the backup entry.
"""
for b in get_backups():
if b['index'] == index:
return b
raise DatabaseFileNotFound(index)
def create_database_copy(folder: str) -> str:
"""Export the current database into a file.
Args:
folder (str): The folder to put the copy into.
Returns:
str: The complete filepath of the created file.
"""
current_date = datetime.now().strftime(r"%Y_%m_%d_%H_%M")
filename = join(folder, f'MIND_{current_date}.db')
get_db().execute(
"VACUUM INTO ?;",
(filename,)
)
return filename
def backup_database() -> None:
"""
Create a backup of the database, delete old backups that
surpass the backup limit and set timer for next run.
"""
settings = Settings()
sv = settings.get_settings()
current_backups = get_backups()
while len(current_backups) >= sv.db_backup_amount:
removed_backup = current_backups.pop()["filepath"]
remove(removed_backup)
LOGGER.info(f"Removed database backup: {removed_backup}")
filepath = create_database_copy(sv.db_backup_folder)
LOGGER.info(f"Created database backup: {filepath}")
settings.update({"db_backup_last_run": int(time())})
DatabaseBackupHandler().set_backup_timer()
return
class DatabaseBackupHandler(metaclass=Singleton):
backup_timer: Union[Timer, None] = None
def set_backup_timer(self) -> None:
"""Update the timer for the backup process. Start one if it hasn't
already. Replace it if it does already exist, in case the interval
setting has a new value.
"""
sv = Settings().get_settings()
if self.backup_timer is not None:
self.backup_timer.cancel()
from backend.internals.server import Server
self.backup_timer = Server().get_db_timer_thread(
sv.db_backup_last_run + sv.db_backup_interval - time(),
backup_database
)
self.backup_timer.start()
return
def stop_backup_timer(self) -> None:
"If the backup timer is running, stop it"
if self.backup_timer is not None:
self.backup_timer.cancel()
return
# ===================
# region Import
# ===================
def revert_db_import(
swap: bool,
imported_db_file: str = ''
) -> None:
"""Revert the database import process. The original_db_file is the file
currently used (`DBConnection.file`).
Args:
swap (bool): Whether or not to keep the imported_db_file or not,
instead of the original_db_file.
imported_db_file (str, optional): The other database file. Keep empty
to use `Constants.DB_ORIGINAL_FILENAME`.
Defaults to ''.
"""
original_db_file = DBConnection.file
if not imported_db_file:
imported_db_file = join(
dirname(DBConnection.file),
Constants.DB_ORIGINAL_NAME
)
if swap:
remove(original_db_file)
move(
imported_db_file,
original_db_file
)
else:
remove(imported_db_file)
return
def import_db(
new_db_file: str,
copy_hosting_settings: bool
) -> None:
"""Replace the current database with a new one.
Args:
new_db_file (str): The path to the new database file.
copy_hosting_settings (bool): Keep the hosting settings from the current
database.
Raises:
InvalidDatabaseFile: The new database file is invalid or unsupported.
"""
LOGGER.info(f'Importing new database; {copy_hosting_settings=}')
cursor = Connection(new_db_file, timeout=20.0).cursor()
try:
database_version = cursor.execute(
"SELECT value FROM config WHERE key = 'database_version' LIMIT 1;"
).fetchone()[0]
if not isinstance(database_version, int):
raise InvalidDatabaseFile(new_db_file)
except (OperationalError, InvalidDatabaseFile):
LOGGER.error('Uploaded database is not a MIND database file')
cursor.connection.close()
revert_db_import(
swap=False,
imported_db_file=new_db_file
)
raise InvalidDatabaseFile(new_db_file)
if database_version > get_latest_db_version():
LOGGER.error(
'Uploaded database is higher version than this MIND installation can support')
revert_db_import(
swap=False,
imported_db_file=new_db_file
)
raise InvalidDatabaseFile(new_db_file)
if copy_hosting_settings:
hosting_settings = get_db().execute("""
SELECT key, value
FROM config
WHERE key = 'host'
OR key = 'port'
OR key = 'url_prefix'
LIMIT 3;
"""
).fetchalldict()
cursor.executemany("""
INSERT INTO config(key, value)
VALUES (:key, :value)
ON CONFLICT(key) DO
UPDATE
SET value = :value;
""",
hosting_settings
)
cursor.connection.commit()
cursor.connection.close()
move(
DBConnection.file,
join(dirname(DBConnection.file), Constants.DB_ORIGINAL_NAME)
)
move(
new_db_file,
DBConnection.file
)
from backend.internals.server import Server
Server().restart(StartType.RESTART_DB_CHANGES)
return
def import_db_backup(
index: int,
copy_hosting_settings: bool
) -> None:
"""Replace the current database with a backup.
Args:
index (int): The index (supplied by `get_backups()`) of the backup.
copy_hosting_settings (bool): Keep the hosting settings from the current
database.
Raises:
DatabaseFileNotFound: No backup entry with the given index.
InvalidDatabaseFile: The new database file is invalid or unsupported.
"""
LOGGER.info(f"Importing database backup with index {index}")
backup = get_backup(index)
dest = copy(
backup["filepath"],
folder_path("db", "MIND_upload.db")
)
import_db(dest, copy_hosting_settings)
return

View File

@@ -318,3 +318,19 @@ class MigrateAddEnabled(DBMigrator):
ADD enabled BOOL NOT NULL DEFAULT 1;
""")
return
class MigrateSetDBBackupFolder(DBMigrator):
start_version = 11
def run(self) -> None:
# V11 -> V12
from backend.internals.settings import Settings, SettingsValues
s = Settings()
sv = s.get_settings()
if sv.db_backup_folder == '':
s.update({"db_backup_folder": SettingsValues.db_backup_folder})
return

View File

@@ -8,7 +8,7 @@ from __future__ import annotations
from os import urandom
from threading import Timer, current_thread
from typing import TYPE_CHECKING, Union
from typing import TYPE_CHECKING, Any, Callable, Iterable, Mapping, Union
from flask import Flask
from waitress.server import create_server
@@ -18,8 +18,8 @@ from werkzeug.middleware.dispatcher import DispatcherMiddleware
from backend.base.definitions import Constants, StartType
from backend.base.helpers import Singleton, folder_path
from backend.base.logging import LOGGER
from backend.internals.db import (DBConnectionManager,
close_db, revert_db_import)
from backend.internals.db import DBConnectionManager, close_db
from backend.internals.db_backup_import import revert_db_import
from backend.internals.settings import Settings
if TYPE_CHECKING:
@@ -117,18 +117,18 @@ class Server(metaclass=Singleton):
def __init__(self) -> None:
self.start_type = None
self.revert_db_timer = Timer(
self.revert_db_timer = self.get_db_timer_thread(
Constants.DB_REVERT_TIME,
revert_db_import,
"DatabaseImportHandler",
kwargs={"swap": True}
)
self.revert_db_timer.name = "DatabaseImportHandler"
self.revert_hosting_timer = Timer(
self.revert_hosting_timer = self.get_db_timer_thread(
Constants.HOSTING_REVERT_TIME,
self.restore_hosting_settings
self.restore_hosting_settings,
"HostingHandler"
)
self.revert_hosting_timer.name = "HostingHandler"
return
@@ -241,9 +241,11 @@ class Server(metaclass=Singleton):
"""
Stop the waitress server. Starts a thread that shuts down the server.
"""
t = Timer(1.0, self.__shutdown_thread_function)
t.name = "InternalStateHandler"
t.start()
self.get_db_timer_thread(
1.0,
self.__shutdown_thread_function,
"InternalStateHandler"
).start()
return
def restart(
@@ -262,14 +264,66 @@ class Server(metaclass=Singleton):
return
def restore_hosting_settings(self) -> None:
with self.app.app_context():
settings = Settings()
values = settings.get_settings()
main_settings = {
'host': values.backup_host,
'port': values.backup_port,
'url_prefix': values.backup_url_prefix
}
settings.update(main_settings)
self.restart()
"Restore the hosting settings from the backup, and restart."
settings = Settings()
values = settings.get_settings()
main_settings = {
'host': values.backup_host,
'port': values.backup_port,
'url_prefix': values.backup_url_prefix
}
settings.update(main_settings)
self.restart()
return
def get_db_timer_thread(
self,
interval: float,
target: Callable[..., object],
name: Union[str, None] = None,
args: Iterable[Any] = (),
kwargs: Mapping[str, Any] = {}
) -> Timer:
"""Create a timer thread that runs under Flask app context.
Args:
interval (float): The time to wait before running the target.
target (Callable[..., object]): The function to run in the thread.
name (Union[str, None], optional): The name of the thread.
Defaults to None.
args (Iterable[Any], optional): The arguments to pass to the function.
Defaults to ().
kwargs (Mapping[str, Any], optional): The keyword arguments to pass
to the function.
Defaults to {}.
Returns:
Timer: The timer thread instance.
"""
def db_thread(*args, **kwargs) -> None:
with self.app.app_context():
target(*args, **kwargs)
thread_id = current_thread().native_id or -1
if (
thread_id in DBConnectionManager.instances
and
not DBConnectionManager.instances[thread_id].closed
):
DBConnectionManager.instances[thread_id].close()
return
t = Timer(
interval=interval,
function=db_thread,
args=args,
kwargs=kwargs
)
if name:
t.name = name
return t

View File

@@ -2,8 +2,9 @@
from dataclasses import _MISSING_TYPE, asdict, dataclass
from functools import lru_cache
from json import dump, load
from logging import DEBUG, INFO
from os import sep
from os.path import abspath, isdir
from typing import Any, Dict, Mapping
from backend.base.custom_exceptions import InvalidKeyValue, KeyNotFound
@@ -13,7 +14,8 @@ from backend.base.logging import LOGGER, set_log_level
from backend.internals.db import DBConnection, commit, get_db
from backend.internals.db_migration import get_latest_db_version
THIRTY_DAYS = 2592000
ONE_DAY = 86400
THIRTY_DAYS = ONE_DAY * 30
@lru_cache(1)
@@ -59,6 +61,11 @@ class SettingsValues:
login_time: int = 3600
login_time_reset: bool = True
db_backup_interval: int = ONE_DAY
db_backup_amount: int = 3
db_backup_folder: str = ''
db_backup_last_run: int = 0
def todict(self) -> Dict[str, Any]:
return {
k: v
@@ -139,14 +146,22 @@ class Settings(metaclass=Singleton):
reversed_tuples(formatted_data.items())
)
old_settings = self.get_settings()
if (
'log_level' in data
and formatted_data['log_level'] != getattr(
self.get_settings(), 'log_level'
)
and formatted_data['log_level'] != old_settings.log_level
):
set_log_level(formatted_data['log_level'])
if (
'db_backup_interval' in data
and formatted_data['db_backup_interval'] != old_settings.db_backup_interval
):
from backend.internals.db_backup_import import \
DatabaseBackupHandler
DatabaseBackupHandler().set_backup_timer()
self._fetch_settings()
LOGGER.info(f"Settings changed: {formatted_data}")
@@ -229,4 +244,21 @@ class Settings(metaclass=Singleton):
if value not in (INFO, DEBUG):
raise InvalidKeyValue(key, value)
elif key == 'db_backup_interval':
if value < 3600:
raise InvalidKeyValue(key, value)
elif key == 'db_backup_amount':
if value <= 0:
raise InvalidKeyValue(key, value)
elif key == 'db_backup_folder':
converted_value = abspath(value.rstrip(sep))
if not isdir(converted_value):
raise InvalidKeyValue(key, value)
elif key == 'db_backup_last_run':
if value < 0:
raise InvalidKeyValue(key, value)
return converted_value

View File

@@ -11,7 +11,8 @@ from typing import List, Type, Union
path.insert(0, dirname(dirname(__file__)))
import frontend.api
from backend.base.custom_exceptions import (NotificationServiceNotFound,
from backend.base.custom_exceptions import (DatabaseFileNotFound,
NotificationServiceNotFound,
ReminderNotFound, TemplateNotFound)
from backend.base.definitions import MindException, StartType
from backend.base.helpers import folder_path
@@ -28,7 +29,8 @@ url_var_map = {
'int:n_id': NotificationServiceNotFound,
'int:r_id': ReminderNotFound,
'int:t_id': TemplateNotFound,
'int:s_id': ReminderNotFound
'int:s_id': ReminderNotFound,
'int:b_idx': DatabaseFileNotFound
}
result = f"""# API
@@ -249,9 +251,9 @@ if __name__ == '__main__':
with open(API_FILE, 'w+') as f:
f.write(result)
# run(["git", "config", "--global", "user.email", '"casvantijn@gmail.com"'])
# run(["git", "config", "--global", "user.name", '"CasVT"'])
# run(["git", "checkout", "Development"])
# run(["git", "add", API_FILE])
# run(["git", "commit", "-m", "Updated API docs"])
# run(["git", "push"])
run(["git", "config", "--global", "user.email", '"casvantijn@gmail.com"'])
run(["git", "config", "--global", "user.name", '"CasVT"'])
run(["git", "checkout", "Development"])
run(["git", "add", API_FILE])
run(["git", "commit", "-m", "Updated API docs"])
run(["git", "push"])

View File

@@ -137,14 +137,17 @@ The following is automatically generated. Please report any issues on [GitHub](h
| Name | Required | Data type | Description | Allowed values |
| ---- | -------- | --------- | ----------- | -------------- |
| new_password | Yes | string | The new password of the user account | N/A |
| new_username | No | string | The new username of the user account | N/A |
| new_password | No | string | The new password of the user account | N/A |
**Returns**
| Code | Error | Description |
| ---- | ----- | ----------- |
| 200 | N/A | Success |
| 400 | KeyNotFound | A key was not found in the input that is required to be given |
| 400 | InvalidKeyValue | The value of a key is invalid |
| 400 | UsernameInvalid | The username contains invalid characters or is not allowed |
| 400 | UsernameTaken | The username is already taken |
??? DELETE
@@ -322,6 +325,7 @@ Replace `<int:n_id>` with the ID of the entry. For example: `/notificationservic
| repeat_interval | No | number | The number of the interval | N/A |
| weekdays | No | list of numbers | On which days of the week to run the reminder | 0, 1, 2, 3, 4, 5, 6 |
| color | No | string | The hex code of the color of the entry, which is shown in the web-UI | N/A |
| enabled | No | bool | Whether the reminder should be enabled | N/A |
**Returns**
@@ -416,6 +420,7 @@ Replace `<int:r_id>` with the ID of the entry. For example: `/reminders/2`.
| repeat_interval | No | number | The number of the interval | N/A |
| weekdays | No | list of numbers | On which days of the week to run the reminder | 0, 1, 2, 3, 4, 5, 6 |
| color | No | string | The hex code of the color of the entry, which is shown in the web-UI | N/A |
| enabled | No | bool | Whether the reminder should be enabled | N/A |
**Returns**
@@ -626,13 +631,13 @@ Replace `<int:t_id>` with the ID of the entry. For example: `/templates/2`.
| 400 | InvalidKeyValue | The value of a key is invalid |
| 400 | KeyNotFound | A key was not found in the input that is required to be given |
### `staticreminders/<int:s_id>`
### `/staticreminders/<int:s_id>`
| Requires being logged in | Description |
| ------------------------ | ----------- |
| Yes | Manage a specific static reminder |
Replace `<int:s_id>` with the ID of the entry. For example: `staticreminders/2`.
Replace `<int:s_id>` with the ID of the entry. For example: `/staticreminders/2`.
??? GET
@@ -776,6 +781,9 @@ Replace `<int:s_id>` with the ID of the entry. For example: `staticreminders/2`.
| port | No | number | The port to listen on | N/A |
| url_prefix | No | string | The base URL to run on. Useful for reverse proxies. Empty string to disable. | N/A |
| log_level | No | number | The level to log on | 20, 10 |
| db_backup_interval | No | number | How often to make a backup of the database | N/A |
| db_backup_amount | No | number | How many backups to keep. The oldest one will be removed if needed. | N/A |
| db_backup_folder | No | string | The folder to store the backups in | N/A |
**Returns**
@@ -851,14 +859,17 @@ Replace `<int:u_id>` with the ID of the entry. For example: `/admin/users/2`.
| Name | Required | Data type | Description | Allowed values |
| ---- | -------- | --------- | ----------- | -------------- |
| new_password | Yes | string | The new password of the user account | N/A |
| new_username | No | string | The new username of the user account | N/A |
| new_password | No | string | The new password of the user account | N/A |
**Returns**
| Code | Error | Description |
| ---- | ----- | ----------- |
| 200 | N/A | Success |
| 400 | KeyNotFound | A key was not found in the input that is required to be given |
| 400 | InvalidKeyValue | The value of a key is invalid |
| 400 | UsernameInvalid | The username contains invalid characters or is not allowed |
| 400 | UsernameTaken | The username is already taken |
??? DELETE
@@ -909,4 +920,56 @@ Replace `<int:u_id>` with the ID of the entry. For example: `/admin/users/2`.
| 201 | N/A | Success |
| 400 | InvalidDatabaseFile | The uploaded database file is invalid or not supported |
| 400 | InvalidKeyValue | The value of a key is invalid |
| 400 | KeyNotFound | A key was not found in the input that is required to be given |
### `/admin/database/backups`
| Requires being logged in | Description |
| ------------------------ | ----------- |
| Yes | Get a list of the current database backups |
??? GET
**Returns**
| Code | Error | Description |
| ---- | ----- | ----------- |
| 200 | N/A | Success |
### `/admin/database/backups/<int:b_idx>`
| Requires being logged in | Description |
| ------------------------ | ----------- |
| Yes | Manage a specific database backup |
Replace `<int:b_idx>` with the ID of the entry. For example: `/admin/database/backups/2`.
??? GET
Download the backup
**Returns**
| Code | Error | Description |
| ---- | ----- | ----------- |
| 200 | N/A | Success |
| 400 | DatabaseFileNotFound | The index of the database backup is invalid |
??? POST
Import the backup, as if it was uploaded and applied. Will automatically restart MIND.
**Parameters (url)**
| Name | Required | Data type | Description | Allowed values |
| ---- | -------- | --------- | ----------- | -------------- |
| copy_hosting_settings | Yes | bool | Copy the hosting settings from the current database | N/A |
**Returns**
| Code | Error | Description |
| ---- | ----- | ----------- |
| 201 | N/A | Success |
| 400 | DatabaseFileNotFound | The index of the database backup is invalid |
| 400 | InvalidKeyValue | The value of a key is invalid |
| 400 | KeyNotFound | A key was not found in the input that is required to be given |

View File

@@ -3,7 +3,7 @@
from datetime import datetime
from io import BytesIO, StringIO
from os import remove, urandom
from os.path import exists
from os.path import basename, exists
from time import time as epoch_time
from typing import Any, Callable, Dict, Tuple, Union
@@ -21,14 +21,16 @@ from backend.features.templates import Templates
from backend.implementations.apprise_parser import get_apprise_services
from backend.implementations.notification_services import NotificationServices
from backend.implementations.users import Users
from backend.internals.db import get_db, import_db
from backend.internals.db_backup_import import (create_database_copy,
get_backup, get_backups,
import_db, import_db_backup)
from backend.internals.server import Server, diffuse_timers
from backend.internals.settings import Settings, get_about_data
from frontend.input_validation import (AboutData, AuthLoginData,
AuthLogoutData, AuthStatusData,
AvailableNotificationServicesData,
DatabaseData, LogfileData,
NotificationServiceData,
BackupData, BackupsData, DatabaseData,
LogfileData, NotificationServiceData,
NotificationServicesData,
PublicSettingsData, ReminderData,
RemindersData, RestartData,
@@ -600,15 +602,10 @@ def api_admin_user(inputs: Dict[str, Any], u_id: int):
@endpoint_wrapper
def api_admin_database(inputs: Dict[str, Any]):
if request.method == "GET":
current_date = datetime.now().strftime(r"%Y_%m_%d_%H_%M")
filename = folder_path(
'db', f'MIND_{current_date}.db'
)
get_db().execute(
"VACUUM INTO ?;",
(filename,)
)
filename = create_database_copy(folder_path('db'))
# We cannot simply pass the filename, ass we have to
# delete the file, but we cannot do that if we send it.
with open(filename, 'rb') as database_file:
bi = BytesIO(database_file.read())
@@ -616,9 +613,29 @@ def api_admin_database(inputs: Dict[str, Any]):
return send_file(
bi,
mimetype="application/x-sqlite3",
download_name=f'MIND_{current_date}.db'
download_name=basename(filename)
), 200
elif request.method == "POST":
import_db(inputs['file'], inputs['copy_hosting_settings'])
return return_api({})
@admin_api.route('/database/backups', BackupsData)
@endpoint_wrapper
def api_admin_backups(inputs: Dict[str, Any]):
return return_api(get_backups())
@admin_api.route('/database/backups/<int:b_idx>', BackupData)
@endpoint_wrapper
def api_admin_backup(inputs: Dict[str, Any], b_idx: int):
if request.method == "GET":
return send_file(
get_backup(b_idx)['filepath'],
mimetype="application/x-sqlite3"
), 200
elif request.method == "POST":
import_db_backup(b_idx, inputs['copy_hosting_settings'])
return return_api({})

View File

@@ -388,6 +388,39 @@ class LoginTimeResetVariable(NonRequiredInputVariable):
)
class DBBackupInterval(NonRequiredInputVariable):
name = "db_backup_interval"
description = "How often to make a backup of the database"
data_type = [DataType.INT]
def validate(self) -> bool:
return self.value is None or (
isinstance(self.value, int)
)
class DBBackupAmount(NonRequiredInputVariable):
name = "db_backup_amount"
description = "How many backups to keep. The oldest one will be removed if needed."
data_type = [DataType.INT]
def validate(self) -> bool:
return self.value is None or (
isinstance(self.value, int)
)
class DBBackupFolder(NonRequiredInputVariable):
name = "db_backup_folder"
description = "The folder to store the backups in"
data_type = [DataType.STR]
def validate(self) -> bool:
return self.value is None or (
isinstance(self.value, str)
)
class HostVariable(NonRequiredInputVariable):
name = "host"
description = "The IP to bind to. Use 0.0.0.0 to bind to all addresses"
@@ -724,7 +757,10 @@ class SettingsData(EndpointData):
HostVariable,
PortVariable,
UrlPrefixVariable,
LogLevelVariable
LogLevelVariable,
DBBackupInterval,
DBBackupAmount,
DBBackupFolder
]
)
)
@@ -780,6 +816,27 @@ class DatabaseData(EndpointData):
)
class BackupsData(EndpointData):
description = "Get a list of the current database backups"
methods = Methods(
get=("", [])
)
class BackupData(EndpointData):
description = "Manage a specific database backup"
methods = Methods(
get=(
"Download the backup",
[]
),
post=(
"Import the backup, as if it was uploaded and applied. Will automatically restart MIND.",
[CopyHostingSettingsVariable]
)
)
# ===================
# region Integration
# ===================

View File

@@ -165,7 +165,6 @@ h2 {
#download-logs-button,
#save-hosting-button,
#add-user-button,
#download-db-button,
#upload-db-button,
#restart-button,
#shutdown-button {
@@ -196,31 +195,32 @@ h2 {
width: min-content;
}
#user-table {
#user-table,
#backup-table {
min-width: 25rem;
border-spacing: 0px;
}
#user-table :where(th, td) {
:where(#user-table, #backup-table) :where(th, td) {
height: 2.65rem;
padding: .25rem .5rem;
text-align: left;
}
#user-table tr td {
:where(#user-table, #backup-table) tr td {
border-top: 1px solid var(--color-gray);
}
#user-table :where(th, td):first-child {
:where(#user-table, #backup-table) :where(th, td):first-child {
width: 10rem;
padding-left: 2rem;
}
#user-table :where(td, td):nth-child(2) {
:where(#user-table, #backup-table) :where(th, td):nth-child(2) {
width: 15rem;
}
#user-table :where(th, td):last-child {
:where(#user-table, #backup-table) :where(th, td):last-child {
width: 5.75rem;
display: flex;
align-items: center;
@@ -228,11 +228,11 @@ h2 {
padding-right: 2rem;
}
#user-table button {
:where(#user-table, #backup-table) button {
height: 1.25rem;
}
#user-table svg {
:where(#user-table, #backup-table) svg {
aspect-ratio: 1/1;
height: 100%;
width: auto;
@@ -248,6 +248,8 @@ h2 {
flex-direction: column;
align-items: center;
gap: 1rem;
margin-block: 2rem 1rem;
}
#hosting-form > p,
@@ -258,6 +260,10 @@ h2 {
word-wrap: break-word;
}
#backup-table :where(th, td):first-child {
width: 20rem;
}
@media (max-width: 40rem) {
h2 {
text-align: center;

View File

@@ -2,7 +2,10 @@ const setting_inputs = {
allow_new_accounts: document.querySelector('#allow-new-accounts-input'),
login_time: document.querySelector('#login-time-input'),
login_time_reset: document.querySelector('#login-time-reset-input'),
log_level: document.querySelector('#log-level-input')
log_level: document.querySelector('#log-level-input'),
db_backup_interval: document.querySelector('#db-backup-interval-input'),
db_backup_amount: document.querySelector('#db-backup-amount-input'),
db_backup_folder: document.querySelector('#db-backup-folder-input')
};
const hosting_inputs = {
@@ -58,6 +61,9 @@ function loadSettings() {
hosting_inputs.host.value = json.result.host;
hosting_inputs.port.value = json.result.port;
hosting_inputs.url_prefix.value = json.result.url_prefix;
setting_inputs.db_backup_interval.value = json.result.db_backup_interval / 3600;
setting_inputs.db_backup_amount.value = json.result.db_backup_amount;
setting_inputs.db_backup_folder.value = json.result.db_backup_folder;
});
};
@@ -66,7 +72,10 @@ function submitSettings() {
'allow_new_accounts': setting_inputs.allow_new_accounts.checked,
'login_time': setting_inputs.login_time.value * 60,
'login_time_reset': setting_inputs.login_time_reset.value === 'true',
'log_level': parseInt(setting_inputs.log_level.value)
'log_level': parseInt(setting_inputs.log_level.value),
'db_backup_interval': parseInt(setting_inputs.db_backup_interval.value) * 3600,
'db_backup_amount': parseInt(setting_inputs.db_backup_amount.value),
'db_backup_folder': setting_inputs.db_backup_folder.value
};
fetch(`${url_prefix}/api/admin/settings?api_key=${api_key}`, {
'method': 'PUT',
@@ -269,6 +278,61 @@ function upload_database() {
});
};
function loadBackups() {
const table = document.querySelector('#backup-list');
table.innerHTML = '';
fetch(`${url_prefix}/api/admin/database/backups?api_key=${api_key}`)
.then(response => response.json())
.then(json => {
json.result.forEach(backup => {
const entry = document.createElement('tr');
entry.dataset.index = backup.index;
const filename = document.createElement('td');
filename.innerText = backup.filename;
entry.appendChild(filename);
const creation = document.createElement('td');
let formatted_datetime = new
Date(backup.creation_date * 1000)
.toLocaleString(getLocalStorage('locale')['locale']);
creation.innerText = formatted_datetime;
entry.appendChild(creation);
const actions = document.createElement('td');
entry.appendChild(actions);
const download = document.createElement('button');
download.onclick =
e => window.location.href =
`${url_prefix}/api/admin/database/backups/${backup.index}?api_key=${api_key}`;
download.innerHTML = Icons.download;
download.title = "Download database backup"
actions.appendChild(download);
const upload = document.createElement('button');
upload.onclick = e => {
upload.innerText = '...';
const copy_hosting = import_inputs.copy_hosting.checked ? 'true' : 'false';
fetch(`${url_prefix}/api/admin/database/backups/${backup.index}?api_key=${api_key}&copy_hosting_settings=${copy_hosting}`, {
method: 'POST',
})
.then(response =>
setTimeout(
() => window.location.reload(),
1000
)
);
};
upload.innerHTML = Icons.upload;
upload.title = "Import database backup"
actions.appendChild(upload);
table.appendChild(entry);
});
});
}
function restart_app() {
power_buttons.restart.innerText = 'Restarting...';
fetch(`${url_prefix}/api/admin/restart?api_key=${api_key}`, {
@@ -300,15 +364,20 @@ function shutdown_app() {
checkLogin();
loadSettings();
loadUsers();
loadBackups();
document.querySelector('#logout-button').onclick = e => logout();
document.querySelector('#settings-form').action = 'javascript:submitSettings();';
document.querySelector('#download-logs-button').onclick = e => downloadLogFile();
hosting_inputs.form.action = 'javascript:submitHostingSettings();';
document.querySelector('#add-user-button').onclick = e => toggleAddUser();
document.querySelector('#add-user-form').action = 'javascript:addUser()';
document.querySelector('#upload-database-form').action = 'javascript:upload_database();';
document.querySelector('#download-db-button').onclick = e =>
window.location.href = `${url_prefix}/api/admin/database?api_key=${api_key}`;
document.querySelector('#upload-database-form').action = 'javascript:upload_database();';
power_buttons.restart.onclick = e => restart_app();
power_buttons.shutdown.onclick = e => shutdown_app();

View File

@@ -11,7 +11,9 @@ const Icons = {
'save': '<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="256" height="256" x="0" y="0" viewBox="0 0 24 24" style="enable-background:new 0 0 512 512" xml:space="preserve"><g><path d="M12,10a4,4,0,1,0,4,4A4,4,0,0,0,12,10Zm0,6a2,2,0,1,1,2-2A2,2,0,0,1,12,16Z"></path><path d="M22.536,4.122,19.878,1.464A4.966,4.966,0,0,0,16.343,0H5A5.006,5.006,0,0,0,0,5V19a5.006,5.006,0,0,0,5,5H19a5.006,5.006,0,0,0,5-5V7.657A4.966,4.966,0,0,0,22.536,4.122ZM17,2.08V3a3,3,0,0,1-3,3H10A3,3,0,0,1,7,3V2h9.343A2.953,2.953,0,0,1,17,2.08ZM22,19a3,3,0,0,1-3,3H5a3,3,0,0,1-3-3V5A3,3,0,0,1,5,2V3a5.006,5.006,0,0,0,5,5h4a4.991,4.991,0,0,0,4.962-4.624l2.16,2.16A3.02,3.02,0,0,1,22,7.657Z"></path></g></svg>',
'edit': '<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="256" height="256" x="0" y="0" viewBox="0 0 24 24" style="enable-background:new 0 0 512 512" xml:space="preserve"><g><g id="_01_align_center" data-name="01 align center"><path d="M22.94,1.06a3.626,3.626,0,0,0-5.124,0L0,18.876V24H5.124L22.94,6.184A3.627,3.627,0,0,0,22.94,1.06ZM4.3,22H2V19.7L15.31,6.4l2.3,2.3ZM21.526,4.77,19.019,7.277l-2.295-2.3L19.23,2.474a1.624,1.624,0,0,1,2.3,2.3Z"></path></g></g></svg>',
'delete': '<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="256" height="256" x="0" y="0" viewBox="0 0 24 24" style="enable-background:new 0 0 512 512" xml:space="preserve"><g><g id="_01_align_center" data-name="01 align center"><path d="M22,4H17V2a2,2,0,0,0-2-2H9A2,2,0,0,0,7,2V4H2V6H4V21a3,3,0,0,0,3,3H17a3,3,0,0,0,3-3V6h2ZM9,2h6V4H9Zm9,19a1,1,0,0,1-1,1H7a1,1,0,0,1-1-1V6H18Z"></path><rect x="9" y="10" width="2" height="8"></rect><rect x="13" y="10" width="2" height="8"></rect></g></g></svg>',
'add': '<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="256" height="256" x="0" y="0" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve"><g><g><path d="M480,224H288V32c0-17.673-14.327-32-32-32s-32,14.327-32,32v192H32c-17.673,0-32,14.327-32,32s14.327,32,32,32h192v192 c0,17.673,14.327,32,32,32s32-14.327,32-32V288h192c17.673,0,32-14.327,32-32S497.673,224,480,224z"></path></g></g></svg>'
'add': '<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="256" height="256" x="0" y="0" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve"><g><g><path d="M480,224H288V32c0-17.673-14.327-32-32-32s-32,14.327-32,32v192H32c-17.673,0-32,14.327-32,32s14.327,32,32,32h192v192 c0,17.673,14.327,32,32,32s32-14.327,32-32V288h192c17.673,0,32-14.327,32-32S497.673,224,480,224z"></path></g></g></svg>',
'download': '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Capa_1" x="0px" y="0px" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve" width="512" height="512"><g><path d="M210.731,386.603c24.986,25.002,65.508,25.015,90.51,0.029c0.01-0.01,0.019-0.019,0.029-0.029l68.501-68.501 c7.902-8.739,7.223-22.23-1.516-30.132c-8.137-7.357-20.527-7.344-28.649,0.03l-62.421,62.443l0.149-329.109 C277.333,9.551,267.782,0,256,0l0,0c-11.782,0-21.333,9.551-21.333,21.333l-0.192,328.704L172.395,288 c-8.336-8.33-21.846-8.325-30.176,0.011c-8.33,8.336-8.325,21.846,0.011,30.176L210.731,386.603z"/><path d="M490.667,341.333L490.667,341.333c-11.782,0-21.333,9.551-21.333,21.333V448c0,11.782-9.551,21.333-21.333,21.333H64 c-11.782,0-21.333-9.551-21.333-21.333v-85.333c0-11.782-9.551-21.333-21.333-21.333l0,0C9.551,341.333,0,350.885,0,362.667V448 c0,35.346,28.654,64,64,64h384c35.346,0,64-28.654,64-64v-85.333C512,350.885,502.449,341.333,490.667,341.333z"/></g></svg>',
'upload': '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Capa_1" x="0px" y="0px" viewBox="0 0 512.008 512.008" style="enable-background:new 0 0 512.008 512.008;" xml:space="preserve" width="512" height="512"><g><path d="M172.399,117.448l62.421-62.443l-0.149,329.344c0,11.782,9.551,21.333,21.333,21.333l0,0 c11.782,0,21.333-9.551,21.333-21.333l0.149-328.981l62.123,62.144c8.475,8.185,21.98,7.951,30.165-0.524 c7.985-8.267,7.985-21.374,0-29.641L301.273,18.76c-24.986-25.002-65.508-25.015-90.51-0.029c-0.01,0.01-0.019,0.019-0.029,0.029 l-68.501,68.523c-8.185,8.475-7.951,21.98,0.524,30.165C151.024,125.433,164.131,125.433,172.399,117.448z"/><path d="M490.671,341.341L490.671,341.341c-11.782,0-21.333,9.551-21.333,21.333v85.333c0,11.782-9.551,21.333-21.333,21.333h-384 c-11.782,0-21.333-9.551-21.333-21.333v-85.333c0-11.782-9.551-21.333-21.333-21.333l0,0c-11.782,0-21.333,9.551-21.333,21.333 v85.333c0,35.346,28.654,64,64,64h384c35.346,0,64-28.654,64-64v-85.333C512.004,350.892,502.453,341.341,490.671,341.341z"/></g></svg>'
};
const InfoClasses = [

View File

@@ -97,6 +97,40 @@
<div class="database-container">
<button id="download-logs-button" type="button">Download Debug Logs</button>
</div>
<h2>Database Backups</h2>
<div class="settings-table-container">
<table class="settings-table">
<tbody>
<tr>
<td><label for="db-backup-interval-input">Database Backup Interval</label></td>
<td>
<div class="number-input">
<input type="number" id="db-backup-interval-input" min="1" required>
<p>Hours</p>
</div>
<p>How often to make a backup of the database.</p>
</td>
</tr>
<tr>
<td><label for="db-backup-amount-input">Database Backup Retention</label></td>
<td>
<div class="number-input">
<input type="number" id="db-backup-amount-input" min="1" required>
<p>Backups</p>
</div>
<p>How many backups to keep. The oldest one will be removed if needed.</p>
</td>
</tr>
<tr>
<td><label for="db-backup-folder-input">Database Backup Folder</label></td>
<td>
<input type="text" id="db-backup-folder-input" required>
<p>The folder to store the backups in.</p>
</td>
</tr>
</tbody>
</table>
</div>
</form>
<form id="hosting-form">
<h2>Hosting</h2>
@@ -128,8 +162,8 @@
</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>
<p>See <a href="https://casvt.github.io/MIND/settings/admin_settings#hosting">the documentation</a> for more information.</p>
<p>IMPORTANT: Login into MIND within one minute to keep the new database, or the import will automatically be reverted.
See <a href="https://casvt.github.io/MIND/general_info/admin_panel#database">the documentation</a> for more information.</p>
</form>
<h2>User Management</h2>
<div class="add-user-container">
@@ -177,31 +211,57 @@
</table>
</div>
<h2>Database</h2>
<div class="database-container">
<button id="download-db-button">Download Database</button>
</div>
<form id="upload-database-form">
<div class="settings-table-container">
<table class="settings-table">
<tbody>
<tr>
<td><label for="database-file-input">Database File</label></td>
<td><input type="file" id="database-file-input" required></td>
</tr>
<tr>
<td><label for="copy-hosting-input">Keep Hosting Settings</label></td>
<td>
<input type="checkbox" id="copy-hosting-input">
<p>Keep the current hosting settings instead of using the settings in the uploaded database.</p>
<p>Keep the current hosting settings instead of using the settings in the uploaded database when importing.</p>
</td>
</tr>
<tr>
<td><label for="database-file-input">Database File</label></td>
<td>
<input type="file" id="database-file-input" required>
<p>Instead of importing a backup, import a database by uploading the file.</p>
</td>
</tr>
</tbody>
</table>
</div>
<button type="submit" id="upload-db-button">Import Database</button>
<p>IMPORTANT: After uploading the backup, it is required to log into the admin panel within 1 minute (60 seconds) in order to keep the new database file. Otherwise, MIND will revert the upload and go back to the old database.</p>
<p>See <a href="https://casvt.github.io/MIND/general_info/admin_panel#database">the documentation</a> for more information.</p>
<p>IMPORTANT: Login into MIND within one minute to keep the new database, or the import will automatically be reverted.
See <a href="https://casvt.github.io/MIND/general_info/admin_panel#database">the documentation</a> for more information.</p>
</form>
<div class="settings-table-container">
<table id="backup-table">
<thead>
<th>File</th>
<th>Creation</th>
<th>Actions</th>
</thead>
<tbody>
<tr>
<td>Current Database</td>
<td></td>
<td>
<button id="download-db-button">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Capa_1" x="0px" y="0px" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve" width="512" height="512">
<g>
<path d="M210.731,386.603c24.986,25.002,65.508,25.015,90.51,0.029c0.01-0.01,0.019-0.019,0.029-0.029l68.501-68.501 c7.902-8.739,7.223-22.23-1.516-30.132c-8.137-7.357-20.527-7.344-28.649,0.03l-62.421,62.443l0.149-329.109 C277.333,9.551,267.782,0,256,0l0,0c-11.782,0-21.333,9.551-21.333,21.333l-0.192,328.704L172.395,288 c-8.336-8.33-21.846-8.325-30.176,0.011c-8.33,8.336-8.325,21.846,0.011,30.176L210.731,386.603z" />
<path d="M490.667,341.333L490.667,341.333c-11.782,0-21.333,9.551-21.333,21.333V448c0,11.782-9.551,21.333-21.333,21.333H64 c-11.782,0-21.333-9.551-21.333-21.333v-85.333c0-11.782-9.551-21.333-21.333-21.333l0,0C9.551,341.333,0,350.885,0,362.667V448 c0,35.346,28.654,64,64,64h384c35.346,0,64-28.654,64-64v-85.333C512,350.885,502.449,341.333,490.667,341.333z" />
</g>
</svg>
</button>
</td>
</tr>
</tbody>
<tbody id="backup-list"></tbody>
</table>
</div>
<h2>Power</h2>
<div class="database-container">
<button id="restart-button">Restart</button>