diff --git a/install/froxlor.sql.php b/install/froxlor.sql.php index 61b7a58f..4453aea7 100644 --- a/install/froxlor.sql.php +++ b/install/froxlor.sql.php @@ -918,7 +918,7 @@ INSERT INTO `cronjobs_run` (`id`, `module`, `cronfile`, `cronclass`, `interval`, (3, 'froxlor/reports', 'usage_report', '\\Froxlor\\Cron\\Traffic\\ReportsCron', '1 DAY', '1', 'cron_usage_report'), (4, 'froxlor/core', 'mailboxsize', '\\Froxlor\\Cron\\System\\MailboxsizeCron', '6 HOUR', '1', 'cron_mailboxsize'), (5, 'froxlor/letsencrypt', 'letsencrypt', '\\Froxlor\\Cron\\Http\\LetsEncrypt\\AcmeSh', '5 MINUTE', '0', 'cron_letsencrypt'), - (6, 'froxlor/export', 'export', '\\Froxlor\\Cron\\System\\ExportCron', '1 DAY', '0', 'cron_export'), + (6, 'froxlor/export', 'export', '\\Froxlor\\Cron\\System\\ExportCron', '1 HOUR', '0', 'cron_export'), (7, 'froxlor/backup', 'backup', '\\Froxlor\\Cron\\Backup\\BackupCron', '1 DAY', '0', 'cron_backup'); diff --git a/install/updates/froxlor/update_0.10.inc.php b/install/updates/froxlor/update_0.10.inc.php index f1082917..d9c79e1f 100644 --- a/install/updates/froxlor/update_0.10.inc.php +++ b/install/updates/froxlor/update_0.10.inc.php @@ -23,11 +23,11 @@ * @license https://files.froxlor.org/misc/COPYING.txt GPLv2 */ -use Froxlor\Froxlor; -use Froxlor\FileDir; use Froxlor\Database\Database; -use Froxlor\Settings; +use Froxlor\FileDir; +use Froxlor\Froxlor; use Froxlor\Install\Update; +use Froxlor\Settings; use Froxlor\System\Cronjob; use Froxlor\System\IPTools; diff --git a/install/updates/froxlor/update_2.1.inc.php b/install/updates/froxlor/update_2.1.inc.php index f19b2d7f..7d412fdb 100644 --- a/install/updates/froxlor/update_2.1.inc.php +++ b/install/updates/froxlor/update_2.1.inc.php @@ -113,6 +113,7 @@ if (Froxlor::isDatabaseVersion('202304260')) { `module`= 'froxlor/export', `cronfile` = 'export', `cronclass` = '\\Froxlor\\Cron\\System\\ExportCron', + `interval` = '1 HOUR', `desc_lng_key` = 'cron_export' WHERE `module` = 'froxlor/backup' "); diff --git a/install/updates/preconfig/preconfig_2.1.inc.php b/install/updates/preconfig/preconfig_2.1.inc.php index 88529296..95401f62 100644 --- a/install/updates/preconfig/preconfig_2.1.inc.php +++ b/install/updates/preconfig/preconfig_2.1.inc.php @@ -36,7 +36,19 @@ $preconfig = [ $return = []; if (Update::versionInUpdate($current_version, '2.1.0-dev1')) { - + // Backup + $description = 'Froxlor now comes with a backup capability (More info see [DOCS LINK].'; + $question = 'Would you like to enable the backup-feature (default: yes)'; + $return['panel_settings_mode'] = [ + 'type' => 'select', + 'select_var' => [ + 0 => 'No', + 1 => 'Yes' + ], + 'selected' => 1, + 'label' => $question, + 'prior_infotext' => $description + ]; } $preconfig['fields'] = $return; diff --git a/lib/Froxlor/Api/Commands/BackupStorages.php b/lib/Froxlor/Api/Commands/BackupStorages.php index 0ba24fe3..47aa4098 100644 --- a/lib/Froxlor/Api/Commands/BackupStorages.php +++ b/lib/Froxlor/Api/Commands/BackupStorages.php @@ -31,6 +31,7 @@ use Froxlor\Api\ResourceEntity; use Froxlor\Database\Database; use Froxlor\FileDir; use Froxlor\FroxlorLogger; +use Froxlor\Settings; use Froxlor\UI\Response; use Froxlor\Validate\Validate; use PDO; @@ -40,6 +41,14 @@ use PDO; */ class BackupStorages extends ApiCommand implements ResourceEntity { + const SUPPORTED_TYPES = [ + 'local', + 'ftp', + 'sftp', + 'rsync', + 's3', + ]; + /** * lists all backup storages entries * @@ -61,7 +70,7 @@ class BackupStorages extends ApiCommand implements ResourceEntity public function listing() { if ($this->isAdmin() && $this->getUserDetail('change_serversettings') == 1) { - $this->logger()->logAction(FroxlorLogger::ADM_ACTION, LOG_NOTICE, "[API] list backups"); + $this->logger()->logAction(FroxlorLogger::ADM_ACTION, LOG_INFO, "[API] list backup storages"); $query_fields = []; $result_stmt = Database::prepare(" SELECT * FROM `" . TABLE_PANEL_BACKUP_STORAGES . "` @@ -103,28 +112,28 @@ class BackupStorages extends ApiCommand implements ResourceEntity } /** - * create a backup storage by given id + * create a backup storage * * @param string $type * required, backup storage type * @param string $destination_path * required, destination path for backup storage * @param string $description - * description for backup storage + * required, description for backup storage * @param string $region - * region for backup storage (used for S3) + * optional, required if type=s3. Region for backup storage (used for S3) * @param string $bucket - * bucket for backup storage (used for S3) + * optional, required if type=s3. Bucket for backup storage (used for S3) * @param string $hostname - * hostname for backup storage + * optional, required if type != local. Hostname for backup storage * @param string $username - * username for backup storage (also used as access key for S3) + * optional, required if type != local. Username for backup storage (also used as access key for S3) * @param string $password - * password for backup storage (also used as secret key for S3) + * optional, required if type != local. Password for backup storage (also used as secret key for S3) * @param string $pgp_public_key - * pgp public key for backup storage + * optional, pgp public key for backup storage * @param string $retention - * retention for backup storage + * optional, retention for backup storage (default 3) * * @access admin * @return string json-encoded array @@ -136,16 +145,39 @@ class BackupStorages extends ApiCommand implements ResourceEntity // required parameters $type = $this->getParam('type'); $destination_path = $this->getParam('destination_path'); + $description = $this->getParam('description'); + + // type related requirements + $optional_flags = [ + 'region' => true, + 'bucket' => true, + 'hostname' => true, + 'username' => true, + 'password' => true, + ]; + + if (!in_array($type, self::SUPPORTED_TYPES)) { + throw new Exception("Unsupported storage type: '" . $type . "'", 406); + } + + if ($type != 'local') { + $optional_flags['hostname'] = false; + $optional_flags['username'] = false; + $optional_flags['password'] = false; + } + if ($type == 's3') { + $optional_flags['region'] = false; + $optional_flags['bucket'] = false; + } // parameters - $description = $this->getParam('description', true, null); - $region = $this->getParam('region', true, null); - $bucket = $this->getParam('bucket', true, null); - $hostname = $this->getParam('hostname', true, null); - $username = $this->getParam('username', true, null); - $password = $this->getParam('password', true, null); + $region = $this->getParam('region', $optional_flags['region']); + $bucket = $this->getParam('bucket', $optional_flags['bucket']); + $hostname = $this->getParam('hostname', $optional_flags['hostname']); + $username = $this->getParam('username', $optional_flags['username']); + $password = $this->getParam('password', $optional_flags['password']); $pgp_public_key = $this->getParam('pgp_public_key', true, null); - $retention = $this->getParam('retention', true, null); + $retention = $this->getParam('retention', true, 3); // validation $destination_path = FileDir::makeCorrectDir(Validate::validate($destination_path, 'destination_path', Validate::REGEX_DIR, '', [], true)); @@ -158,7 +190,7 @@ class BackupStorages extends ApiCommand implements ResourceEntity Response::standardError('gnupgextensionnotavailable', '', true); } // check if the pgp public key is a valid key - putenv('GNUPGHOME='.sys_get_temp_dir()); + putenv('GNUPGHOME=' . sys_get_temp_dir()); if (gnupg_import(gnupg_init(), $pgp_public_key) === false) { Response::standardError('invalidpgppublickey', '', true); } @@ -204,22 +236,22 @@ class BackupStorages extends ApiCommand implements ResourceEntity ]; Database::pexecute($stmt, $params, true, true); $id = Database::lastInsertId(); - $this->logger()->logAction($this->isAdmin() ? FroxlorLogger::ADM_ACTION : FroxlorLogger::USR_ACTION, LOG_NOTICE, "[API] edited backup storage for '" . $result['id'] . "'"); // return $result = $this->apiCall('BackupStorages.get', [ 'id' => $id ]); + $this->logger()->logAction(FroxlorLogger::ADM_ACTION, LOG_NOTICE, "[API] added backup storage '" . $result['description'] . "' (" . $result['type'] . ")"); return $this->response($result); } throw new Exception("Not allowed to execute given command.", 403); } /** - * return an admin entry by id + * return a backup storage entry by id * * @param int $id - * optional, the backup-storage-id + * the backup-storage-id * * @access admin * @return string json-encoded array @@ -239,7 +271,7 @@ class BackupStorages extends ApiCommand implements ResourceEntity ]; $result = Database::pexecute_first($result_stmt, $params, true, true); if ($result) { - $this->logger()->logAction(FroxlorLogger::ADM_ACTION, LOG_INFO, "[API] get backup storage for '" . $result['id'] . "'"); + $this->logger()->logAction(FroxlorLogger::ADM_ACTION, LOG_INFO, "[API] get backup storage '" . $result['description'] . "'"); return $this->response($result); } throw new Exception("Backup storage with " . $id . " could not be found", 404); @@ -251,27 +283,27 @@ class BackupStorages extends ApiCommand implements ResourceEntity * update a backup storage by given id * * @param int $id - * required, the backup-storage-id + * required, the backup-storage-id * @param string $type - * backup storage type + * optional, backup storage type * @param string $destination_path - * destination path for backup storage + * optional, destination path for backup storage * @param string $description - * description for backup storage + * required, description for backup storage * @param string $region - * region for backup storage (used for S3) + * optional, region for backup storage (used for S3) * @param string $bucket - * bucket for backup storage (used for S3) + * optional, bucket for backup storage (used for S3) * @param string $hostname - * hostname for backup storage + * optional, hostname for backup storage * @param string $username - * username for backup storage (also used as access key for S3) + * optional, username for backup storage (also used as access key for S3) * @param string $password - * password for backup storage (also used as secret key for S3) + * optional, password for backup storage (also used as secret key for S3) * @param string $pgp_public_key - * pgp public key for backup storage + * optional, pgp public key for backup storage * @param string $retention - * retention for backup storage + * optional, retention for backup storage (default 3) * * @access admin * @return string json-encoded array @@ -295,27 +327,61 @@ class BackupStorages extends ApiCommand implements ResourceEntity $destination_path = $this->getParam('destination_path', true, $result['destination_path']); $hostname = $this->getParam('hostname', true, $result['hostname']); $username = $this->getParam('username', true, $result['username']); - $password = $this->getParam('password', true, $result['password']); + $password = $this->getParam('password', true, ''); $pgp_public_key = $this->getParam('pgp_public_key', true, $result['pgp_public_key']); $retention = $this->getParam('retention', true, $result['retention']); + if (!in_array($type, self::SUPPORTED_TYPES)) { + throw new Exception("Unsupported storage type: '" . $type . "'", 406); + } + + if ($type != 'local') { + if (empty($hostname)) { + throw new Exception("Field 'hostname' cannot be empty", 406); + } + if (empty($username)) { + throw new Exception("Field 'username' cannot be empty", 406); + } + $password = Validate::validate($password, 'password', '', '', [], true); + } + if ($type == 's3') { + if (empty($region)) { + throw new Exception("Field 'region' cannot be empty", 406); + } + if (empty($bucket)) { + throw new Exception("Field 'bucket' cannot be empty", 406); + } + } + // validation $destination_path = FileDir::makeCorrectDir(Validate::validate($destination_path, 'destination_path', Validate::REGEX_DIR, '', [], true)); // TODO: add more validation // pgp public key validation - if (!empty($pgp_public_key)) { + if (!empty($pgp_public_key) && $pgp_public_key != $result['pgp_public_key']) { // check if gnupg extension is loaded if (!extension_loaded('gnupg')) { Response::standardError('gnupgextensionnotavailable', '', true); } // check if the pgp public key is a valid key - putenv('GNUPGHOME='.sys_get_temp_dir()); + putenv('GNUPGHOME=' . sys_get_temp_dir()); if (gnupg_import(gnupg_init(), $pgp_public_key) === false) { Response::standardError('invalidpgppublickey', '', true); } } + if (!empty($password)) { + $stmt = Database::prepare("UPDATE `" . TABLE_PANEL_BACKUP_STORAGES . "` + SET `password` = :password + WHERE `id` = :id + "); + Database::pexecute($stmt, [ + "id" => $id, + "password" => $password + ], true, true); + $this->logger()->logAction(FroxlorLogger::ADM_ACTION, LOG_NOTICE, "[API] updated password for backup-storage '" . $result['description'] . "'"); + } + // update $stmt = Database::prepare(" UPDATE `" . TABLE_PANEL_BACKUP_STORAGES . "` @@ -326,7 +392,6 @@ class BackupStorages extends ApiCommand implements ResourceEntity `destination_path` = :destination_path, `hostname` = :hostname, `username` = :username, - `password` = :password, `pgp_public_key` = :pgp_public_key, `retention` = :retention WHERE `id` = :id @@ -340,12 +405,11 @@ class BackupStorages extends ApiCommand implements ResourceEntity "destination_path" => $destination_path, "hostname" => $hostname, "username" => $username, - "password" => $password, "pgp_public_key" => $pgp_public_key, "retention" => $retention, ]; Database::pexecute($stmt, $params, true, true); - $this->logger()->logAction($this->isAdmin() ? FroxlorLogger::ADM_ACTION : FroxlorLogger::USR_ACTION, LOG_NOTICE, "[API] edited backup storage for '" . $result['id'] . "'"); + $this->logger()->logAction(FroxlorLogger::ADM_ACTION, LOG_NOTICE, "[API] edited backup storage '" . $result['description'] . "'"); // return $result = $this->apiCall('BackupStorages.get', [ @@ -376,6 +440,33 @@ class BackupStorages extends ApiCommand implements ResourceEntity 'id' => $id ]); + // validate no-one's using it + + // settings + if ($id == Settings::Get('backup.default_storage')) { + throw new Exception("Given backup storage is currently set as default storage and cannot be deleted.", 406); + } + // customers + $sel_stmt = Database::prepare(" + SELECT COUNT(*) as num_storage_users + FROM `" . TABLE_PANEL_CUSTOMERS . "` + WHERE `backup` = :id + "); + $storage_users_result = Database::pexecute_first($sel_stmt, ['id' => $id]); + if ($storage_users_result && $storage_users_result['num_storage_users'] > 0) { + throw new Exception("Given backup storage is currently assigned to " . $storage_users_result['num_storage_users'] . " customers and cannot be deleted.", 406); + } + // existing backups + $sel_stmt = Database::prepare(" + SELECT COUNT(*) as num_storage_backups + FROM `" . TABLE_PANEL_BACKUPS . "` + WHERE `storage_id` = :id + "); + $storage_backups_result = Database::pexecute_first($sel_stmt, ['id' => $id]); + if ($storage_backups_result && $storage_backups_result['num_storage_backups'] > 0) { + throw new Exception("Given backup storage has still " . $storage_backups_result['num_storage_backups'] . " backups on it and cannot be deleted.", 406); + } + // delete $stmt = Database::prepare(" DELETE FROM `" . TABLE_PANEL_BACKUP_STORAGES . "` @@ -385,7 +476,7 @@ class BackupStorages extends ApiCommand implements ResourceEntity "id" => $id ]; Database::pexecute($stmt, $params, true, true); - $this->logger()->logAction($this->isAdmin() ? FroxlorLogger::ADM_ACTION : FroxlorLogger::USR_ACTION, LOG_NOTICE, "[API] deleted backup storage for '" . $result['id'] . "'"); + $this->logger()->logAction(FroxlorLogger::ADM_ACTION, LOG_NOTICE, "[API] deleted backup storage '" . $result['description'] . "'"); // return return $this->response(true); diff --git a/lib/Froxlor/Api/Commands/Backups.php b/lib/Froxlor/Api/Commands/Backups.php index 638b4912..72c13f18 100644 --- a/lib/Froxlor/Api/Commands/Backups.php +++ b/lib/Froxlor/Api/Commands/Backups.php @@ -43,37 +43,6 @@ use PDO; */ class Backups extends ApiCommand implements ResourceEntity { - - /** - * increase resource-usage - * - * @param int $adminid - * @param string $resource - * @param string $extra - * optional, default empty - * @param int $increase_by - * optional, default 1 - */ - public static function increaseUsage($adminid = 0, $resource = null, $extra = '', $increase_by = 1) - { - self::updateResourceUsage(TABLE_PANEL_BACKUPS, 'adminid', $adminid, '+', $resource, $extra, $increase_by); - } - - /** - * decrease resource-usage - * - * @param int $adminid - * @param string $resource - * @param string $extra - * optional, default empty - * @param int $decrease_by - * optional, default 1 - */ - public static function decreaseUsage($adminid = 0, $resource = null, $extra = '', $decrease_by = 1) - { - self::updateResourceUsage(TABLE_PANEL_BACKUPS, 'adminid', $adminid, '-', $resource, $extra, $decrease_by); - } - /** * lists all admin entries * @@ -95,7 +64,7 @@ class Backups extends ApiCommand implements ResourceEntity public function listing() { if ($this->isAdmin() && $this->getUserDetail('change_serversettings') == 1) { - $this->logger()->logAction(FroxlorLogger::ADM_ACTION, LOG_NOTICE, "[API] list backups"); + $this->logger()->logAction(FroxlorLogger::ADM_ACTION, LOG_INFO, "[API] list backups"); $query_fields = []; $result_stmt = Database::prepare(" SELECT `b`.*, `a`.`loginname` as `adminname` diff --git a/lib/Froxlor/Cron/System/ExportCron.php b/lib/Froxlor/Cron/System/ExportCron.php index 8575013a..8def5b7d 100644 --- a/lib/Froxlor/Cron/System/ExportCron.php +++ b/lib/Froxlor/Cron/System/ExportCron.php @@ -46,7 +46,9 @@ class ExportCron extends FroxlorCron "); $all_jobs = $result_tasks_stmt->fetchAll(); - self::runFork([self::class, 'handle'], $all_jobs); + if (!empty($all_jobs)) { + self::runFork([self::class, 'handle'], $all_jobs); + } } public static function handle(array $row) diff --git a/lib/formfields/admin/backup_storages/formfield.backup_storage_add.php b/lib/formfields/admin/backup_storages/formfield.backup_storage_add.php index a9a51647..28856194 100644 --- a/lib/formfields/admin/backup_storages/formfield.backup_storage_add.php +++ b/lib/formfields/admin/backup_storages/formfield.backup_storage_add.php @@ -36,7 +36,9 @@ return [ 'fields' => [ 'description' => [ 'label' => lng('backup.backup_storage.description'), - 'type' => 'text' + 'type' => 'text', + 'maxlength' => 200, + 'mandatory' => true, ], 'type' => [ 'label' => lng('backup.backup_storage.type'), @@ -48,7 +50,8 @@ return [ 'sftp' => lng('backup.backup_storage.type_sftp'), 'rsync' => lng('backup.backup_storage.type_rsync'), 's3' => lng('backup.backup_storage.type_s3'), - ] + ], + 'mandatory' => true, ], 'region' => [ 'label' => lng('backup.backup_storage.region'), @@ -72,7 +75,8 @@ return [ ], 'password' => [ 'label' => lng('backup.backup_storage.password'), - 'type' => 'text' + 'type' => 'password', + 'autocomplete' => 'off', ], 'pgp_public_key' => [ 'label' => lng('backup.backup_storage.pgp_public_key'), diff --git a/lib/formfields/admin/backup_storages/formfield.backup_storage_edit.php b/lib/formfields/admin/backup_storages/formfield.backup_storage_edit.php index ac283615..0ed8b6fa 100644 --- a/lib/formfields/admin/backup_storages/formfield.backup_storage_edit.php +++ b/lib/formfields/admin/backup_storages/formfield.backup_storage_edit.php @@ -35,7 +35,8 @@ return [ 'description' => [ 'label' => lng('backup.backup_storage.description'), 'type' => 'text', - 'value' => $result['description'] + 'value' => $result['description'], + 'mandatory' => true, ], 'type' => [ 'label' => lng('backup.backup_storage.type'), @@ -47,7 +48,8 @@ return [ 'sftp' => lng('backup.backup_storage.type_sftp'), 'rsync' => lng('backup.backup_storage.type_rsync'), 's3' => lng('backup.backup_storage.type_s3'), - ] + ], + 'mandatory' => true, ], 'region' => [ 'label' => lng('backup.backup_storage.region'), @@ -75,7 +77,7 @@ return [ 'value' => $result['username'] ], 'password' => [ - 'label' => lng('backup.backup_storage.password'), + 'label' => lng('backup.backup_storage.password') . ' (' . lng('panel.emptyfornochanges') . ')', 'type' => 'password', 'autocomplete' => 'off' ],