diff --git a/src/ext/backup/.htaccess b/src/ext/backup/.htaccess new file mode 100644 index 0000000..8d2f256 --- /dev/null +++ b/src/ext/backup/.htaccess @@ -0,0 +1 @@ +deny from all diff --git a/src/ext/backup/class.backup.php b/src/ext/backup/class.backup.php new file mode 100644 index 0000000..f4cb8c0 --- /dev/null +++ b/src/ext/backup/class.backup.php @@ -0,0 +1,190 @@ + + Licensed under the GNU GPL version 2 or any later. See file COPYRIGHT for details. +*/ + +namespace BackupExtension; + +use Exception; + +class Backup +{ + public $lastErrorString = null; + public $filename; + private $fh; + private $level = 0; + private $tagClosed = true; + + function __construct(?string $filename) + { + $this->filename = is_null($filename) ? MTTPATH. 'db/backup.xml' : $filename; + } + + function isFileWritable() + { + if (!file_exists($this->filename)) { + @touch($this->filename); + } + if (!is_writable($this->filename)) { + return false; + } + return true; + } + + function makeBackup() + { + if (!$this->isFileWritable()) { + $this->lastErrorString = __('backup.not_writable'); + return false; + } + + $this->fh = fopen($this->filename, 'w'); + if ($this->fh === false) { + $ea = error_get_last(); + $this->lastErrorString = $ea['message'] ?? "Failed to open file for writing"; + return false; + } + + $db = \DBConnection::instance(); + + fwrite($this->fh, "\n"); + $this->writeOpeningTag('mttdb', [ + 'version' => 1, + 'appversion' => \mytinytodo\Version::VERSION, + 'dbversion' => \mytinytodo\Version::DB_VERSION, + 'dbtype' => $db::DBTYPE, + 'created' => date(DATE_ATOM) + ]); + $this->level = 0; + + + $this->writeTable($db->prefix.'lists', 'lists', 'list'); + $this->writeTable($db->prefix.'todolist', 'tasks', 'task'); + $this->writeTable($db->prefix.'tags', 'tags', 'tag'); + $this->writeTable($db->prefix.'tag2task', 'tag2task', 'item'); + $this->writeTable($db->prefix.'settings', 'settings', 'item'); + + + $this->writeClosingTag('mttdb'); + fwrite($this->fh, "\n"); + + if (!fclose($this->fh)) { + $ea = error_get_last(); + $this->lastErrorString = $ea['message'] ?? "Failed to close file"; + return false; + } + return true; + } + + function writeTable(string $table, string $group, string $itemName) + { + if (!preg_match("/^[\\w:]+$/", $table)) { + throw new Exception("Malformed table name: $table"); + } + $db = \DBConnection::instance(); + $props = null; + if ($db::DBTYPE == \DBConnection::DBTYPE_MYSQL) { + $autoinc = $this->getMysqlTableAutoIncrement($table); + if ($autoinc != '') { + $props = ['auto_increment' => $autoinc]; + } + } + $this->writeOpeningTag($group, $props); + $q = $db->dq("SELECT * FROM $table"); + while ($r = $q->fetchAssoc()) { + $this->writeItem($itemName, $r); + } + $this->writeClosingTag($group); + } + + function writeItem(string $entity, $r) + { + $tagAttrs = null; + if (isset($r['id'])) { + $tagAttrs = ['id' => $r['id']]; + unset($r['id']); + } + $this->writeOpeningTag($entity, $tagAttrs); + foreach ($r as $field => $value) { + $props = null; + if (is_null($value)) { + $props['isnull'] = 'yes'; + } + $this->writeOpeningTag($field, $props); + $this->writeTagContent((string)$value); + $this->writeClosingTag($field); + } + $this->writeClosingTag($entity); + } + + function getMysqlTableAutoIncrement(string $table): string + { + $db = \DBConnection::instance(); + $r = $db->sqa("SHOW TABLE STATUS WHERE Name=?", [$table]); + return (string)$r['Auto_increment'] ?? ''; + } + + + + function writeOpeningTag(string $tag, ?array $attrs = null) + { + if (!preg_match("/^[\\w:]+$/", $tag)) { + throw new Exception("Malformed tag: $tag"); + } + $data = "<$tag"; + if ($attrs !== null) { + $a = []; + foreach ($attrs as $k => $v) { + if (!preg_match("/^[\\w:-]+$/", $k)) { + throw new Exception("Malformed attribute name: $k"); + } + $v = (string)$v; + if (preg_match("/[\\r\\n]+/", $v)) { + throw new Exception("Malformed attribute value: $v"); + } + $a[] = "$k=\"". htmlspecialchars($v). "\""; + } + if (count($a) > 0) { + $data .= " ". implode(" ", $a); + } + } + $data .= ">"; + $this->write( ($this->tagClosed ? "" : "\n"). str_repeat(' ', $this->level) . $data ); + $this->level += 1; + $this->tagClosed = false; + } + + function writeClosingTag(string $tag) + { + if (!preg_match("/^[\\w:]+$/", $tag)) { + throw new Exception("Malformed tag: $tag"); + } + $this->level -= 1; + if ($this->level < 0) $this->level = 0; + $padding = ''; + if ($this->tagClosed) { + $padding = str_repeat(' ', $this->level); + } + $this->write( $padding . "\n" ); + $this->tagClosed = true; + } + + function writeTagContent(?string $content) + { + if ($content !== null) { + $this->write( htmlspecialchars($content, ENT_XML1, 'UTF-8') ); //TODO: make xml compliant? + } + } + + function write(string $data) + { + if (false === @fwrite($this->fh, $data)) { + $ea = error_get_last(); + throw new Exception("Failed to write to file: ". ($ea['message'] ?? "unknown reason")); + } + } + +} diff --git a/src/ext/backup/class.check.php b/src/ext/backup/class.check.php new file mode 100644 index 0000000..9ec520d --- /dev/null +++ b/src/ext/backup/class.check.php @@ -0,0 +1,126 @@ + + Licensed under the GNU GPL version 2 or any later. See file COPYRIGHT for details. +*/ + +namespace BackupExtension; + +use DBConnection; + +class Check +{ + public $lastErrorString = null; + public $report = ''; + + function check(): bool + { + $db = DBConnection::instance(); + $msg = []; + + // Task without list + $count = $db->sq("SELECT COUNT(*) FROM {$db->prefix}todolist WHERE list_id NOT IN (SELECT id FROM {$db->prefix}lists)"); + if ($count) { + $msg[] = "Tasks without list: $count"; + } + + // Tag without task (not a broblem) + $count = $db->sq("SELECT COUNT(*) FROM {$db->prefix}tags WHERE id NOT IN (SELECT tag_id FROM {$db->prefix}tag2task)"); + if ($count) { + $msg[] = "Tags without task: $count"; + } + + // tag2task no list + $count = $db->sq("SELECT COUNT(*) FROM {$db->prefix}tag2task WHERE list_id NOT IN (SELECT id FROM {$db->prefix}lists)"); + if ($count) { + $msg[] = "tag2task no list: $count"; + } + + // tag2task no tag + $count = $db->sq("SELECT COUNT(*) FROM {$db->prefix}tag2task WHERE tag_id NOT IN (SELECT id FROM {$db->prefix}tags)"); + if ($count) { + $msg[] = "tag2task no tag: $count"; + } + + // tag2task no task + $count = $db->sq("SELECT COUNT(*) FROM {$db->prefix}tag2task WHERE task_id NOT IN (SELECT id FROM {$db->prefix}todolist)"); + if ($count) { + $msg[] = "tag2task no task: $count"; + } + + $count = 0; + $uniqTag = []; // lowerTag => [id, tag] + $nonuniqTag = []; // id => [tag, lowerTag, uniqId, uniqTag, taskCount] + $q = $db->dq("SELECT id,name,COUNT(task_id) c FROM {$db->prefix}tags t LEFT JOIN {$db->prefix}tag2task tt ON t.id=tt.tag_id GROUP BY id ORDER BY id"); + while ($r = $q->fetchAssoc()) { + $v = mb_strtolower((string)$r['name'], 'UTF-8'); + if (!isset($uniqTag[$v])) { + $uniqTag[$v] = [$r['id'], $r['name']]; + } + else { + $count++; + $nonuniqTag[$r['id']] = [$r['name'], $v, $uniqTag[$v][0], $uniqTag[$v][1], $r['c']]; + } + } + if ($count > 0) { + $msg[] = "Non-unique tags: $count"; + foreach ($nonuniqTag as $id => $a) { + $msg[] = " ID:{$id} Tag:{$a[0]} (tasks: {$a[4]}) same as ID:{$a[2]} Tag:{$a[3]}"; + } + } + + if (count($msg) == 0) { + $msg[] = "OK"; + } + + $this->report = implode("\n", $msg); + return true; + } + + function repair(): bool + { + $db = DBConnection::instance(); + + $db->ex("BEGIN"); + + // Task without list + $count = (int)$db->sq("SELECT COUNT(*) FROM {$db->prefix}todolist WHERE list_id NOT IN (SELECT id FROM {$db->prefix}lists)"); + if ($count > 0) { + // Move to new list + $listID = \DBCore::default()->createListWithName("Restored tasks"); + $db->ex("UPDATE {$db->prefix}todolist SET list_id=? WHERE list_id NOT IN (SELECT id FROM {$db->prefix}lists)", [$listID]); + } + + //Tags + $db->ex("DELETE FROM {$db->prefix}tags WHERE id NOT IN (SELECT tag_id FROM {$db->prefix}tag2task)"); + $db->ex("DELETE FROM {$db->prefix}tag2task WHERE task_id NOT IN (SELECT id FROM {$db->prefix}todolist)"); + $db->ex("DELETE FROM {$db->prefix}tag2task WHERE tag_id NOT IN (SELECT id FROM {$db->prefix}tags)"); + + //Non-unique tags replace with first unique + $uniqTag = []; + $replace = []; + $q = $db->dq("SELECT id,name FROM {$db->prefix}tags t LEFT JOIN {$db->prefix}tag2task tt ON t.id=tt.tag_id GROUP BY id ORDER BY id"); + while ($r = $q->fetchAssoc()) { + $v = mb_strtolower((string)$r['name'], 'UTF-8'); + if (!isset($uniqTag[$v])) { + $uniqTag[$v] = $r['id']; + } + else { + $replace[$r['id']] = $uniqTag[$v]; + } + } + foreach ($replace as $id => $newId) { + $db->ex("UPDATE {$db->prefix}tag2task SET tag_id=? WHERE tag_id=?", [$newId, $id]); + } + $db->ex("DELETE FROM {$db->prefix}tags WHERE id NOT IN (SELECT tag_id FROM {$db->prefix}tag2task)"); + + + // TODO: tag2task no list ? + + $db->ex("COMMIT"); + return true; + } + +} diff --git a/src/ext/backup/class.controller.php b/src/ext/backup/class.controller.php new file mode 100644 index 0000000..50b795a --- /dev/null +++ b/src/ext/backup/class.controller.php @@ -0,0 +1,148 @@ + + Licensed under the GNU GPL version 2 or any later. See file COPYRIGHT for details. +*/ + +namespace BackupExtension; + +use BackupExtension; +use BackupExtension\Backup; +use BackupExtension\Download; +use BackupExtension\Check; + +class Controller extends \ApiController +{ + function postMakeBackup() + { + require_once('class.backup.php'); + $filename = BackupExtension::backupFilePath(); + $backup = new Backup($filename); + + if (!$backup->makeBackup()) { + $this->response->data = [ + 'total' => 0, + 'msg' => __("error"), + 'details' => $backup->lastErrorString ?? '', + ]; + } + + $this->response->data = [ + 'total' => 1, + 'msg' => __("backup.done"), + 'details' => '' + ]; + } + + function postDownload() + { + require_once('class.download.php'); + $filename = BackupExtension::backupFilePath(); + $download = new Download($filename); + + if (!$download->checkFileAccess()) { + $this->response->data = [ + 'total' => 0, + 'msg' => __("error"), + 'details' => $download->lastErrorString ?? '', + ]; + return; + } + $this->response->data = [ + 'total' => 1, + 'redirect' => $download->downloadUrl() + ]; + } + + function getDownload() + { + require_once('class.download.php'); + $filename = BackupExtension::backupFilePath(); + $download = new Download($filename); + + $ott = (string)_get('t'); + if (!$download->checkFileAccess($ott)) { + $this->response->data = [ + 'total' => 0, + 'msg' => __("error"), + 'details' => $download->lastErrorString ?? '', + ]; + return; + } + $download->printFile(); + exit(); + } + + function postRestore() + { + require_once('class.restore.php'); + $restore = new Restore(); + + if (!$restore->isUploaded()) { + $this->response->data = [ + 'total' => 0, + 'msg' => __("error"), + 'details' => $restore->lastErrorString ?? '', + ]; + return; + } + + if (!$restore->restore()) { + $this->response->data = [ + 'total' => 0, + 'msg' => __("error"), + 'details' => $restore->lastErrorString ?? '', + ]; + return; + } + + $this->response->data = [ + 'total' => 1, + 'msg' => __("backup.done"), + 'redirect' => get_mttinfo('url'), + ]; + } + + function postCheckInconsistency() + { + require_once('class.check.php'); + $check = new Check(); + + if (!$check->check()) { + $this->response->data = [ + 'total' => 0, + 'msg' => __("error"), + 'details' => $check->lastErrorString ?? '', + ]; + return; + } + $html = "
". htmlspecialchars($check->report). "
"; + $this->response->data = [ + 'total' => 1, + 'msg' => __("backup.done"), + 'html' => $html, + ]; + } + + 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, + 'msg' => __("backup.done"), + ]; + } + +} diff --git a/src/ext/backup/class.download.php b/src/ext/backup/class.download.php new file mode 100644 index 0000000..116187c --- /dev/null +++ b/src/ext/backup/class.download.php @@ -0,0 +1,74 @@ + + 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(); + } + +} diff --git a/src/ext/backup/class.restore.php b/src/ext/backup/class.restore.php new file mode 100644 index 0000000..eeccb31 --- /dev/null +++ b/src/ext/backup/class.restore.php @@ -0,0 +1,251 @@ + + 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; + 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 + $db->ex("DELETE FROM $table"); + } + } + $db->ex("DELETE FROM {$db->prefix}sessions"); + } + + private function endRestore() + { + $db = DBConnection::instance(); + $db->ex("COMMIT"); + // vacuum? + } + + +} diff --git a/src/ext/backup/extension.json b/src/ext/backup/extension.json new file mode 100644 index 0000000..c69d823 --- /dev/null +++ b/src/ext/backup/extension.json @@ -0,0 +1,6 @@ +{ + "bundleId": "backup", + "name": "Backup", + "version": "0.9", + "description": "Backup" +} diff --git a/src/ext/backup/lang/en.json b/src/ext/backup/lang/en.json new file mode 100644 index 0000000..32307cf --- /dev/null +++ b/src/ext/backup/lang/en.json @@ -0,0 +1,17 @@ +{ + "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…" +} diff --git a/src/ext/backup/lang/ru.json b/src/ext/backup/lang/ru.json new file mode 100644 index 0000000..e8689c0 --- /dev/null +++ b/src/ext/backup/lang/ru.json @@ -0,0 +1,17 @@ +{ + "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": "Восстановить…" +} diff --git a/src/ext/backup/loader.php b/src/ext/backup/loader.php new file mode 100644 index 0000000..8ecb259 --- /dev/null +++ b/src/ext/backup/loader.php @@ -0,0 +1,140 @@ + + 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:m:s", $time)) ); + } + else { + $downloadDisabled = 'disabled'; + } + + return << +function onBackupFileChange(el) { + const fd = new FormData(); + fd.append('file', el.files[0]); + mytinytodo.extensionSettingsAction(el.dataset.extSettingsAction, el.dataset.ext, fd); +} + +
+
{$e('backup.h_make')} +
{$e('backup.d_make', 'db')}
+
+
+
+
$lastBackup   + +
+
+
+
{$e('backup.h_inconsistency')} +
{$e('backup.d_inconsistency')}
+
+
+   +
+
+
+
+
{$e('backup.h_restore')} +
{$e('backup.d_restore')}
+
+
+ +
+
+EOD; + } + + function settingsPageType(): int + { + return 1; //no form buttons + } + + function saveSettings(array $params, ?string &$outMessage): bool + { + return false; + } +/* + static function preferences(): array + { + return [ + 'backupFilePath' => MTTPATH. 'db/backup.xml' + ]; + } +*/ + static function backupFilePath() + { + //return self::preferences()['backupFilePath']; + return MTTPATH. 'db/backup.xml'; + } + + +}