1<?php 2 3use splitbrain\PHPArchive\Zip; 4 5/** 6 * DokuWiki Plugin archivegenerator (Admin Component) 7 * 8 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html 9 * @author Michael Große <dokuwiki@cosmocode.de> 10 */ 11class admin_plugin_archivegenerator extends DokuWiki_Admin_Plugin 12{ 13 14 protected $generateArchive = false; 15 protected $type = 'full'; 16 protected $base = 'dokuwiki/'; 17 18 /** @inheritdoc */ 19 public function getMenuSort() 20 { 21 return 123; 22 } 23 24 /** @inheritdoc */ 25 public function forAdminOnly() 26 { 27 return true; 28 } 29 30 /** @inheritdoc */ 31 public function handle() 32 { 33 global $INPUT; 34 35 if ($INPUT->bool('isupdate')) $this->type = 'update'; 36 37 if ($INPUT->bool('downloadArchive') && checkSecurityToken()) { 38 $this->sendArchiveAndExit(); 39 } 40 41 if ($INPUT->server->str('REQUEST_METHOD') !== 'POST') { 42 return; 43 } 44 45 46 $sectok = $INPUT->post->str('sectok'); 47 if (!checkSecurityToken($sectok)) { 48 return; 49 } 50 51 // check for email and pass on full archives only 52 if ($this->type == 'full') { 53 $email = $INPUT->post->str('adminMail'); 54 $pass = $INPUT->post->str('adminPass'); 55 56 if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { 57 msg(sprintf($this->getLang('message: email invalid'), hsc($email)), -1); 58 return; 59 } 60 61 if (empty($pass)) { 62 msg($this->getLang('message: password empty'), -1); 63 return; 64 } 65 } 66 67 $this->generateArchive = true; 68 } 69 70 /** @inheritdoc */ 71 public function html() 72 { 73 if (!$this->generateArchive) { 74 $this->downloadView(); 75 76 ptln('<h1>' . $this->getLang('menu') . '</h1>'); 77 echo $this->locale_xhtml('intro'); 78 } else { 79 ptln('<h1>' . $this->getLang('menu') . '</h1>'); 80 try { 81 if ($this->type == 'full') { 82 $this->generateArchive(); 83 } else { 84 $this->generateUpdateArchive(); 85 } 86 return; 87 } catch (\splitbrain\PHPArchive\ArchiveIOException $e) { 88 msg(hsc($e->getMessage()), -1); 89 } 90 } 91 $this->showFullForm(); 92 $this->showUpdateForm(); 93 } 94 95 /** 96 * Send the existing wiki archive file and exit 97 */ 98 protected function sendArchiveAndExit() 99 { 100 global $conf; 101 if ($this->type == 'full') { 102 $persistentArchiveFN = $conf['tmpdir'] . '/archivegenerator/archive.zip'; 103 } else { 104 $persistentArchiveFN = $conf['tmpdir'] . '/archivegenerator/archive-update.zip'; 105 } 106 header('Content-Type: application/zip'); 107 header('Content-Disposition: attachment; filename="archive.zip"'); 108 http_sendfile($persistentArchiveFN); 109 readfile($persistentArchiveFN); 110 exit(); 111 } 112 113 /** 114 * Build the archive based on the existing wiki 115 * 116 * @throws \splitbrain\PHPArchive\ArchiveIOException 117 */ 118 protected function generateArchive() 119 { 120 global $conf; 121 $this->log('info', $this->getLang('message: starting')); 122 $tmpArchiveFN = $conf['tmpdir'] . '/archivegenerator/archive_new.zip'; 123 $archive = $this->createZipArchive($tmpArchiveFN); 124 set_time_limit(0); 125 $this->addDirToArchive($archive, '.', false); 126 $this->addDirToArchive($archive, 'inc'); 127 $this->addDirToArchive($archive, 'bin'); 128 $this->addDirToArchive($archive, 'vendor'); 129 $this->addDirToArchive($archive, 'conf', true, '^' . $this->base . 'conf/(users\.auth\.php|acl\.auth\.php)$'); 130 $this->addUsersAuthToArchive($archive); 131 $this->addACLToArchive($archive); 132 $this->addDirToArchive($archive, 'lib', true, '^' . $this->base . 'lib/plugins$'); 133 $this->addDirToArchive($archive, 'lib/plugins', true, $this->buildSkipPluginRegex()); 134 $this->addDirToArchive($archive, 'data/pages'); 135 $this->addDirToArchive($archive, 'data/meta', true, '\.changes(\.trimmed)?$'); 136 $this->addDirToArchive($archive, 'data/media'); 137 $this->addDirToArchive($archive, 'data/media_meta', true, '\.changes$'); 138 $this->addDirToArchive($archive, 'data/index'); 139 140 $this->addEmptyDirToArchive($archive, 'data/attic'); 141 $this->addEmptyDirToArchive($archive, 'data/cache'); 142 $this->addEmptyDirToArchive($archive, 'data/log'); 143 $this->addEmptyDirToArchive($archive, 'data/locks'); 144 $this->addEmptyDirToArchive($archive, 'data/tmp'); 145 $this->addEmptyDirToArchive($archive, 'data/media_attic'); 146 147 $archive->close(); 148 $this->log('info', $this->getLang('message: adding data done')); 149 150 $persistentArchiveFN = $conf['tmpdir'] . '/archivegenerator/archive.zip'; 151 io_rename($tmpArchiveFN, $persistentArchiveFN); 152 153 $href = $this->getDownloadLinkHref(); 154 $link = "<a href=\"$href\">" . $this->getLang('link: download now') . '</a>'; 155 $this->log('success', $this->getLang('message: done') . ' ' . $link); 156 157 // try a redirect to self 158 ptln('<script type="text/javascript">window.location.href=\'' . $this->getSelfRedirect() . '\';</script>'); 159 } 160 161 /** 162 * Build an update archive based on the existing wiki 163 * 164 * @throws \splitbrain\PHPArchive\ArchiveIOException 165 */ 166 protected function generateUpdateArchive() 167 { 168 global $conf; 169 $this->log('info', $this->getLang('message: starting')); 170 $tmpArchiveFN = $conf['tmpdir'] . '/archivegenerator/archive-update_new.zip'; 171 $archive = $this->createZipArchive($tmpArchiveFN); 172 set_time_limit(0); 173 $this->addDirToArchive($archive, '.', false); 174 $this->addDirToArchive($archive, 'inc'); 175 $this->addDirToArchive($archive, 'bin'); 176 $this->addDirToArchive($archive, 'vendor'); 177 $this->addDirToArchive($archive, 'conf', true, '^' . $this->base . 'conf/(users\.auth\.php|acl\.auth\.php|.*local\.(php|conf))$'); 178 $this->addDirToArchive($archive, 'lib', true, '^' . $this->base . 'lib/plugins$'); 179 $this->addDirToArchive($archive, 'lib/plugins', true, $this->buildSkipPluginRegex()); 180 181 $this->addEmptyDirToArchive($archive, 'data/pages'); 182 $this->addEmptyDirToArchive($archive, 'data/media'); 183 $this->addEmptyDirToArchive($archive, 'data/index'); 184 $this->addEmptyDirToArchive($archive, 'data/media_meta'); 185 $this->addEmptyDirToArchive($archive, 'data/meta'); 186 $this->addEmptyDirToArchive($archive, 'data/attic'); 187 $this->addEmptyDirToArchive($archive, 'data/cache'); 188 $this->addEmptyDirToArchive($archive, 'data/log'); 189 $this->addEmptyDirToArchive($archive, 'data/locks'); 190 $this->addEmptyDirToArchive($archive, 'data/tmp'); 191 $this->addEmptyDirToArchive($archive, 'data/media_attic'); 192 193 $archive->close(); 194 $this->log('info', $this->getLang('message: adding data done')); 195 196 $persistentArchiveFN = $conf['tmpdir'] . '/archivegenerator/archive-update.zip'; 197 io_rename($tmpArchiveFN, $persistentArchiveFN); 198 199 $href = $this->getDownloadLinkHref('update'); 200 $link = "<a href=\"$href\">" . $this->getLang('link: download now') . '</a>'; 201 $this->log('success', $this->getLang('message: done') . ' ' . $link); 202 203 // try a redirect to self 204 ptln('<script type="text/javascript">window.location.href=\'' . $this->getSelfRedirect() . '\';</script>'); 205 } 206 207 /** 208 * Build a regex for the plugins to skip, relative to the DokuWiki root 209 * 210 * @return string 211 */ 212 protected function buildSkipPluginRegex() 213 { 214 $list = array_map('trim', explode(',', $this->getConf('pluginsToIgnore'))); 215 return '^' . $this->base . 'lib/plugins/(' . implode('|', $list) . ')$'; 216 } 217 218 /** 219 * Generate a href for a link to download the archive 220 * 221 * @param string $type 222 * @return string 223 */ 224 protected function getDownloadLinkHref($type = 'full') 225 { 226 global $ID; 227 return wl($ID, [ 228 'do' => 'admin', 229 'page' => 'archivegenerator', 230 'downloadArchive' => 1, 231 'sectok' => getSecurityToken(), 232 'isupdate' => (int)($type == 'update'), 233 ]); 234 } 235 236 /** 237 * Generate the link to the admin page itself 238 * 239 * @return string 240 */ 241 protected function getSelfRedirect() 242 { 243 global $ID; 244 return wl($ID, [ 245 'do' => 'admin', 246 'page' => 'archivegenerator', 247 ], false, '&'); 248 } 249 250 /** 251 * Add an empty directory to the archive. 252 * 253 * The directory will contain a dummy .keep file. 254 * 255 * @param Zip $archive 256 * @param string $directory path of the directory to add relative to the dokuwiki root 257 * 258 * @throws \splitbrain\PHPArchive\ArchiveIOException 259 */ 260 protected function addEmptyDirToArchive(Zip $archive, $directory) 261 { 262 $this->log('info', sprintf($this->getLang('message: create empty dir'), $directory)); 263 $dirPath = $this->base . $directory . '/.keep'; 264 $archive->addData($dirPath, ''); 265 } 266 267 /** 268 * Create a users.auth.php file with a single admin user 269 * 270 * @param Zip $archive 271 * 272 * @throws \splitbrain\PHPArchive\ArchiveIOException 273 */ 274 protected function addUsersAuthToArchive(Zip $archive) 275 { 276 global $INPUT; 277 278 $email = $INPUT->post->str('adminMail'); 279 $pass = $INPUT->post->str('adminPass'); 280 281 $this->log('info', $this->getLang('message: create users')); 282 $authFile = ' 283# users.auth.php 284# <?php exit()?> 285# Don\'t modify the lines above 286# 287# Userfile 288# 289# Format: 290# 291# login:passwordhash:Real Name:email:groups,comma,separated 292 293 '; 294 295 $pwHash = auth_cryptPassword($pass); 296 $adminLine = "admin:$pwHash:Administrator:$email:user,admin\n"; 297 $archive->addData($this->base . 'conf/users.auth.php', $authFile . $adminLine); 298 } 299 300 /** 301 * Create an acl.auth.php file that allows reading only for logged-in users 302 * 303 * @param Zip $archive 304 * 305 * @throws \splitbrain\PHPArchive\ArchiveIOException 306 */ 307 protected function addACLToArchive(Zip $archive) 308 { 309 $this->log('info', $this->getLang('message: create acl')); 310 $aclFileContents = '# acl.auth.php 311# <?php exit()?> 312* @ALL 0 313* @users 1 314'; 315 $archive->addData($this->base . 'conf/acl.auth.php', $aclFileContents); 316 } 317 318 /** 319 * Create the archive file 320 * 321 * @return Zip 322 * @throws \splitbrain\PHPArchive\ArchiveIOException 323 */ 324 protected function createZipArchive($archiveFN) 325 { 326 $this->log('info', sprintf($this->getLang('message: create zip archive'), hsc($archiveFN))); 327 io_makeFileDir($archiveFN); 328 $zip = new Zip(); 329 $zip->create($archiveFN); 330 331 return $zip; 332 } 333 334 /** 335 * Add the contents of an directory to the archive 336 * 337 * @param Zip $archive 338 * @param string $srcDir the directory relative to the dokuwiki root 339 * @param bool $recursive whether to add subdirectories as well 340 * @param null|string $skipRegex files and directories matching this regex will be ignored. no delimiters 341 * 342 * @throws \splitbrain\PHPArchive\ArchiveIOException 343 */ 344 protected function addDirToArchive(Zip $archive, $srcDir, $recursive = true, $skipRegex = null) 345 { 346 $message = []; 347 $message[] = sprintf($this->getLang('message: add files in dir'), hsc($srcDir . '/')); 348 if ($recursive) { 349 $message[] = $this->getLang('message: recursive'); 350 } 351 if ($skipRegex) { 352 $message[] = sprintf($this->getLang('message: skipping files'), hsc($skipRegex)); 353 } 354 $message[] .= '...'; 355 $this->log('info', implode(' ', $message)); 356 $this->addFilesToArchive(DOKU_INC . $srcDir, $archive, !$recursive, $skipRegex); 357 } 358 359 /** 360 * Recursive method to add files and directories to a archive 361 * 362 * It will report large files that might cause the process to fail. 363 * 364 * @param string $source 365 * @param Zip $archive 366 * @param bool $filesOnly 367 * @param null $skipRegex 368 * 369 * @return bool 370 * @throws \splitbrain\PHPArchive\ArchiveIOException 371 */ 372 protected function addFilesToArchive($source, Zip $archive, $filesOnly = false, $skipRegex = null) 373 { 374 // Simple copy for a file 375 if (is_file($source)) { 376 if (filesize($source) > 50 * 1024 * 1024) { 377 $this->log('warning', sprintf($this->getLang('message: file is large'), 378 hsc($source)) . ' ' . filesize_h(filesize($source))); 379 } 380 381 try { 382 $archive->addFile($source, $this->getDWPathName($source)); 383 } catch (\splitbrain\PHPArchive\ArchiveIOException $e) { 384 $this->log('error', hsc($e->getMessage())); 385 throw $e; 386 } 387 return true; 388 } 389 390 // Loop through the folder 391 $dir = dir($source); 392 while (false !== $entry = $dir->read()) { 393 if (in_array($entry, ['.', '..', '.git', 'node_modules'])) { 394 continue; 395 } 396 $srcFN = "$source/$entry"; 397 398 if ($skipRegex && preg_match("#$skipRegex#", $this->getDWPathName($srcFN))) { 399 continue; 400 } 401 402 if (is_dir($srcFN) && $filesOnly) { 403 continue; 404 } 405 406 $copyOK = $this->addFilesToArchive($srcFN, $archive, $filesOnly, $skipRegex); 407 if ($copyOK === false) { 408 return false; 409 } 410 } 411 412 // Clean up 413 $dir->close(); 414 return true; 415 } 416 417 /** 418 * Get the filepath relative to the dokuwiki root 419 * 420 * @param $filepath 421 * 422 * @return string 423 */ 424 protected function getDWPathName($filepath) 425 { 426 return $this->base . substr($filepath, strlen(DOKU_INC)); 427 } 428 429 /** 430 * Display the download info 431 */ 432 protected function downloadView() 433 { 434 global $conf; 435 436 $fullArchiveFN = $conf['tmpdir'] . '/archivegenerator/archive.zip'; 437 $updateArchiveFN = $conf['tmpdir'] . '/archivegenerator/archive-update.zip'; 438 if (!file_exists($fullArchiveFN) && !file_exists($updateArchiveFN)) return; 439 440 ptln('<h1>' . $this->getLang('label: download') . '</h1>'); 441 442 if (file_exists($fullArchiveFN)) { 443 $mtime = dformat(filemtime($fullArchiveFN)); 444 $href = $this->getDownloadLinkHref(); 445 ptln('<p>'); 446 ptln('<b>' . $this->getLang('label: full archive') . '</b><br>'); 447 ptln(sprintf($this->getLang('message: archive exists'), $mtime)); 448 ptln("<a href=\"$href\">" . $this->getLang('link: download now') . '</a>'); 449 ptln('</p>'); 450 } 451 452 if (file_exists($updateArchiveFN)) { 453 $mtime = dformat(filemtime($updateArchiveFN)); 454 $href = $this->getDownloadLinkHref('update'); 455 ptln('<p>'); 456 ptln('<b>' . $this->getLang('label: update archive') . '</b><br>'); 457 ptln(sprintf($this->getLang('message: archive exists'), $mtime)); 458 ptln("<a href=\"$href\">" . $this->getLang('link: download now') . '</a>'); 459 ptln('</p>'); 460 } 461 } 462 463 /** 464 * Show the form to create a full archive 465 */ 466 protected function showFullForm() 467 { 468 $form = new \dokuwiki\Form\Form(); 469 $form->addFieldsetOpen($this->getLang('label: full archive')); 470 471 $adminMailInput = $form->addTextInput('adminMail', $this->getLang('label: admin mail')); 472 $adminMailInput->addClass('block'); 473 $adminMailInput->attrs(['type' => 'email', 'required' => '1']); 474 475 $adminPassInput = $form->addPasswordInput('adminPass', $this->getLang('label: admin pass')); 476 $adminPassInput->addClass('block'); 477 $adminPassInput->attr('required', 1); 478 479 $form->addButton('submit', $this->getLang('button: generate archive')); 480 481 $form->addFieldsetClose(); 482 echo $form->toHTML(); 483 } 484 485 /** 486 * Show the form to create a full archive 487 */ 488 protected function showUpdateForm() 489 { 490 $form = new \dokuwiki\Form\Form(); 491 $form->addFieldsetOpen($this->getLang('label: update archive')); 492 493 $form->setHiddenField('isupdate', '1'); 494 495 $form->addButton('submit', $this->getLang('button: generate archive')); 496 497 $form->addFieldsetClose(); 498 echo $form->toHTML(); 499 } 500 501 /** 502 * Print a message to the user, prefixes the time since the first message 503 * 504 * This adds whitespace padding to force the message being printed immediately. 505 * 506 * @param string $level can be 'error', 'warning' or 'info' 507 * @param string $message 508 */ 509 protected function log($level, $message) 510 { 511 static $startTime; 512 if (!$startTime) { 513 $startTime = microtime(true); 514 } 515 516 $time = round(microtime(true) - $startTime, 3); 517 $timedMessage = sprintf($this->getLang('seconds'), $time) . ': ' . $message; 518 519 switch ($level) { 520 case 'error': 521 $msgLVL = -1; 522 break; 523 case 'warning': 524 $msgLVL = 2; 525 break; 526 case 'success': 527 $msgLVL = 1; 528 break; 529 default: 530 $msgLVL = 0; 531 } 532 533 msg($timedMessage, $msgLVL); 534 echo str_repeat(' ', 16 * 1024); 535 536 /** @noinspection MissingOrEmptyGroupStatementInspection */ 537 /** @noinspection LoopWhichDoesNotLoopInspection */ 538 /** @noinspection PhpStatementHasEmptyBodyInspection */ 539 while (@ob_end_flush()) { 540 }; 541 flush(); 542 } 543} 544 545