mirror of
https://github.com/maxpozdeev/mytinytodo.git
synced 2026-03-11 08:55:27 +00:00
+ add backup/restore feature as extension
This commit is contained in:
parent
149b554676
commit
1a75eec5dc
10 changed files with 970 additions and 0 deletions
1
src/ext/backup/.htaccess
Normal file
1
src/ext/backup/.htaccess
Normal file
|
|
@ -0,0 +1 @@
|
|||
deny from all
|
||||
190
src/ext/backup/class.backup.php
Normal file
190
src/ext/backup/class.backup.php
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
<?php declare(strict_types=1);
|
||||
|
||||
/*
|
||||
This file is a part of myTinyTodo.
|
||||
(C) Copyright 2023 Max Pozdeev <maxpozdeev@gmail.com>
|
||||
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, "<?xml version=\"1.0\" encoding=\"utf-8\"?>\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 . "</$tag>\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"));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
126
src/ext/backup/class.check.php
Normal file
126
src/ext/backup/class.check.php
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
<?php declare(strict_types=1);
|
||||
|
||||
/*
|
||||
This file is a part of myTinyTodo.
|
||||
(C) Copyright 2023 Max Pozdeev <maxpozdeev@gmail.com>
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
148
src/ext/backup/class.controller.php
Normal file
148
src/ext/backup/class.controller.php
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
<?php declare(strict_types=1);
|
||||
|
||||
/*
|
||||
This file is a part of myTinyTodo.
|
||||
(C) Copyright 2023 Max Pozdeev <maxpozdeev@gmail.com>
|
||||
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 = "<pre>". htmlspecialchars($check->report). "</pre>";
|
||||
$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"),
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
74
src/ext/backup/class.download.php
Normal file
74
src/ext/backup/class.download.php
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<?php declare(strict_types=1);
|
||||
|
||||
/*
|
||||
This file is a part of myTinyTodo.
|
||||
(C) Copyright 2023 Max Pozdeev <maxpozdeev@gmail.com>
|
||||
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();
|
||||
}
|
||||
|
||||
}
|
||||
251
src/ext/backup/class.restore.php
Normal file
251
src/ext/backup/class.restore.php
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
<?php declare(strict_types=1);
|
||||
|
||||
/*
|
||||
This file is a part of myTinyTodo.
|
||||
(C) Copyright 2023 Max Pozdeev <maxpozdeev@gmail.com>
|
||||
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?
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
6
src/ext/backup/extension.json
Normal file
6
src/ext/backup/extension.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"bundleId": "backup",
|
||||
"name": "Backup",
|
||||
"version": "0.9",
|
||||
"description": "Backup"
|
||||
}
|
||||
17
src/ext/backup/lang/en.json
Normal file
17
src/ext/backup/lang/en.json
Normal file
|
|
@ -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…"
|
||||
}
|
||||
17
src/ext/backup/lang/ru.json
Normal file
17
src/ext/backup/lang/ru.json
Normal file
|
|
@ -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": "Восстановить…"
|
||||
}
|
||||
140
src/ext/backup/loader.php
Normal file
140
src/ext/backup/loader.php
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
<?php declare(strict_types=1);
|
||||
|
||||
/*
|
||||
This file is a part of myTinyTodo.
|
||||
(C) Copyright 2023 Max Pozdeev <maxpozdeev@gmail.com>
|
||||
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 <<<EOD
|
||||
$warning
|
||||
<script>
|
||||
function onBackupFileChange(el) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', el.files[0]);
|
||||
mytinytodo.extensionSettingsAction(el.dataset.extSettingsAction, el.dataset.ext, fd);
|
||||
}
|
||||
</script>
|
||||
<div class="tr">
|
||||
<div class="th"> {$e('backup.h_make')}
|
||||
<div class="descr">{$e('backup.d_make', 'db')}</div>
|
||||
</div>
|
||||
<div class="td">
|
||||
<button type=button data-ext-settings-action="post:makeBackup" data-ext="$ext"> {$e('backup.make')} </button> <br>
|
||||
<br> $lastBackup
|
||||
<button type=button data-ext-settings-action="post:download" data-ext="$ext" $downloadDisabled> {$e('backup.download')} </button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tr">
|
||||
<div class="th"> {$e('backup.h_inconsistency')}
|
||||
<div class="descr">{$e('backup.d_inconsistency')}</div>
|
||||
</div>
|
||||
<div class="td">
|
||||
<button type=button data-ext-settings-action="post:checkInconsistency:file" data-ext="$ext"> {$e('backup.check')} </button>
|
||||
<button type=button data-ext-settings-action="post:repairInconsistency" data-ext="$ext"> {$e('backup.repair')} </button> <br>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tr">
|
||||
<div class="th"> {$e('backup.h_restore')}
|
||||
<div class="descr"> {$e('backup.d_restore')} </div>
|
||||
</div>
|
||||
<div class="td">
|
||||
<label class="mtt-settings-upload-button">
|
||||
<input type="file" name="file" onchange="return onBackupFileChange(this)" data-ext-settings-action="post:restore" data-ext="$ext">
|
||||
{$e('backup.restore')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
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';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
Loading…
Reference in a new issue