artabro/site/modules/ProcessDatabaseBackups/ProcessDatabaseBackups.module
2024-08-27 11:35:37 +02:00

790 lines
21 KiB
Text
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php namespace ProcessWire;
/**
* ProcessWire Database Backup and Restore
*
* License: MPL v2
*
* For ProcessWire 3.x
* Copyright (C) 2021 by Ryan Cramer
*
* https://processwire.com
*
*/
class ProcessDatabaseBackups extends Process {
/**
* Minimum required version for this module
*
*/
const minVersion = '3.0.62';
/**
* @var WireDatabaseBackup
*
*/
protected $backup = null;
/**
* Shared translation labels
*
*/
protected $labels = array();
/**
* This is an optional initialization function called before any execute functions.
*
*/
public function init() {
parent::init(); // required
$this->backup = $this->wire()->database->backups();
include(__DIR__ . '/ProcessDatabaseBackups.info.php');
/** @var array $info */
$this->labels = array(
"module-title" => $info['title'],
"info" => $this->_('Info'),
"download" => $this->_('SQL file'),
"downloadZIP" => $this->_('ZIP file'),
"backup" => $this->_('Backup'),
"delete" => $this->_('Delete'),
"restore" => $this->_('Restore'),
"cancel" => $this->_('Cancel'),
"upload" => $this->_('Upload'),
"description" => $this->_('Description'),
"valid" => $this->_('Valid?'),
"time" => $this->_('Date/Time'),
"user" => $this->_('Exported by'),
"size" => $this->_('File size'),
"pathname" => $this->_('Filename'),
"dbName" => $this->_('Database name'),
"tables" => $this->_('Which tables?'),
"numTables" => $this->_('Num tables exported'),
"numCreateTables" => $this->_('Num tables created'),
"numInserts" => $this->_('Num rows'),
"numSeconds" => $this->_('Export time (seconds)'),
);
}
/**
* Get the path where backup files are stored
*
* @param bool $short Specify true if you only want path relative to site root (for display purposes)
* @return string
*
*/
protected function backupPath($short = false) {
$path = $this->backup->getPath();
if($short) $path = str_replace($this->wire('config')->paths->root, '/', $path);
return $path;
}
/**
* Get array of all backup files
*
* @param string $onlyID Specify ID of file if you only want to get a specific backup file
* @return array Array of file info arrays indexed by file 'id'
*
*/
protected function getBackupFiles($onlyID = '') {
$files = array();
$n = 0;
foreach($this->backup->getFiles() as $file) {
$id = "$n:$file";
if($onlyID && $id != $onlyID) continue;
$info = $this->backup->getFileInfo($file);
$info['id'] = $id;
$files[$id] = $info;
$n++;
}
return $files;
}
/**
* Get actions for given $file array
*
* @param array $file File info array
* @return array
*
*/
protected function getFileActions(array $file) {
$url = $this->wire('page')->url();
$actions = array(
'info' => array(
'href' => $url . "info/?id=$file[id]",
'label' => $this->labels['info'],
'icon' => 'info-circle',
'secondary' => false,
'head' => false,
),
'download' => array(
'href' => $url . "download/?id=$file[id]",
'label' => $this->labels['download'],
'icon' => 'cloud-download',
'secondary' => false,
'head' => true,
),
'downloadZIP' => array(
'href' => $url . "download/?id=$file[id]&zip=1",
'label' => $this->labels['downloadZIP'],
'icon' => 'download',
'secondary' => false,
'head' => true,
),
'restore ' => array(
'href' => $url . "restore/?id=$file[id]",
'label' => $this->labels['restore'],
'icon' => 'life-ring',
'secondary' => true,
'head' => false,
),
'delete' => array(
'href' => $url . "delete/?id=$file[id]",
'label' => $this->labels['delete'],
'icon' => 'trash',
'secondary' => true,
'head' => false,
),
);
if(!class_exists("\\ZipArchive")) unset($actions['downloadZIP']);
return $actions;
}
/**
* Get information about file requested in URL via $_GET['id'] or given file array
*
* @param array $file Omit to get file specified in GET[id]
* @return array
* @throws WireException
*
*/
protected function getFile(array $file = null) {
if($file === null) {
$id = $this->input->get('id');
if(is_null($id)) throw new WireException("No file specified");
$files = $this->getBackupFiles();
if(!isset($files[$id])) throw new WireException("Unrecognized file");
$file = $files[$id];
}
if(empty($file['pathname'])) {
throw new WireException('Backup file missing pathname index');
}
if(empty($file['zip'])) {
$basename = basename($file['pathname'], '.sql');
$file['zip'] = dirname($file['pathname']) . '/' . $basename . '.zip';
}
return $file;
}
/**
* This function is executed when a page with your Process assigned is accessed.
*
* This can be seen as your main or index function. You'll probably want to replace
* everything in this function.
*
*/
public function ___execute() {
$sanitizer = $this->wire()->sanitizer;
$modules = $this->wire()->modules;
$input = $this->wire()->input;
$numFiles = 0;
$backupFiles = $this->getBackupFiles();
$this->headline($this->labels['module-title']);
/** @var InputfieldForm $form */
$form = $modules->get('InputfieldForm');
/** @var InputfieldCheckbox $checkbox */
$checkbox = $modules->get('InputfieldCheckbox');
$checkbox->attr('name', 'deletes[]');
$checkbox->addClass('delete-checkbox');
$checkbox->checkboxOnly = true;
$checkbox->label = ' ';
/** @var MarkupAdminDataTable $table */
$table = $modules->get('MarkupAdminDataTable');
$table->setEncodeEntities(false);
$table->headerRow(array(
$this->_x('file', 'th'),
$this->_x('date', 'th'),
$this->_x('tables', 'th'),
$this->_x('rows', 'th'),
$this->_x('size', 'th'),
$this->_x('actions', 'th'),
wireIconMarkup('trash', 'lg')
));
foreach($backupFiles as $id => $file) {
$numFiles++;
$numTables = $file['numTables'];
if($numTables && !count($file['tables'])) $numTables .= " " . $this->_('(all)');
$basename = $file['basename'];
$time = $file['time'] ? $file['time'] : $file['mtime'];
if($file['description']) $basename .= '*';
$actions = array();
foreach($this->getFileActions($file) as $action) {
$actions[] = $this->aTooltip($action['href'], wireIconMarkup($action['icon'], 'fw'), $action['label']);
}
$checkbox->attr('id', "delete_" . $sanitizer->fieldName($id));
$checkbox->attr('value', $id);
$table->row(array(
$this->nowrap($basename) => "./info/?id=$id",
$this->tdSort(strtotime($time), wireRelativeTimeStr($time, true)),
$this->nowrap($numTables),
$this->tdSort($file['numInserts'], number_format($file['numInserts'])),
$this->tdSort($file['size'], wireBytesStr($file['size'])),
$this->nowrap(implode(' ', $actions)),
$checkbox->render(),
));
}
if(!$numFiles) $form->description = $this->_('No database backup files yet.');
$form->value = $table->render();
/** @var InputfieldButton $f */
$f = $modules->get('InputfieldButton');
$f->value = $this->labels['backup'];
$f->icon = 'database';
$f->href = "./backup/";
$f->addClass('btn-backup');
$f->showInHeader(true);
$form->add($f);
/** @var InputfieldButton $f */
$f = $modules->get('InputfieldButton');
$f->value = $this->labels['upload'];
$f->href = "./upload/";
$f->icon = 'cloud-upload';
$f->addClass('btn-upload');
$f->setSecondary();
$f->showInHeader(true);
$form->add($f);
/** @var InputfieldButton $f */
$f = $modules->get('InputfieldSubmit');
$f->attr('name', 'submit_delete_checked');
$f->value = $this->_('Delete checked');
$f->addClass('btn-delete-checked');
$f->icon = 'trash';
$f->showInHeader(true);
$form->add($f);
if($input->post('submit_delete_checked') && is_array($input->post('deletes'))) {
// process deleted backups
$form->processInput($input->post); // for CSRF only
foreach($input->post('deletes') as $id) {
if(!isset($backupFiles[$id])) continue;
/** @var array $backupFile */
$backupFile = $backupFiles[$id];
$this->unlinkBackup($backupFile);
}
$this->wire()->session->redirect('./');
}
return $form->render();
}
/**
* Execute upload
*
* @return string
*
*/
public function ___executeUpload() {
$modules = $this->wire()->modules;
$input = $this->wire()->input;
/** @var InputfieldForm $form */
$form = $modules->get('InputfieldForm');
$form->attr('id', 'upload_form');
$form->description = $this->_('Add new SQL database dump file');
/** @var InputfieldFile $f */
$f = $modules->get("InputfieldFile");
$f->name = 'upload_file';
$f->label = $this->_('Upload File');
$f->extensions = 'sql';
$f->maxFiles = 0;
$f->unzip = 1;
$f->overwrite = false;
$f->destinationPath = $this->backupPath();
if(method_exists($f, 'setMaxFilesize')) $f->setMaxFilesize('100g');
$form->add($f);
/** @var InputfieldSubmit $b */
$b = $modules->get('InputfieldSubmit');
$b->attr('name', 'submit_upload_file');
$b->attr('value', $this->labels['upload']);
$form->add($b);
if($input->post('submit_upload_file')) {
$form->processInput($input->post);
foreach($f->value as $pagefile) {
$this->message(sprintf($this->_('Added file: %s'), $pagefile->basename));
}
$this->session->redirect($this->wire()->page->url);
}
return $form->render();
}
/**
* Execute backup info
*
* @return string
*
*/
public function ___executeInfo() {
$modules = $this->wire()->modules;
$config = $this->wire()->config;
$file = $this->getFile();
$this->headline($file['basename']);
$info = $this->backup->getFileInfo($file['pathname']);
$info = array_merge($file, $info);
if($info['valid']) {
$info['valid'] = $this->_('Yes! Confirmed valid begin and end of file.');
if(!count($info['tables'])) $info['tables'] = array($this->_('All Tables'));
} else {
$info['valid'] = $this->_('Unable to confirm if valid file (likely not created by this tool)');
}
unset($info['basename'], $info['id'], $info['zip']);
$info['pathname'] = str_replace($config->paths->root, '/', $info['pathname']);
if(empty($info['time'])) {
$info['mtime'] = date('Y-m-d H:i:s') . " (" . wireRelativeTimeStr($info['mtime']) . ")";
} else {
unset($info['mtime']);
$time = strtotime($info['time']);
$info['time'] = "$info[time] (" . wireRelativeTimeStr($time) . ")";
}
$bytes = $info['size'];
$info['size'] = number_format($bytes) . " " . $this->_x('bytes', 'file-details');
if(function_exists("ProcessWire\\wireBytesStr")) {
$info['size'] .= ' (' . wireBytesStr($bytes) . ')';
}
/** @var MarkupAdminDataTable $table */
$table = $modules->get('MarkupAdminDataTable');
foreach($info as $key => $value) {
if(is_array($value)) $value = implode(', ', $value);
if(!strlen($value)) continue;
$label = isset($this->labels[$key]) ? $this->labels[$key] : $key;
$table->row(array($label, $value));
}
/** @var InputfieldForm $form */
$form = $modules->get('InputfieldForm');
$form->value = $table->render();
$n = 0;
foreach($this->getFileActions($file) as $name => $action) {
if($name === 'info') continue;
/** @var InputfieldButton $f */
$f = $modules->get('InputfieldButton');
$f->href = $action['href'];
$f->value = $action['label'];
$f->icon = $action['icon'];
if($action['secondary']) $f->setSecondary();
if($action['head']) $f->showInHeader(true);
$form->add($f);
$n++;
}
return $form->render();
}
/**
* Execute backup
*
* @return string
*
*/
public function ___executeBackup() {
$input = $this->wire()->input;
$session = $this->wire()->session;
$modules = $this->wire()->modules;
$allTables = $this->backup->getAllTables();
$this->headline($this->labels['backup']);
if($input->post('submit_backup') && ($input->post('backup_all') || count($input->post('tables')))) {
$this->processBackup();
$session->redirect('../');
}
/** @var InputfieldForm $form */
$form = $modules->get('InputfieldForm');
/** @var InputfieldName $f */
$f = $modules->get('InputfieldName');
$f->attr('name', 'backup_name');
$f->label = $this->_('Backup name');
$f->description = $this->_('This will be used for the backup filename. The extension .sql will be added to it automatically.');
$f->notes = $this->_('If omitted, a unique filename will be automatically generated.');
$f->required = false;
// $f->attr('value', $this->wire('config')->dbName . '_' . date('Y-m-d'));
$form->add($f);
/** @var InputfieldText $f */
$f = $modules->get('InputfieldText');
$f->attr('name', 'description');
$f->label = $this->_('Backup description');
$f->collapsed = Inputfield::collapsedBlank;
$form->add($f);
/** @var InputfieldCheckbox $f */
$f = $modules->get('InputfieldCheckbox');
$f->attr('name', 'backup_all');
$f->label = $this->_('Backup all tables?');
$f->attr('value', 1);
$f->attr('checked', 'checked');
$form->add($f);
/** @var InputfieldSelectMultiple $f */
$f = $modules->get('InputfieldSelectMultiple');
$f->attr('name', 'tables');
$f->label = $this->_('Tables');
$f->description = $this->_('By default, the export will include all tables. If you only want certain tables to be included, select them below.');
foreach($allTables as $table) $f->addOption($table, $table);
$f->attr('value', $allTables);
$f->showIf = 'backup_all=0';
$form->add($f);
/** @var InputfieldSubmit $f */
$f = $modules->get('InputfieldSubmit');
$f->attr('name', 'submit_backup');
$f->icon = 'database';
$f->showInHeader(true);
$form->add($f);
$form->appendMarkup =
"<p class='detail'>" .
$this->_('Please be patient after clicking submit. Backups may take some time, depending on how much there is to backup.') .
"</p>";
return $form->render();
}
/**
* Process submitted backup form, creating a new backup file
*
*/
protected function processBackup() {
$input = $this->wire()->input;
$config = $this->wire()->config;
$sanitizer = $this->wire()->sanitizer;
$allTables = $this->backup->getAllTables();
$filename = basename($sanitizer->filename($input->post('backup_name')), '.sql');
if(empty($filename)) $filename = $config->dbName;
$_filename = $filename;
$filename .= '.sql';
if(preg_match('/^(.+)-(\d+)$/', $_filename, $matches)) {
$_filename = $matches[1];
$n = $matches[2];
} else {
$n = 0;
}
while(file_exists($this->backupPath() . $filename)) {
$filename = $_filename . "-" . (++$n) . ".sql";
}
$options = array(
'filename' => $filename,
'description' => $sanitizer->text($input->post('description')),
);
if(!$input->post('backup_all')) {
// selective tables
$options['tables'] = array();
foreach($input->post('tables') as $table) {
if(!isset($allTables[$table])) continue;
$options['tables'][] = $allTables[$table];
}
}
$file = $this->backup->backup($options);
if($file) $this->message($this->_('Saved new backup:') . " $file");
}
/**
* Execute download
*
*/
public function ___executeDownload() {
$input = $this->wire()->input;
$session = $this->wire()->session;
$file = $this->getFile();
$getZIP = $input->get('zip') && !empty($file['zip']);
if($getZIP && !is_file($file['zip'])) {
$zipInfo = wireZipFile($file['zip'], array($file['pathname']));
if(!empty($zipInfo['errors']) || !is_file($file['zip'])) {
foreach($zipInfo['errors'] as $error) $this->error($error);
$this->error(sprintf($this->_('Failed to create ZIP file: %s'), $file['zip']));
if(is_file($file['zip'])) $this->unlink($file['zip']);
$session->redirect($this->wire()->page->url);
return;
}
}
$filename = $getZIP ? $file['zip'] : $file['pathname'];
wireSendFile($filename, array(
'forceDownload' => true,
'exit' => false
));
if($getZIP && is_file($file['zip'])) {
$this->unlink($file['zip']);
}
exit;
}
/**
* Execute delete
*
* @return string
*
*/
public function ___executeDelete() {
$input = $this->wire()->input;
$modules = $this->wire()->modules;
$session = $this->wire()->session;
$page = $this->wire()->page;
$this->headline($this->_('Delete Backup'));
$file = $this->getFile();
$submitDelete = $input->post('submit_delete');
if($submitDelete && $file && $input->post('delete_confirm')) {
// confirmed delete
$this->unlinkBackup($file);
$session->redirect($page->url);
} else if($submitDelete) {
// not confirmed
$session->redirect($page->url);
} else {
// render confirmation form
/** @var InputfieldForm $form */
$form = $modules->get('InputfieldForm');
$form->action = "./?id=$file[id]";
$form->description = sprintf($this->_('Delete %s?'), $file['basename']);
/** @var InputfieldCheckbox $f */
$f = $modules->get('InputfieldCheckbox');
$f->attr('name', 'delete_confirm');
$f->label = $this->_('Check the box to confirm');
$form->add($f);
/** @var InputfieldSubmit $f */
$f = $modules->get('InputfieldSubmit');
$f->attr('name', 'submit_delete');
$form->add($f);
return $form->render();
}
return '';
}
/**
* Execute restore
*
* @return string
*
*/
public function ___executeRestore() {
$input = $this->wire()->input;
$session = $this->wire()->session;
$modules = $this->wire()->modules;
$page = $this->wire()->page;
$file = $this->getFile();
$this->headline($this->_('Restore Backup'));
if($input->post('submit_restore')) {
if($input->post('restore_confirm') && file_exists($file['pathname'])) {
$options = array(
'allowDrop' => true,
'maxSeconds' => 3600
);
if($input->post('restore_drop')) $options['dropAll'] = true;
$success = $this->backup->restore($file['basename'], $options);
if($success) {
$this->message(sprintf($this->_('Restored: %s'), "$file[basename]"));
$session->redirect($page->url);
} else {
$this->error(sprintf($this->_('Error restoring: %s'), "$file[pathname]"));
}
}
} else {
/** @var InputfieldForm $form */
$form = $modules->get('InputfieldForm');
$form->action = "./?id=$file[id]";
$form->description = $this->_('Warning: the current database will be destroyed and replaced (this has potential to break your site!)');
/** @var InputfieldCheckbox $f */
$f = $modules->get('InputfieldCheckbox');
$f->attr('name', 'restore_confirm');
$f->label = sprintf($this->_('Restore %s?'), $file['basename']);
$form->add($f);
if(method_exists($this->backup, 'dropAllTables')) {
/** @var InputfieldCheckbox $f */
$f = $modules->get('InputfieldCheckbox');
$f->attr('name', 'restore_drop');
$f->label = $this->_('Drop all tables from current database before restore?');
$f->showIf = 'restore_confirm=1';
$form->add($f);
}
/** @var InputfieldSubmit $f */
$f = $modules->get('InputfieldSubmit');
$f->attr('name', 'submit_restore');
$form->add($f);
/** @var InputfieldButton $f */
$f = $modules->get('InputfieldButton');
$f->attr('value', $this->labels['cancel']);
$f->href = $page->url;
$f->setSecondary(true);
$form->add($f);
return $form->render();
}
return '';
}
/**
* Render a sortable column for a list table
*
* @param string|int $unformatted Unformatted sortable value
* @param string $formatted Formatted value
* @return string
*
*/
protected function tdSort($unformatted, $formatted) {
return "<span style='display:none;'>$unformatted</span>" . $this->nowrap($formatted);
}
/**
* Rener a nowrap span with given markup
*
* @param string $markup
* @return string
*
*/
protected function nowrap($markup) {
return "<span style='white-space:nowrap'>$markup</span>";
}
/**
* Render an <a> link with tooltip
*
* @param string $href Link URL
* @param string $label Link text
* @param string $description Tooltip text
* @return string
*
*/
protected function aTooltip($href, $label, $description) {
return "<a href='$href' class='tooltip' title='$description'>$label</a>";
}
/**
* Delete/unlink DB file from file array
*
* @param array $file
* @throws WireException
*
*/
protected function unlinkBackup(array $file) {
if(empty($file['zip'])) $file = $this->getFile($file);
$pathnames = array($file['pathname'], $file['zip']);
foreach($pathnames as $pathname) {
if(empty($pathname) || !is_file($pathname)) continue;
if($this->unlink($pathname)) {
$this->message(sprintf($this->_('Deleted: %s'), basename($pathname)));
}
}
}
/**
* Unlink filename
*
* @param string $pathname
* @return bool
* @throws WireException
*
*/
protected function unlink($pathname) {
if(!is_string($pathname)) throw new WireException('pathname must be a string');
$fileTools = $this->wire()->files;
if(method_exists($fileTools, 'unlink')) return $fileTools->unlink($pathname);
return unlink($pathname);
}
/**
* Install
*
* @throws WireException
*
*/
public function ___install() {
// check that they have the required PW version
if(version_compare($this->wire()->config->version, self::minVersion, '<')) {
throw new WireException("This module requires ProcessWire " . self::minVersion . " or newer.");
}
parent::___install();
}
/**
* Uninstall
*
*/
public function ___uninstall() {
$path = $this->backupPath(true);
$this->warning("Please note that the backup files in $path remain. If you dont want them there, please remove them manually.");
parent::___uninstall();
}
}