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