mirror of
https://github.com/Casvt/MIND.git
synced 2026-04-03 03:00:22 -04:00
Added DB backup system
This commit is contained in:
5
MIND.py
5
MIND.py
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
298
backend/internals/db_backup_import.py
Normal file
298
backend/internals/db_backup_import.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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 |
|
||||
@@ -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({})
|
||||
|
||||
@@ -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
|
||||
# ===================
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}©_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();
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user