". htmlspecialchars($check->report). ""; } } function postRepairInconsistency() { require_once('class.check.php'); $check = new Check(); if (!$check->repair()) { $this->response->data = [ 'total' => 0, 'msg' => __("error"), 'details' => $check->lastErrorString ?? '', ]; return; } $this->response->data = [ 'total' => 1, 'ok' => true, 'msg' => __("backup.done"), 'alertText' => __("backup.done"), ]; } } ================================================ FILE: src/ext/backup/class.download.php ================================================ Licensed under the GNU GPL version 2 or any later. See file COPYRIGHT for details. */ namespace BackupExtension; use BackupExtension; class Download { public $filename; public $lastErrorString = null; private $token = ''; function __construct(?string $filename) { $this->filename = is_null($filename) ? MTTPATH. 'db/backup.xml' : $filename; } function checkFileAccess(?string $tokenHash = null): bool { if (!file_exists($this->filename)) { $this->lastErrorString = "Backup file not found"; return false; } $this->token = access_token(); if ($this->token == '') { $this->lastErrorString = "No token provided"; return false; } if (!is_null($tokenHash)) { $a = explode(':', $tokenHash, 2); $rnd = $a[0] ?? ''; $hash = $a[1] ?? ''; if (!hash_equals(hash_hmac('sha256', $rnd, $this->token), $hash)) { $this->lastErrorString = "No temp token provided"; return false; } } return true; } function downloadUrl() { $rnd = randomString(); $hash = $rnd. ':'. hash_hmac('sha256', $rnd, $this->token); $url = BackupExtension::extApiActionUrl("download", "t=$hash"); return $url; } function printFile() { header('Content-type: application/xml; charset=utf-8'); header('Content-disposition: attachment; filename=backup.xml'); $fh = fopen($this->filename, "r") or die("Couldn't open file"); if ($fh) { while (!feof($fh)) { $buffer = fgets($fh, 4096); print($buffer); } fclose($fh); } exit(); } } ================================================ FILE: src/ext/backup/class.restore.php ================================================ Licensed under the GNU GPL version 2 or any later. See file COPYRIGHT for details. */ namespace BackupExtension; use XMLReader; use DBConnection; use Exception; class Restore { public $lastErrorString = null; private $filename; /** @var XMLReader */ private $reader; private $tableItem; function __construct() { // xml table => [ db table, xml item ] $this->tableItem = [ 'lists' => ['lists', 'list'], 'tasks' => ['todolist', 'task'], 'tags' => ['tags', 'tag'], 'tag2task' => ['tag2task', 'item'], 'settings' => ['settings', 'item'], ]; } function isUploaded(): bool { if (!isset($_FILES['file']) || !isset($_FILES['file']['name']) || !isset($_FILES['file']['tmp_name'])) { $this->lastErrorString = "Not uploaded"; return false; } $this->filename = $_FILES['file']['tmp_name']; if (!file_exists($this->filename) || !is_readable($this->filename)) { $this->lastErrorString = "Can't open file"; return false; } return true; } function restore(): bool { $this->reader = $reader = new XMLReader(); $reader->open($this->filename); // root element $reader->next('mttdb'); if ($reader->name != 'mttdb') { $this->lastErrorString = "Incorrect format: missing 'mttdb'."; return false; } if (!$this->moveNextElement()) { $this->lastErrorString = "Incorrect format: tables not found."; return false; } $this->beginRestore(); $tables = array_keys($this->tableItem); // Enumerate tables do { if ($reader->nodeType != XMLReader::ELEMENT) { error_log("Unexpected element '{$reader->name}' of type: {$reader->nodeType}"); break; } //error_log("Found table '{$reader->name}'"); $result = null; if (in_array($reader->name, $tables)) { $result = $this->readTable($this->tableItem[$reader->name][0], $this->tableItem[$reader->name][1]); } else { continue; // Unexpected table, just skip } if (is_null($result)) { return false; // Incorrect format, error is set, stop } } while ($this->moveNextElementSameLevel()); $this->endRestore(); $reader->close(); return true; } function moveNextElement(?string $el = null): ?bool { while ($this->reader->read()) { if ($this->reader->nodeType == XMLReader::ELEMENT) { if (!is_null($el) && $this->reader->name != $el) { return false; } return true; } else if ($this->reader->nodeType == XMLReader::END_ELEMENT) { return null; } } return null; } function moveNextElementSameLevel(?string $el = null) { return $this->reader->next() && ($this->reader->nodeType == XMLReader::ELEMENT || $this->moveNextElement($el)); } function readTable(string $table, string $itemName): ?int { $autoinc = $this->reader->getAttribute("auto_increment"); $maxId = 0; $count = 0; // find first item $found = $this->moveNextElement($itemName); if ($found === false) { $this->lastErrorString = "Incorrect item name {$this->reader->name}, expected '{$itemName}'"; return null; // Error } else if (is_null($found)) { return 0; // No items found } do { $count++; $id = $this->reader->getAttribute("id"); if (!is_null($id)) { $maxId = max($maxId, (int)$id); } // error_log("# $count: found $itemName with id $id"); $itemXml = $this->reader->readOuterXml(); $xml = simplexml_load_string($itemXml); //SimpleXMLElement if ($xml === false) { error_log("Incorrect format of $itemName"); continue; } if (!$this->insertToTable($table, $xml)) { return null; // Error } } while ($this->moveNextElementSameLevel($itemName)); // restore table last auto_increment (mysql) if (!is_null($autoinc)) { $autoinc = max((int)$autoinc, $maxId); } else { $autoinc = $maxId + 1; } if ($autoinc > 1) { $this->updateAutoinc($table, $autoinc); } return $count; } private function insertToTable(string $table, \SimpleXMLElement $xml): bool { if (!preg_match("/^[\\w]+$/", $table)) { throw new Exception("Malformed table name: $table"); } $fields = []; $values = []; $attrsXml = $xml->attributes(); if (isset($attrsXml['id']) && $attrsXml['id'] != '') { $fields[] = 'id'; $values[] = (string)$attrsXml['id']; } foreach ($xml->children() as $item) { $field = $item->getName(); $value = (string)$item; if (!preg_match("/^[\\w]+$/", $field)) { throw new Exception("Malformed field name: $field"); } $attrsXml = $item->attributes(); if (isset($attrsXml['isnull']) && $attrsXml['isnull'] == 'yes') { //$attrsXml['isnull']->__toString() $value = null; } $fields[] = $field; $values[] = $value; } $fieldsStr = implode(',', $fields); // id,name,title ... $subsStr = implode(',', array_fill(0, count($fields), '?')); // ?,?,? ... $db = DBConnection::instance(); try { $db->ex("INSERT INTO {$db->prefix}{$table} ($fieldsStr) VALUES ($subsStr)", $values); } catch (Exception $e) { error_log("Failed query: {$db->lastQuery}"); $this->lastErrorString = "Failed to add data to table '{$db->prefix}$table'. Database error (see query in error log): ". $e->getMessage(); return false; } return true; } private function updateAutoinc(string $table, int $autoinc) { $db = DBConnection::instance(); switch ($db::DBTYPE) { case DBConnection::DBTYPE_MYSQL: $db->ex("ALTER TABLE {$db->prefix}$table AUTO_INCREMENT = ". (int)$autoinc); break; case DBConnection::DBTYPE_POSTGRES: $db->ex("ALTER TABLE {$db->prefix}$table ALTER COLUMN id RESTART WITH ". (int)$autoinc); break; case DBConnection::DBTYPE_SQLITE: $db->ex("UPDATE sqlite_sequence SET seq=? WHERE name=?", [$autoinc, $db->prefix. $table]); break; default: break; } } private function beginRestore() { $db = DBConnection::instance(); $db->ex("BEGIN"); foreach ($this->tableItem as $a) { $table = $db->prefix. $a[0]; if ($db::DBTYPE == DBConnection::DBTYPE_POSTGRES) { $db->ex("TRUNCATE TABLE $table RESTART IDENTITY"); } else { # - we do not use TRUNCATE on mysql due to autocommit # - sqlite has truncate optimizer while delete all to make it faster # - no need to reset auto_increment sequence before inserting lower ids $db->ex("DELETE FROM $table"); } } $db->ex("DELETE FROM {$db->prefix}sessions"); } private function endRestore() { $db = DBConnection::instance(); $db->ex("COMMIT"); // vacuum? } } ================================================ FILE: src/ext/backup/extension.json ================================================ { "bundleId": "backup", "name": "Backup", "version": "1.1", "description": "Backup" } ================================================ FILE: src/ext/backup/lang/en.json ================================================ { "ext.backup.name": "Backup", "backup.h_make": "Make backup", "backup.d_make": "Will create backup file in '%s' folder.", "backup.make": "Make", "backup.download": "Download", "backup.done": "Done", "backup.not_writable": "Backup file is not writable, check permissions for db/backup.xml", "backup.last_backup": "Last backup: %s", "backup.h_inconsistency": "Check for inconsistency", "backup.d_inconsistency": "If you want to restore the backup to another database version or type, it's better to fix inconsistency before making backup.", "backup.check": "Check", "backup.repair": "Repair", "backup.h_restore": "Restore from backup", "backup.d_restore": "Will delete all records in existing database before restoring.", "backup.restore": "Restore…" } ================================================ FILE: src/ext/backup/lang/ru.json ================================================ { "ext.backup.name": "Резервное копирование", "backup.h_make": "Сделать копию", "backup.d_make": "Сохранит файл в папке '%s'.", "backup.make": "Создать", "backup.download": "Скачать", "backup.done": "Выполнено", "backup.not_writable": "Ошибка записи в файл, проверьте права доступа к db/backup.xml", "backup.last_backup": "Резервная копия: %s", "backup.h_inconsistency": "Проверка базы данных", "backup.d_inconsistency": "Если планируете восстановление из резервной копии в другую базу данных, то лучше исправить возможные ошибки перед резервным копированием.", "backup.check": "Проверить", "backup.repair": "Исправить", "backup.h_restore": "Восстановить из резервной копии", "backup.d_restore": "Удалит все существующие записи во время восстановления.", "backup.restore": "Восстановить…" } ================================================ FILE: src/ext/backup/loader.php ================================================ Licensed under the GNU GPL version 2 or any later. See file COPYRIGHT for details. */ if (!defined('MTTPATH')) { die("Unexpected usage."); } require_once('class.controller.php'); function mtt_ext_backup_instance(): MTTExtension { return new BackupExtension(); } use BackupExtension\Controller; class BackupExtension extends MTTExtension implements MTTExtensionSettingsInterface, MTTHttpApiExtender { //the same as dir name const bundleId = 'backup'; // settings domain const domain = "ext.backup.json"; function init() { } // MTTHttpApiExtender function extendHttpApi(): array { return array( '/makeBackup' => [ 'POST' => [ Controller::class , 'postMakeBackup' ], ], '/download' => [ 'POST' => [ Controller::class , 'postDownload' ], 'GET' => [ Controller::class , 'getDownload', true ], // doesn't check auth token ], '/restore' => [ 'POST' => [ Controller::class , 'postRestore' ], ], '/checkInconsistency' => [ 'POST' => [ Controller::class , 'postCheckInconsistency' ], ], '/repairInconsistency' => [ 'POST' => [ Controller::class , 'postRepairInconsistency' ], ], ); } function settingsPage(): string { $warning = ''; $e = function($s, $arg=null) { return __($s, true, $arg); }; $ext = htmlspecialchars(self::bundleId); $downloadDisabled = ''; $lastBackup = ''; $filename = MTTPATH. 'db/backup.xml'; if (file_exists($filename)) { $time = filemtime($filename); $lastBackup = htmlspecialchars( sprintf($e('backup.last_backup'), formatTime(Config::get('dateformat'). " H:i:s", $time)) ); } else { $downloadDisabled = 'disabled'; } return <<
". htmlspecialchars($e->getTraceAsString()) . "\n"; exit; } function myExceptionHandler(Throwable $e) { $called = ''; foreach ($e->getTrace() as $a) { if ($a['file'] == __FILE__) { $called = " in ". htmlspecialchars(basename($a['file']). ':'. $a['line']). ""; break; } } echo '