diff --git a/composer.json b/composer.json index 662006d8..b00bf23f 100644 --- a/composer.json +++ b/composer.json @@ -13,10 +13,10 @@ "brick/math": "^0.8.15", "composer/package-versions-deprecated": "1.11.99.4", "doctrine/annotations": "^1.6", + "doctrine/dbal": "^3.4.6", "doctrine/doctrine-bundle": "^2.0", "doctrine/doctrine-migrations-bundle": "^3.0", "doctrine/orm": "^2.9", - "doctrine/dbal": "^3.4.6", "dompdf/dompdf": "^2.0.0", "erusev/parsedown": "^1.7", "florianv/swap": "^4.0", @@ -25,6 +25,7 @@ "jbtronics/2fa-webauthn": "^1.0.0", "league/html-to-markdown": "^5.0.1", "liip/imagine-bundle": "^2.2", + "nelexa/zip": "^4.0", "nelmio/security-bundle": "^3.0", "nyholm/psr7": "^1.1", "ocramius/proxy-manager": "2.2.*", @@ -38,6 +39,7 @@ "scheb/2fa-trusted-device": "^5.13", "sensio/framework-extra-bundle": "^6.1.1", "shivas/versioning-bundle": "^4.0", + "spatie/db-dumper": "^2.21", "symfony/apache-pack": "^1.0", "symfony/asset": "5.4.*", "symfony/console": "5.4.*", diff --git a/composer.lock b/composer.lock index 72674699..c384ed51 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c6dc9c0385baeff44cb2bf8ee4c1e656", + "content-hash": "433a25b4df056e2a121ed5cbf442b172", "packages": [ { "name": "beberlei/assert", @@ -3270,6 +3270,79 @@ ], "time": "2022-07-24T11:55:47+00:00" }, + { + "name": "nelexa/zip", + "version": "4.0.2", + "source": { + "type": "git", + "url": "https://github.com/Ne-Lexa/php-zip.git", + "reference": "88a1b6549be813278ff2dd3b6b2ac188827634a7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Ne-Lexa/php-zip/zipball/88a1b6549be813278ff2dd3b6b2ac188827634a7", + "reference": "88a1b6549be813278ff2dd3b6b2ac188827634a7", + "shasum": "" + }, + "require": { + "ext-zlib": "*", + "php": "^7.4 || ^8.0", + "psr/http-message": "*", + "symfony/finder": "*" + }, + "require-dev": { + "ext-bz2": "*", + "ext-dom": "*", + "ext-fileinfo": "*", + "ext-iconv": "*", + "ext-openssl": "*", + "ext-xml": "*", + "friendsofphp/php-cs-fixer": "^3.4.0", + "guzzlehttp/psr7": "^1.6", + "phpunit/phpunit": "^9", + "symfony/http-foundation": "*", + "symfony/var-dumper": "*", + "vimeo/psalm": "^4.6" + }, + "suggest": { + "ext-bz2": "Needed to support BZIP2 compression", + "ext-fileinfo": "Needed to get mime-type file", + "ext-iconv": "Needed to support convert zip entry name to requested character encoding", + "ext-openssl": "Needed to support encrypt zip entries or use ext-mcrypt" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpZip\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ne-Lexa", + "email": "alexey@nelexa.ru", + "role": "Developer" + } + ], + "description": "PhpZip is a php-library for extended work with ZIP-archives. Open, create, update, delete, extract and get info tool. Supports appending to existing ZIP files, WinZip AES encryption, Traditional PKWARE Encryption, BZIP2 compression, external file attributes and ZIP64 extensions. Alternative ZipArchive. It does not require php-zip extension.", + "homepage": "https://github.com/Ne-Lexa/php-zip", + "keywords": [ + "archive", + "extract", + "unzip", + "winzip", + "zip", + "ziparchive" + ], + "support": { + "issues": "https://github.com/Ne-Lexa/php-zip/issues", + "source": "https://github.com/Ne-Lexa/php-zip/tree/4.0.2" + }, + "time": "2022-06-17T11:17:46+00:00" + }, { "name": "nelmio/security-bundle", "version": "v3.0.0", @@ -5699,6 +5772,66 @@ }, "time": "2022-07-10T17:39:01+00:00" }, + { + "name": "spatie/db-dumper", + "version": "2.21.1", + "source": { + "type": "git", + "url": "https://github.com/spatie/db-dumper.git", + "reference": "05e5955fb882008a8947c5a45146d86cfafa10d1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/db-dumper/zipball/05e5955fb882008a8947c5a45146d86cfafa10d1", + "reference": "05e5955fb882008a8947c5a45146d86cfafa10d1", + "shasum": "" + }, + "require": { + "php": "^7.2|^8.0", + "symfony/process": "^4.2|^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.0|^8.0|^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\DbDumper\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Dump databases", + "homepage": "https://github.com/spatie/db-dumper", + "keywords": [ + "database", + "db-dumper", + "dump", + "mysqldump", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/db-dumper/issues", + "source": "https://github.com/spatie/db-dumper/tree/2.21.1" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2021-02-24T14:56:42+00:00" + }, { "name": "spomky-labs/base64url", "version": "v2.0.4", diff --git a/config/services.yaml b/config/services.yaml index b5fe8112..aa1be89e 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -233,3 +233,7 @@ services: # We are needing this service inside of a migration, where only the container is injected. So we need to define it as public, to access it from the container. App\Services\UserSystem\PermissionPresetsHelper: public: true + + App\Command\BackupCommand: + arguments: + $project_dir: '%kernel.project_dir%' diff --git a/src/Command/BackupCommand.php b/src/Command/BackupCommand.php new file mode 100644 index 00000000..10ea68cb --- /dev/null +++ b/src/Command/BackupCommand.php @@ -0,0 +1,211 @@ +project_dir = $project_dir; + $this->entityManager = $entityManager; + + parent::__construct(); + } + + protected function configure(): void + { + $this->setHelp('This command allows you to backup the files and database of Part-DB.'); + + $this + ->addArgument('output', InputArgument::REQUIRED, 'The file to which the backup should be written') + ->addOption('overwrite', null, InputOption::VALUE_NONE, 'Overwrite the output file, if it already exists without asking') + ->addOption('database', null, InputOption::VALUE_NONE, 'Backup the database') + ->addOption('attachments', null, InputOption::VALUE_NONE, 'Backup the attachments files') + ->addOption('config', null, InputOption::VALUE_NONE, 'Backup the config files') + ->addOption('full', null, InputOption::VALUE_NONE, 'Backup database, attachments and config files') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $output_filepath = $input->getArgument('output'); + $backup_database = $input->getOption('database'); + $backup_attachments = $input->getOption('attachments'); + $backup_config = $input->getOption('config'); + $backup_full = $input->getOption('full'); + + if ($backup_full) { + $backup_database = true; + $backup_attachments = true; + $backup_config = true; + } + + //When all options are false, we abort and show an error message + if (! $backup_database && ! $backup_attachments && ! $backup_config) { + $io->error('You have to select at least one option what to backup! Use --full to backup everything.'); + + return Command::FAILURE; + } + + $io->info('Backup Part-DB to '.$output_filepath); + + //Check if the file already exists + if (file_exists($output_filepath)) { + //Then ask the user, if he wants to overwrite the file + if (!$io->confirm('The file '.realpath($output_filepath).' already exists. Do you want to overwrite it?', false)) { + $io->error('Backup aborted!'); + + return Command::FAILURE; + } + } + + $io->note('Starting backup...'); + + //Open ZIP file + $zip = new ZipFile(); + + if ($backup_config) { + $this->backupConfig($zip, $io); + } + if ($backup_attachments) { + $this->backupAttachments($zip, $io); + } + if ($backup_database) { + $this->backupDatabase($zip, $io); + } + + $zip->setArchiveComment('Part-DB Backup of '.date('Y-m-d H:i:s')); + + //Write and close ZIP file + try { + $zip->saveAsFile($output_filepath); + } catch (ZipException $e) { + $io->error('Could not write ZIP file: '.$e->getMessage()); + + return Command::FAILURE; + } + $zip->close(); + + $io->success('Backup finished! You can find the backup file at '.$output_filepath); + + return Command::SUCCESS; + } + + /** + * Constructs the MySQL PDO DSN. + * Taken from https://github.com/doctrine/dbal/blob/3.5.x/src/Driver/PDO/MySQL/Driver.php + * + * @param mixed[] $params + */ + private function configureDumper(array $params, DbDumper $dumper): void + { + if (isset($params['host']) && $params['host'] !== '') { + $dumper->setHost($params['host']); + } + + if (isset($params['port'])) { + $dumper->setPort($params['port']); + } + + if (isset($params['dbname'])) { + $dumper->setDbName($params['dbname']); + } + + if (isset($params['unix_socket'])) { + $dumper->setSocket($params['unix_socket']); + } + + if (isset($params['user'])) { + $dumper->setUserName($params['user']); + } + + if (isset($params['password'])) { + $dumper->setPassword($params['password']); + } + } + + protected function backupDatabase(ZipFile $zip, SymfonyStyle $io): void + { + $io->note('Backup database...'); + + //Determine if we use MySQL or SQLite + $connection = $this->entityManager->getConnection(); + if ($connection->getDatabasePlatform() instanceof AbstractMySQLPlatform) { + try { + $io->note('MySQL database detected. Dump DB to SQL using mysqldump...'); + $params = $connection->getParams(); + $dumper = MySql::create(); + $this->configureDumper($params, $dumper); + + $tmp_file = tempnam(sys_get_temp_dir(), 'partdb_sql_dump'); + + $dumper->dumpToFile($tmp_file); + $zip->addFile($tmp_file, 'mysql_dump.sql'); + } catch (\Exception $e) { + $io->error('Could not dump database: '.$e->getMessage()); + $io->error('This can maybe be fixed by installing the mysqldump binary and adding it to the PATH variable!'); + } + } elseif ($connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\SqlitePlatform) { + $io->note('SQLite database detected. Copy DB file to ZIP...'); + $params = $connection->getParams(); + $zip->addFile($params['path'], 'var/app.db'); + } else { + $io->error('Unknown database platform. Could not backup database!'); + } + + + } + + protected function backupConfig(ZipFile $zip, SymfonyStyle $io): void + { + $io->note('Backing up config files...'); + + //Add .env.local file if it exists + $env_local_filepath = $this->project_dir.'/.env.local'; + if (file_exists($env_local_filepath)) { + $zip->addFile($env_local_filepath, '.env.local'); + } else { + $io->warning('Could not find .env.local file. You maybe use env variables, then you have to backup them manually!!'); + } + + //Add config/parameters.yaml and config/banner.md files + $config_dir = $this->project_dir.'/config'; + $zip->addFile($config_dir.'/parameters.yaml', 'config/parameters.yaml'); + $zip->addFile($config_dir.'/banner.md', 'config/banner.md'); + } + + protected function backupAttachments(ZipFile $zip, SymfonyStyle $io): void + { + $io->note('Backing up attachments files...'); + + //Add public attachments directory + $attachments_dir = $this->project_dir.'/public/media/'; + $zip->addDirRecursive($attachments_dir, 'public/media/', ZipCompressionMethod::DEFLATED); + + //Add private attachments directory + $attachments_dir = $this->project_dir.'/uploads/'; + $zip->addDirRecursive($attachments_dir, 'uploads/', ZipCompressionMethod::DEFLATED); + } +}