1<?php 2 3use splitbrain\PHPArchive\Tar; 4 5/** 6 * Backup Tool for DokuWiki 7 * 8 * @license GPL 2 (http://www.gnu.org/licenses/gpl.html) 9 * @author Terence J. Grant<tjgrant@tatewake.com> 10 * @author Andreas Wagner <andreas.wagner@em.uni-frankfurt.de> 11 * @author Andreas Gohr <gohr@cosmocode.de> 12 */ 13class admin_plugin_backup extends DokuWiki_Admin_Plugin 14{ 15 protected $prefFile = DOKU_CONF . 'backup.json'; 16 protected $filters = null; 17 18 protected function isRunningWindows() 19 { 20 return (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') ? true : false; 21 } 22 23 public function btRemoveFiles($dir, $startString) 24 { 25 if (is_dir($dir)) { 26 $objects = scandir($dir); 27 28 foreach ($objects as $object) { 29 if ($object != "." && $object != ".." && substr($object, 0, strlen($startString)) === $startString) { 30 if (!is_dir($dir. DIRECTORY_SEPARATOR .$object) || is_link($dir."/".$object)) { 31 unlink($dir. DIRECTORY_SEPARATOR .$object); 32 } 33 } 34 } 35 } 36 } 37 38 /** @inheritdoc */ 39 public function handle() 40 { 41 global $INPUT; 42 if ($INPUT->post->has('pref') && checkSecurityToken()) { 43 $this->savePreferences($INPUT->post->arr('pref')); 44 } 45 } 46 47 /** 48 * output appropriate html 49 */ 50 public function html() 51 { 52 global $INPUT; 53 54 echo '<div class="plugin_backup">'; 55 56 if ($INPUT->post->bool('backup')) { 57 $this->removeMediaAtticBackups(); 58 $this->runBackup(); 59 } else { 60 echo '<h1>' . $this->getLang('menu') . '</h1>'; 61 62 if ($this->isRunningWindows()) { 63 msg($this->getLang('windows-msg'), 2); 64 } 65 66 if ($this->isRunningWindows()) { 67 echo '<div class="bt-warning" style="display: block;">'; 68 echo $this->locale_xhtml('windows'); 69 echo '<button type="button" class="collapsible">I understand</button>'; 70 echo '</div>'; 71 72 echo '<div class="bt-content" style="display: none;">'; 73 } else { 74 echo '<div>'; 75 } 76 77 echo $this->locale_xhtml('intro'); 78 79 echo $this->getForm(); 80 81 $this->listBackups(); 82 83 echo $this->locale_xhtml('donate'); 84 echo '</div>'; 85 } 86 87 echo '</div>'; 88 } 89 90 /** 91 * Lists the 5 most recent backups if any. 92 */ 93 protected function listBackups() 94 { 95 global $ID; 96 $ns = $this->getConf('backupnamespace'); 97 $link = wl($ID, ['do' => 'media', 'ns' => $ns]); 98 99 echo '<div class="recent">'; 100 101 $backups = glob(dirname(mediaFN("$ns:foo")) . '/*.tar*'); 102 rsort($backups); 103 $backups = array_slice($backups, 0, 5); 104 if ($backups) { 105 echo '<h2>' . $this->getLang('recent') . '</h2>'; 106 echo '<ul>'; 107 foreach ($backups as $full) { 108 $backup = basename($full); 109 $url = ml("$ns:$backup"); 110 echo '<li><div class="li">'; 111 echo '<a href="' . $url . '">' . $backup . '</a> '; 112 echo filesize_h(filesize($full)); 113 echo ' '; 114 echo dformat(filemtime($full), '%f'); 115 echo '</div></li>'; 116 } 117 echo '</ul>'; 118 } 119 120 echo '<p>' . sprintf($this->getLang('medians'), $ns, $link) . '</p>'; 121 echo '</div>'; 122 } 123 124 protected function removeMediaAtticBackups() 125 { 126 try { 127 global $conf; 128 129 $self = fullpath(dirname(mediaFN($this->getConf('backupnamespace') . ':foo'))); 130 $targetdir = $conf['mediaolddir'] . '/' . $this->stripPrefix($self, fullpath(dirname(mediaFN($conf['savedir'])))); 131 132 $this->btRemoveFiles($targetdir, 'dw-backup-'); 133 } catch (Exception $e) { 134 } 135 } 136 137 /** 138 * Runs the backup process with XHTML output 139 */ 140 protected function runBackup() 141 { 142 echo '<h1>' . $this->getLang('menu') . '</h1>'; 143 echo '<p class="bt-running">'; 144 echo hsc($this->getLang('running')); 145 echo ' '; 146 echo '<img src="' . DOKU_BASE . 'lib/plugins/backup/spinner.gif" alt="…" />'; 147 echo '</p>'; 148 149 $id = $this->createBackupID(); 150 $fn = mediaFN($id); 151 try { 152 echo '<div class="log">'; 153 tpl_flush(); 154 $this->createBackup($fn, $this->loadPreferences(), [$this, 'logXHTML']); 155 echo '</div>'; 156 msg(sprintf($this->getLang('success'), ml($id), $id), 1); 157 } catch (\splitbrain\PHPArchive\ArchiveIOException $e) { 158 echo '</div>'; // close the log wrapping 159 msg('Backup failed. ' . $e->getMessage(), -1); 160 @unlink($fn); 161 } 162 163 echo '<script>document.getElementsByClassName(\'bt-running\')[0].style.display=\'none\';</script>'; 164 } 165 166 /** 167 * The logger to output the progress of the backup 168 * 169 * We want the filenames a little bit less prominent, so we handle those differently 170 * 171 * @param string $msg 172 * @param int $level 173 */ 174 protected function logXHTML($msg, $level = 0) 175 { 176 if ($level === -1 || $level === 1) { 177 msg(hsc($msg), $level); 178 } else { 179 echo '<div>' . hsc($msg) . '</div>'; 180 } 181 ob_flush(); 182 flush(); 183 } 184 185 /** 186 * Create the preference form 187 * 188 * @return string 189 */ 190 protected function getForm() 191 { 192 global $ID; 193 $form = new \dokuwiki\Form\Form([ 194 'method' => 'POST', 195 'action' => wl($ID, ['do' => 'admin', 'page' => 'backup'], false, '&') 196 ]); 197 $form->addFieldsetOpen($this->getLang('components')); 198 199 $prefs = $this->loadPreferences(); 200 foreach ($prefs as $pref => $val) { 201 $label = $this->getLang('bt_' . $pref); 202 if (!$label) { 203 continue; 204 } // unknown pref, skip it 205 206 $form->setHiddenField("pref[$pref]", '0'); 207 $cb = $form->addCheckbox("pref[$pref]", $label)->useInput(false)->addClass('block'); 208 if ($val) { 209 $cb->attr('checked', 'checked'); 210 } 211 } 212 213 $form->addButton('backup', $this->getLang('bt_create_backup')); 214 return $form->toHTML(); 215 } 216 217 /** 218 * Get the currently saved preferences 219 * 220 * @return array 221 */ 222 protected function loadPreferences() 223 { 224 $prefs = [ 225 'config' => 1, 226 'pages' => 1, 227 'revisions' => 1, 228 'meta' => 1, 229 'media' => 1, 230 'mediarevs' => 0, 231 'mediameta' => 1, 232 'templates' => 0, 233 'plugins' => 0 234 ]; 235 // load and merge saved preferences 236 if (file_exists($this->prefFile)) { 237 $more = json_decode(io_readFile($this->prefFile, false), true); 238 $prefs = array_merge($prefs, $more); 239 } 240 241 return $prefs; 242 } 243 244 /** 245 * Store the backup preferences 246 * 247 * @param array $prefs 248 */ 249 protected function savePreferences($prefs) 250 { 251 $prefs = array_map('intval', $prefs); 252 io_saveFile($this->prefFile, json_encode($prefs, JSON_PRETTY_PRINT)); 253 } 254 255 /** 256 * Generate a new unique backup name 257 * 258 * @return string 259 */ 260 protected function createBackupID() 261 { 262 $tarfilename = 'dw-backup-' . date('Ymd-His') . '.tar'; 263 if (extension_loaded('bz2')) { 264 $tarfilename .= '.bz2'; 265 } elseif (extension_loaded('gz')) { 266 $tarfilename .= '.gz'; 267 } 268 return cleanID($this->getConf('backupnamespace') . ':' . $tarfilename); 269 } 270 271 /** 272 * Create the backup 273 * 274 * @param string $fn Filename of the backup archive 275 * @param array $prefs 276 * @param Callable $logger A method compatible to DokuWiki's msg() 277 * @throws \splitbrain\PHPArchive\ArchiveIOException 278 */ 279 protected function createBackup($fn, $prefs, $logger) 280 { 281 @set_time_limit(0); 282 io_mkdir_p(dirname($fn)); 283 $tar = new Tar(); 284 $tar->create($fn); 285 286 foreach ($prefs as $pref => $val) { 287 if (!$val) { 288 continue; 289 } 290 291 $cmd = [$this, 'backup' . ucfirst($pref)]; 292 if (is_callable($cmd)) { 293 $cmd($tar, $logger); 294 } else { 295 $logger('Can\'t call ' . $cmd[1], -1); 296 } 297 } 298 299 $tar->close(); 300 } 301 302 /** 303 * Adds the given directory recursively to the tar archive 304 * 305 * @param Tar $tar 306 * @param string $dir The original directory 307 * @param string $as The directory name to use in the archive 308 * @param Callable|null $logger msg() compatible logger 309 * @param Callable|null $filter a filter method, returns true for all files to add 310 * @throws \splitbrain\PHPArchive\ArchiveCorruptedException 311 * @throws \splitbrain\PHPArchive\ArchiveIOException 312 */ 313 protected function addDirectoryToTar(Tar $tar, $dir, $as, $logger = null, $filter = null) 314 { 315 $dir = fullpath($dir); 316 $ri = new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS); 317 $rii = new RecursiveIteratorIterator($ri, RecursiveIteratorIterator::SELF_FIRST); 318 319 foreach ($rii as $path => $info) { 320 $file = $this->stripPrefix($path, $dir); 321 $file = $as . '/' . $file; 322 323 // custom filter: 324 if ($filter !== null && !$filter($file)) { 325 continue; 326 } 327 if (!$this->defaultFilter($file)) { 328 continue; 329 } 330 331 if ($logger !== null) { 332 $logger($file); 333 } 334 $tar->addFile($path, $file); 335 } 336 } 337 338 /** 339 * Checks the default filters against the given backup path 340 * 341 * We also filter .git directories 342 * 343 * @param string $path the backup path 344 * @return bool true if the file should be backed up, false if not 345 */ 346 protected function defaultFilter($path) 347 { 348 if ($this->filters === null) { 349 $this->filters = explode("\n", $this->getConf('filterdirs')); 350 $this->filters = array_map('trim', $this->filters); 351 $this->filters = array_filter($this->filters); 352 } 353 354 if (strpos($path, '/.git') !== false) { 355 return false; 356 } 357 358 foreach ($this->filters as $filter) { 359 if (strpos($path, $filter) === 0) { 360 return false; 361 } 362 } 363 364 return true; 365 } 366 367 /** 368 * Strip the given prefix from the directory 369 * 370 * @param string $dir 371 * @param string $prefix 372 * @return string 373 */ 374 protected function stripPrefix($dir, $prefix) 375 { 376 if (strpos($dir, $prefix) === 0) { 377 $dir = substr($dir, strlen($prefix)); 378 } 379 return ltrim($dir, '/'); 380 } 381 382 // region backup components 383 384 /** 385 * Backup the config files 386 * 387 * @param Tar $tar 388 * @param Callable $logger 389 * @throws \splitbrain\PHPArchive\ArchiveCorruptedException 390 * @throws \splitbrain\PHPArchive\ArchiveIOException 391 */ 392 protected function backupConfig(Tar $tar, $logger) 393 { 394 $this->addDirectoryToTar($tar, DOKU_CONF, 'conf', $logger, function ($path) { 395 return !preg_match('/\.(dist|example|bak)/', $path); 396 }); 397 // we consider the preload a config file 398 if (file_exists(DOKU_INC . 'inc/preload.php')) { 399 $tar->addFile(DOKU_INC . 'inc/preload.php', 'inc/preload.php'); 400 } 401 } 402 403 /** 404 * Backup the pages 405 * 406 * @param Tar $tar 407 * @param Callable $logger 408 * @throws \splitbrain\PHPArchive\ArchiveCorruptedException 409 * @throws \splitbrain\PHPArchive\ArchiveIOException 410 */ 411 protected function backupPages(Tar $tar, $logger) 412 { 413 global $conf; 414 $this->addDirectoryToTar($tar, $conf['datadir'], 'data/pages', $logger); 415 } 416 417 /** 418 * Backup the page revisions 419 * 420 * @param Tar $tar 421 * @param Callable $logger 422 * @throws \splitbrain\PHPArchive\ArchiveCorruptedException 423 * @throws \splitbrain\PHPArchive\ArchiveIOException 424 */ 425 protected function backupRevisions(Tar $tar, $logger) 426 { 427 global $conf; 428 $this->addDirectoryToTar($tar, $conf['olddir'], 'data/attic', $logger); 429 } 430 431 /** 432 * Backup the meta files 433 * 434 * @param Tar $tar 435 * @param Callable $logger 436 * @throws \splitbrain\PHPArchive\ArchiveCorruptedException 437 * @throws \splitbrain\PHPArchive\ArchiveIOException 438 */ 439 protected function backupMeta(Tar $tar, $logger) 440 { 441 global $conf; 442 $this->addDirectoryToTar($tar, $conf['metadir'], 'data/meta', $logger); 443 } 444 445 /** 446 * Backup the media files 447 * 448 * @param Tar $tar 449 * @param Callable $logger 450 * @throws \splitbrain\PHPArchive\ArchiveCorruptedException 451 * @throws \splitbrain\PHPArchive\ArchiveIOException 452 */ 453 protected function backupMedia(Tar $tar, $logger) 454 { 455 global $conf; 456 457 // figure out what our backup folder would be called within the backup 458 $media = fullpath(dirname(mediaFN('foo'))); 459 $self = fullpath(dirname(mediaFN($this->getConf('backupnamespace') . ':foo'))); 460 $relself = 'data/media/' . $this->stripPrefix($self, $media); 461 462 $this->addDirectoryToTar($tar, $conf['mediadir'], 'data/media', $logger, function ($path) use ($relself) { 463 // skip our own backups 464 return (strpos($path, $relself) !== 0); 465 }); 466 } 467 468 /** 469 * Backup the media revisions 470 * 471 * @param Tar $tar 472 * @param Callable $logger 473 * @throws \splitbrain\PHPArchive\ArchiveCorruptedException 474 * @throws \splitbrain\PHPArchive\ArchiveIOException 475 */ 476 protected function backupMediarevs(Tar $tar, $logger) 477 { 478 global $conf; 479 480 // figure out what our backup folder would be called within the backup 481 $media = fullpath(dirname(mediaFN('foo'))); 482 $self = fullpath(dirname(mediaFN($this->getConf('backupnamespace') . ':foo'))); 483 $relself = 'data/media_attic/' . $this->stripPrefix($self, $media); 484 485 $this->addDirectoryToTar($tar, $conf['mediaolddir'], 'data/media_attic', $logger, function ($path) use ($relself) { 486 // skip our own backups 487 return (strpos($path, $relself) !== 0); 488 }); 489 } 490 491 /** 492 * Backup the media meta info 493 * 494 * @param Tar $tar 495 * @param Callable $logger 496 * @throws \splitbrain\PHPArchive\ArchiveCorruptedException 497 * @throws \splitbrain\PHPArchive\ArchiveIOException 498 */ 499 protected function backupMediameta(Tar $tar, $logger) 500 { 501 global $conf; 502 503 // figure out what our backup folder would be called within the backup 504 $media = fullpath(dirname(mediaFN('foo'))); 505 $self = fullpath(dirname(mediaFN($this->getConf('backupnamespace') . ':foo'))); 506 $relself = 'data/media_meta/' . $this->stripPrefix($self, $media); 507 508 $this->addDirectoryToTar($tar, $conf['mediametadir'], 'data/media_meta', $logger, function ($path) use ($relself) { 509 // skip our own backups 510 return (strpos($path, $relself) !== 0); 511 }); 512 } 513 514 /** 515 * Backup the templates 516 * 517 * @param Tar $tar 518 * @param Callable $logger 519 * @throws \splitbrain\PHPArchive\ArchiveCorruptedException 520 * @throws \splitbrain\PHPArchive\ArchiveIOException 521 */ 522 protected function backupTemplates(Tar $tar, $logger) 523 { 524 // FIXME skip builtin ones 525 $this->addDirectoryToTar($tar, DOKU_INC . 'lib/tpl', 'lib/tpl', $logger); 526 } 527 528 /** 529 * Backup the plugins 530 * 531 * @param Tar $tar 532 * @param Callable $logger 533 * @throws \splitbrain\PHPArchive\ArchiveCorruptedException 534 * @throws \splitbrain\PHPArchive\ArchiveIOException 535 */ 536 protected function backupPlugins(Tar $tar, $logger) 537 { 538 // FIXME skip builtin ones 539 $this->addDirectoryToTar($tar, DOKU_INC . 'lib/plugins', 'lib/plugins', $logger); 540 } 541 542 // endregion 543} 544