Merge pull request 'feat: SFTP remote storage with key file auth + CLI restore options' (#94) from feat/sftp-keyfile into main
This commit was merged in pull request #94.
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
# Changelog
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- SFTP remote storage with SSH key file authentication — key stored securely in database
|
||||
- CLI restore options: --files-only, --db-only, --no-preserve-config, --password
|
||||
|
||||
## [01.34.00] --- 2026-06-23
|
||||
|
||||
## [01.34.00] --- 2026-06-23
|
||||
|
||||
@@ -124,6 +124,7 @@ class BackupsController extends ApiController
|
||||
// Strip sensitive credentials before serialization
|
||||
$sensitiveFields = [
|
||||
'ftp_password', 'ftp_username',
|
||||
'sftp_password', 'sftp_key_data', 'sftp_passphrase',
|
||||
's3_access_key', 's3_secret_key',
|
||||
'gdrive_client_secret', 'gdrive_refresh_token',
|
||||
'encryption_password', 'ntfy_token',
|
||||
|
||||
@@ -160,6 +160,7 @@
|
||||
>
|
||||
<option value="none">COM_MOKOJOOMBACKUP_REMOTE_NONE</option>
|
||||
<option value="ftp">COM_MOKOJOOMBACKUP_REMOTE_FTP</option>
|
||||
<option value="sftp">COM_MOKOJOOMBACKUP_REMOTE_SFTP</option>
|
||||
<option value="google_drive">COM_MOKOJOOMBACKUP_REMOTE_GDRIVE</option>
|
||||
<option value="s3">COM_MOKOJOOMBACKUP_REMOTE_S3</option>
|
||||
</field>
|
||||
@@ -339,6 +340,70 @@
|
||||
</field>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="sftp" label="COM_MOKOJOOMBACKUP_FIELDSET_SFTP">
|
||||
<field
|
||||
name="sftp_host"
|
||||
type="text"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_HOST"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_HOST_DESC"
|
||||
maxlength="255"
|
||||
showon="remote_storage:sftp"
|
||||
/>
|
||||
<field
|
||||
name="sftp_port"
|
||||
type="number"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PORT"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PORT_DESC"
|
||||
default="22"
|
||||
min="1"
|
||||
max="65535"
|
||||
showon="remote_storage:sftp"
|
||||
/>
|
||||
<field
|
||||
name="sftp_username"
|
||||
type="text"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_USERNAME"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_USERNAME_DESC"
|
||||
maxlength="255"
|
||||
showon="remote_storage:sftp"
|
||||
/>
|
||||
<field
|
||||
name="sftp_password"
|
||||
type="password"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD_DESC"
|
||||
maxlength="255"
|
||||
showon="remote_storage:sftp"
|
||||
/>
|
||||
<field
|
||||
name="sftp_key_data"
|
||||
type="textarea"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_DESC"
|
||||
rows="6"
|
||||
cols="60"
|
||||
filter="raw"
|
||||
showon="remote_storage:sftp"
|
||||
/>
|
||||
<field
|
||||
name="sftp_passphrase"
|
||||
type="password"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE_DESC"
|
||||
maxlength="255"
|
||||
showon="remote_storage:sftp"
|
||||
/>
|
||||
<field
|
||||
name="sftp_path"
|
||||
type="text"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH_DESC"
|
||||
default="/backups"
|
||||
maxlength="512"
|
||||
showon="remote_storage:sftp"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="google_drive" label="COM_MOKOJOOMBACKUP_FIELDSET_GDRIVE">
|
||||
<field
|
||||
name="gdrive_client_id"
|
||||
|
||||
@@ -242,7 +242,25 @@ COM_MOKOJOOMBACKUP_VERIFY_FAILED="INTEGRITY CHECK FAILED — archive has been mo
|
||||
COM_MOKOJOOMBACKUP_VERIFY_NO_CHECKSUM="No checksum stored for this backup. Only backups created after this update can be verified."
|
||||
|
||||
; S3 storage
|
||||
COM_MOKOJOOMBACKUP_REMOTE_SFTP="SFTP (SSH File Transfer)"
|
||||
COM_MOKOJOOMBACKUP_REMOTE_S3="Amazon S3 / S3-Compatible"
|
||||
|
||||
; SFTP fields
|
||||
COM_MOKOJOOMBACKUP_FIELDSET_SFTP="SFTP Settings"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_HOST="SFTP Host"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_HOST_DESC="SFTP server hostname or IP address"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_PORT="SFTP Port"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_PORT_DESC="SSH port (default: 22)"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_USERNAME="SSH Username"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_USERNAME_DESC="Username for SSH authentication"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD="SSH Password"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD_DESC="Password for SSH authentication. Leave blank if using a key file."
|
||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY="SSH Private Key"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_DESC="Paste the contents of your SSH private key (e.g. id_rsa or id_ed25519). The key is stored securely in the database and written to a temp file with 0600 permissions only during upload, then deleted. Leave blank to use password authentication."
|
||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE="Key Passphrase"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE_DESC="Passphrase for the private key, if encrypted. Leave blank for unencrypted keys."
|
||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH="Remote Path"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH_DESC="Directory on the remote server to upload backups to"
|
||||
COM_MOKOJOOMBACKUP_FIELDSET_S3="S3 Storage Settings"
|
||||
COM_MOKOJOOMBACKUP_FIELD_S3_ENDPOINT="S3 Endpoint"
|
||||
COM_MOKOJOOMBACKUP_FIELD_S3_ENDPOINT_DESC="S3 API endpoint URL. Leave blank for AWS S3. For Wasabi, MinIO, Backblaze B2, enter their endpoint URL."
|
||||
|
||||
@@ -19,6 +19,13 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_profiles` (
|
||||
`ftp_path` VARCHAR(512) NOT NULL DEFAULT '/backups',
|
||||
`ftp_passive` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`ftp_ssl` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`sftp_host` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`sftp_port` INT(5) UNSIGNED NOT NULL DEFAULT 22,
|
||||
`sftp_username` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`sftp_password` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`sftp_key_data` MEDIUMTEXT,
|
||||
`sftp_passphrase` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`sftp_path` VARCHAR(512) NOT NULL DEFAULT '/backups',
|
||||
`gdrive_client_id` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`gdrive_client_secret` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`gdrive_refresh_token` VARCHAR(512) NOT NULL DEFAULT '',
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- MokoSuiteBackup 01.35.00 — SFTP support with key file storage
|
||||
|
||||
ALTER TABLE `#__mokosuitebackup_profiles`
|
||||
ADD COLUMN `sftp_host` VARCHAR(255) NOT NULL DEFAULT '' AFTER `ftp_ssl`,
|
||||
ADD COLUMN `sftp_port` INT(5) UNSIGNED NOT NULL DEFAULT 22 AFTER `sftp_host`,
|
||||
ADD COLUMN `sftp_username` VARCHAR(255) NOT NULL DEFAULT '' AFTER `sftp_port`,
|
||||
ADD COLUMN `sftp_password` VARCHAR(255) NOT NULL DEFAULT '' AFTER `sftp_username`,
|
||||
ADD COLUMN `sftp_key_data` MEDIUMTEXT AFTER `sftp_password`,
|
||||
ADD COLUMN `sftp_passphrase` VARCHAR(255) NOT NULL DEFAULT '' AFTER `sftp_key_data`,
|
||||
ADD COLUMN `sftp_path` VARCHAR(512) NOT NULL DEFAULT '/backups' AFTER `sftp_passphrase`;
|
||||
@@ -453,6 +453,7 @@ class BackupEngine
|
||||
{
|
||||
return match ($type) {
|
||||
'ftp' => new FtpUploader($profile),
|
||||
'sftp' => new SftpUploader($profile),
|
||||
'google_drive' => new GoogleDriveUploader($profile),
|
||||
's3' => new S3Uploader($profile),
|
||||
default => throw new \InvalidArgumentException('Unknown remote storage type: ' . $type),
|
||||
|
||||
@@ -278,6 +278,21 @@ class PreflightCheck
|
||||
|
||||
break;
|
||||
|
||||
case 'sftp':
|
||||
if (empty($profile->sftp_host)) {
|
||||
$this->warnings[] = 'SFTP host is not configured — remote upload will fail';
|
||||
}
|
||||
|
||||
if (empty($profile->sftp_username)) {
|
||||
$this->warnings[] = 'SFTP username is not configured — remote upload will fail';
|
||||
}
|
||||
|
||||
if (empty($profile->sftp_key_data) && empty($profile->sftp_password)) {
|
||||
$this->warnings[] = 'SFTP requires either a private key or password — remote upload will fail';
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'google_drive':
|
||||
if (empty($profile->gdrive_client_id) || empty($profile->gdrive_client_secret)) {
|
||||
$this->warnings[] = 'Google Drive OAuth credentials are not configured — remote upload will fail';
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteBackup
|
||||
* @subpackage com_mokosuitebackup
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*
|
||||
* SFTP uploader using the system sftp/scp binary with SSH key authentication.
|
||||
*
|
||||
* The private key is stored in the database (profile column) and written
|
||||
* to a temp file with 0600 permissions at upload time, then deleted.
|
||||
* This avoids leaving key files on the filesystem permanently.
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
class SftpUploader implements RemoteUploaderInterface
|
||||
{
|
||||
private string $host;
|
||||
private int $port;
|
||||
private string $username;
|
||||
private string $keyData;
|
||||
private string $passphrase;
|
||||
private string $password;
|
||||
private string $remotePath;
|
||||
|
||||
public function __construct(object $profile)
|
||||
{
|
||||
$this->host = $profile->sftp_host ?? '';
|
||||
$this->port = (int) ($profile->sftp_port ?? 22);
|
||||
$this->username = $profile->sftp_username ?? '';
|
||||
$this->keyData = $profile->sftp_key_data ?? '';
|
||||
$this->passphrase = $profile->sftp_passphrase ?? '';
|
||||
$this->password = $profile->sftp_password ?? '';
|
||||
$this->remotePath = rtrim($profile->sftp_path ?? '/backups', '/');
|
||||
}
|
||||
|
||||
public function upload(string $localPath, string $remoteName): array
|
||||
{
|
||||
if (empty($this->host)) {
|
||||
return ['success' => false, 'message' => 'SFTP host is not configured'];
|
||||
}
|
||||
|
||||
if (empty($this->username)) {
|
||||
return ['success' => false, 'message' => 'SFTP username is not configured'];
|
||||
}
|
||||
|
||||
if (empty($this->keyData) && empty($this->password)) {
|
||||
return ['success' => false, 'message' => 'SFTP requires either a private key or password'];
|
||||
}
|
||||
|
||||
$keyFile = null;
|
||||
|
||||
try {
|
||||
/* Write key to temp file if using key auth */
|
||||
if (!empty($this->keyData)) {
|
||||
$keyFile = $this->writeTempKey();
|
||||
}
|
||||
|
||||
/* Ensure remote directory exists */
|
||||
$this->ensureRemoteDir($keyFile);
|
||||
|
||||
/* Upload via scp */
|
||||
$remoteTarget = $this->username . '@' . $this->host . ':' . $this->remotePath . '/' . $remoteName;
|
||||
$cmd = $this->buildScpCommand($localPath, $remoteTarget, $keyFile);
|
||||
|
||||
$output = [];
|
||||
$exitCode = 0;
|
||||
exec($cmd . ' 2>&1', $output, $exitCode);
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
$errorMsg = implode("\n", $output);
|
||||
throw new \RuntimeException('scp failed (exit ' . $exitCode . '): ' . $errorMsg);
|
||||
}
|
||||
|
||||
/* Verify upload by checking remote file size */
|
||||
$remoteFile = $this->remotePath . '/' . $remoteName;
|
||||
$remoteSize = $this->getRemoteFileSize($remoteFile, $keyFile);
|
||||
$localSize = filesize($localPath);
|
||||
|
||||
if ($remoteSize > 0 && $remoteSize !== $localSize) {
|
||||
throw new \RuntimeException(
|
||||
'Size mismatch after upload: local=' . $localSize . ' remote=' . $remoteSize
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => 'Uploaded via SFTP: ' . $remoteFile,
|
||||
'remote_path' => $remoteFile,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return ['success' => false, 'message' => 'SFTP upload failed: ' . $e->getMessage()];
|
||||
} finally {
|
||||
$this->cleanupTempKey($keyFile);
|
||||
}
|
||||
}
|
||||
|
||||
public function testConnection(): array
|
||||
{
|
||||
if (empty($this->host)) {
|
||||
return ['success' => false, 'message' => 'SFTP host is not configured'];
|
||||
}
|
||||
|
||||
$keyFile = null;
|
||||
|
||||
try {
|
||||
if (!empty($this->keyData)) {
|
||||
$keyFile = $this->writeTempKey();
|
||||
}
|
||||
|
||||
$cmd = $this->buildSshCommand('echo "MokoSuiteBackup connection test OK" && hostname', $keyFile);
|
||||
$output = [];
|
||||
$exitCode = 0;
|
||||
exec($cmd . ' 2>&1', $output, $exitCode);
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
return ['success' => false, 'message' => 'SSH connection failed: ' . implode(' ', $output)];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => 'Connected to ' . $this->host . ' — ' . implode(' ', $output),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return ['success' => false, 'message' => 'Connection test failed: ' . $e->getMessage()];
|
||||
} finally {
|
||||
$this->cleanupTempKey($keyFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the private key from the database to a temp file with 0600 permissions.
|
||||
*/
|
||||
private function writeTempKey(): string
|
||||
{
|
||||
$tmpDir = sys_get_temp_dir();
|
||||
$keyFile = $tmpDir . '/mokobackup-sftp-' . bin2hex(random_bytes(8)) . '.key';
|
||||
|
||||
if (file_put_contents($keyFile, $this->keyData) === false) {
|
||||
throw new \RuntimeException('Cannot write temporary SSH key file');
|
||||
}
|
||||
|
||||
chmod($keyFile, 0600);
|
||||
|
||||
return $keyFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the temp key file.
|
||||
*/
|
||||
private function cleanupTempKey(?string $keyFile): void
|
||||
{
|
||||
if ($keyFile !== null && is_file($keyFile)) {
|
||||
unlink($keyFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the remote directory exists via ssh mkdir -p.
|
||||
*/
|
||||
private function ensureRemoteDir(?string $keyFile): void
|
||||
{
|
||||
$escapedPath = escapeshellarg($this->remotePath);
|
||||
$cmd = $this->buildSshCommand('mkdir -p ' . $escapedPath, $keyFile);
|
||||
|
||||
$output = [];
|
||||
$exitCode = 0;
|
||||
exec($cmd . ' 2>&1', $output, $exitCode);
|
||||
|
||||
/* mkdir -p exits 0 even if dir already exists, so only fail on non-zero */
|
||||
if ($exitCode !== 0) {
|
||||
throw new \RuntimeException('Cannot create remote directory: ' . implode(' ', $output));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remote file size via ssh stat.
|
||||
*/
|
||||
private function getRemoteFileSize(string $remotePath, ?string $keyFile): int
|
||||
{
|
||||
$escapedPath = escapeshellarg($remotePath);
|
||||
$cmd = $this->buildSshCommand('stat -c %s ' . $escapedPath . ' 2>/dev/null || echo -1', $keyFile);
|
||||
|
||||
$output = [];
|
||||
exec($cmd . ' 2>&1', $output);
|
||||
|
||||
$size = (int) trim(implode('', $output));
|
||||
|
||||
return $size > 0 ? $size : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an scp command string with proper SSH options.
|
||||
*/
|
||||
private function buildScpCommand(string $localPath, string $remoteTarget, ?string $keyFile): string
|
||||
{
|
||||
$parts = ['scp', '-o', 'StrictHostKeyChecking=no', '-o', 'BatchMode=yes'];
|
||||
|
||||
if ($this->port !== 22) {
|
||||
$parts[] = '-P';
|
||||
$parts[] = (string) $this->port;
|
||||
}
|
||||
|
||||
if ($keyFile !== null) {
|
||||
$parts[] = '-i';
|
||||
$parts[] = escapeshellarg($keyFile);
|
||||
}
|
||||
|
||||
if (!empty($this->passphrase)) {
|
||||
/* scp doesn't natively support passphrase via CLI — requires ssh-agent or expect.
|
||||
For now, key files should be unencrypted or use ssh-agent. */
|
||||
}
|
||||
|
||||
$parts[] = escapeshellarg($localPath);
|
||||
$parts[] = escapeshellarg($remoteTarget);
|
||||
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an ssh command string for remote commands.
|
||||
*/
|
||||
private function buildSshCommand(string $remoteCmd, ?string $keyFile): string
|
||||
{
|
||||
$parts = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'BatchMode=yes'];
|
||||
|
||||
if ($this->port !== 22) {
|
||||
$parts[] = '-p';
|
||||
$parts[] = (string) $this->port;
|
||||
}
|
||||
|
||||
if ($keyFile !== null) {
|
||||
$parts[] = '-i';
|
||||
$parts[] = escapeshellarg($keyFile);
|
||||
}
|
||||
|
||||
$parts[] = escapeshellarg($this->username . '@' . $this->host);
|
||||
$parts[] = escapeshellarg($remoteCmd);
|
||||
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
}
|
||||
@@ -410,6 +410,7 @@ class SteppedBackupEngine
|
||||
|
||||
$uploader = match ($session->remoteStorage) {
|
||||
'ftp' => new FtpUploader($profile),
|
||||
'sftp' => new SftpUploader($profile),
|
||||
'google_drive' => new GoogleDriveUploader($profile),
|
||||
's3' => new S3Uploader($profile),
|
||||
default => throw new \InvalidArgumentException('Unknown storage: ' . $session->remoteStorage),
|
||||
|
||||
@@ -17,6 +17,7 @@ use Joomla\Component\MokoSuiteBackup\Administrator\Engine\RestoreEngine;
|
||||
use Joomla\Console\Command\AbstractCommand;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
@@ -28,6 +29,10 @@ class RestoreCommand extends AbstractCommand
|
||||
{
|
||||
$this->setDescription('Restore a backup by record ID');
|
||||
$this->addArgument('id', InputArgument::REQUIRED, 'Backup record ID to restore');
|
||||
$this->addOption('files-only', null, InputOption::VALUE_NONE, 'Restore files only (skip database)');
|
||||
$this->addOption('db-only', null, InputOption::VALUE_NONE, 'Restore database only (skip files)');
|
||||
$this->addOption('no-preserve-config', null, InputOption::VALUE_NONE, 'Do not preserve current configuration.php');
|
||||
$this->addOption('password', 'p', InputOption::VALUE_REQUIRED, 'Decryption password for encrypted archives', '');
|
||||
}
|
||||
|
||||
protected function doExecute(InputInterface $input, OutputInterface $output): int
|
||||
@@ -85,8 +90,22 @@ class RestoreCommand extends AbstractCommand
|
||||
require_once $engineFile;
|
||||
}
|
||||
|
||||
$filesOnly = $input->getOption('files-only');
|
||||
$dbOnly = $input->getOption('db-only');
|
||||
$preserveConfig = !$input->getOption('no-preserve-config');
|
||||
$password = $input->getOption('password') ?: '';
|
||||
|
||||
$restoreFiles = !$dbOnly;
|
||||
$restoreDb = !$filesOnly;
|
||||
|
||||
if ($filesOnly) {
|
||||
$io->note('Restoring files only (database will not be touched)');
|
||||
} elseif ($dbOnly) {
|
||||
$io->note('Restoring database only (files will not be touched)');
|
||||
}
|
||||
|
||||
$engine = new RestoreEngine();
|
||||
$result = $engine->restore($recordId);
|
||||
$result = $engine->restore($recordId, $restoreFiles, $restoreDb, $preserveConfig, $password);
|
||||
|
||||
if ($result['success']) {
|
||||
$io->success($result['message']);
|
||||
|
||||
Reference in New Issue
Block a user