1<?php 2/** 3 * Site Backup admin plugin for DokuWiki. 4 * 5 * Renders a small form letting an admin pick which parts of the wiki to 6 * include in a tar.gz, builds it on the server side, then streams it to 7 * the browser as a download. Uses DokuWiki's bundled splitbrain/php-archive 8 * (no external dependencies). 9 * 10 * Intentionally admin-only (forAdminOnly() = true). The archive can contain 11 * password hashes (conf/users.auth.php), ACLs, and any credentials stored in 12 * conf/local.php (DB, SMTP, etc.), so treat the download as sensitive. 13 */ 14 15use dokuwiki\Extension\AdminPlugin; 16use dokuwiki\Form\Form; 17use splitbrain\PHPArchive\Tar; 18use splitbrain\PHPArchive\Archive; 19use splitbrain\PHPArchive\ArchiveIOException; 20 21class admin_plugin_sitebackup extends AdminPlugin 22{ 23 /** @var array list of [absolute path, archive-relative path] of files to include */ 24 protected $fileList = []; 25 26 /** @var int total uncompressed size of selected files */ 27 protected $totalBytes = 0; 28 29 public function forAdminOnly() 30 { 31 return true; 32 } 33 34 public function getMenuSort() 35 { 36 return 1000; 37 } 38 39 public function getMenuText($language) 40 { 41 return 'Site Backup'; 42 } 43 44 /** 45 * Dispatch based on the submitted action. 46 * Actions: "preview" (default - show file list + sizes), "download" (stream tar.gz). 47 */ 48 public function handle() 49 { 50 global $INPUT; 51 if (!$INPUT->has('sitebackup_action')) return; 52 if (!checkSecurityToken()) return; 53 54 $action = $INPUT->str('sitebackup_action'); 55 if ($action !== 'preview' && $action !== 'download') return; 56 57 $this->collectFiles(); 58 59 if ($action === 'download') { 60 $this->streamArchive(); 61 // streamArchive exits when successful; if it returns, fall through to html() 62 } 63 // For 'preview', html() will render the file list + a download button. 64 } 65 66 public function html() 67 { 68 global $INPUT; 69 70 echo '<h1>Site Backup</h1>'; 71 echo '<p>Select what to include, click <em>Preview</em> to see the file list and total size, ' 72 . 'then <em>Download</em> to get a tar.gz archive.</p>'; 73 echo '<p style="background:#fff3cd;border:1px solid #ffeeba;padding:8px;border-radius:4px;">' 74 . '<strong>Sensitive content warning.</strong> The archive can contain password hashes ' 75 . '(<code>conf/users.auth.php</code>), ACL rules, and any secrets stored in ' 76 . '<code>conf/local.php</code> (DB, SMTP, API keys). Treat it like a credential.' 77 . '</p>'; 78 79 $this->renderForm(); 80 81 if ($this->fileList) { 82 $this->renderPreview(); 83 } 84 } 85 86 /* ----------------------------------------------------------------- * 87 * Form 88 * ----------------------------------------------------------------- */ 89 90 protected function renderForm() 91 { 92 global $INPUT; 93 94 // Read current selections (defaulting to "everything sensible" on first load). 95 $hasSubmitted = $INPUT->has('sitebackup_action'); 96 $defaults = [ 97 'pages' => true, 98 'media' => true, 99 'meta' => true, 100 'media_meta' => true, 101 'attic' => false, 102 'media_attic' => false, 103 'index' => false, 104 'conf' => true, 105 'plugins' => true, 106 'tpl' => true, 107 ]; 108 $sel = []; 109 foreach ($defaults as $k => $def) { 110 $sel[$k] = $hasSubmitted ? $INPUT->bool('sb_' . $k, false) : $def; 111 } 112 113 $form = new Form(['method' => 'POST', 'id' => 'sitebackup_form']); 114 $form->setHiddenField('do', 'admin'); 115 $form->setHiddenField('page', 'sitebackup'); 116 117 $form->addFieldsetOpen('Wiki content'); 118 $this->addCheckboxRow($form, 'sb_pages', 'Pages (data/pages)', $sel['pages']); 119 $this->addCheckboxRow($form, 'sb_media', 'Media files (data/media)', $sel['media']); 120 $this->addCheckboxRow($form, 'sb_meta', 'Page metadata (data/meta)', $sel['meta']); 121 $this->addCheckboxRow($form, 'sb_media_meta', 'Media metadata (data/media_meta)', $sel['media_meta']); 122 $this->addCheckboxRow($form, 'sb_attic', 'Page revisions (data/attic) - can be large', $sel['attic']); 123 $this->addCheckboxRow($form, 'sb_media_attic', 'Media revisions (data/media_attic)', $sel['media_attic']); 124 $this->addCheckboxRow($form, 'sb_index', 'Search index (data/index) - rebuildable', $sel['index']); 125 $form->addFieldsetClose(); 126 127 $form->addFieldsetOpen('Configuration & code'); 128 $this->addCheckboxRow($form, 'sb_conf', 'Configuration (conf/) - includes secrets', $sel['conf']); 129 $this->addCheckboxRow($form, 'sb_plugins', 'Plugins source (lib/plugins/)', $sel['plugins']); 130 $this->addCheckboxRow($form, 'sb_tpl', 'Templates source (lib/tpl/)', $sel['tpl']); 131 $form->addFieldsetClose(); 132 133 $form->addTagOpen('p'); 134 $form->addButton('sitebackup_action', 'Preview')->val('preview'); 135 $form->addHTML(' '); 136 $form->addButton('sitebackup_action', 'Download tar.gz')->val('download'); 137 $form->addTagClose('p'); 138 139 echo $form->toHTML(); 140 } 141 142 protected function addCheckboxRow(Form $form, $name, $label, $checked) 143 { 144 $form->addTagOpen('div')->attr('style', 'margin:4px 0;'); 145 $cb = $form->addCheckbox($name, ' ' . $label); 146 $cb->val('1'); 147 if ($checked) $cb->attr('checked', 'checked'); 148 $form->addTagClose('div'); 149 } 150 151 /* ----------------------------------------------------------------- * 152 * File collection 153 * ----------------------------------------------------------------- */ 154 155 /** 156 * Walk every selected root and build $this->fileList + $this->totalBytes. 157 */ 158 protected function collectFiles() 159 { 160 global $INPUT, $conf; 161 162 // Map of (form-field => [absolute source path, archive-relative path]). 163 // Use $conf[...] for data dirs so we handle relocated savedir installs correctly. 164 $roots = [ 165 'sb_pages' => [$conf['datadir'], 'data/pages'], 166 'sb_media' => [$conf['mediadir'], 'data/media'], 167 'sb_meta' => [$conf['metadir'], 'data/meta'], 168 'sb_media_meta' => [$conf['mediametadir'], 'data/media_meta'], 169 'sb_attic' => [$conf['olddir'], 'data/attic'], 170 'sb_media_attic' => [$conf['mediaolddir'], 'data/media_attic'], 171 'sb_index' => [$conf['indexdir'], 'data/index'], 172 'sb_conf' => [rtrim(DOKU_CONF, '/'), 'conf'], 173 'sb_plugins' => [rtrim(DOKU_PLUGIN, '/'), 'lib/plugins'], 174 'sb_tpl' => [DOKU_INC . 'lib/tpl', 'lib/tpl'], 175 ]; 176 177 foreach ($roots as $field => $pair) { 178 if (!$INPUT->bool($field, false)) continue; 179 [$srcAbs, $archiveRel] = $pair; 180 $this->walkInto($srcAbs, $archiveRel); 181 } 182 } 183 184 /** 185 * Recursively walk a directory (or single file) and append to $fileList. 186 */ 187 protected function walkInto($srcAbs, $archiveRel) 188 { 189 if (!file_exists($srcAbs)) return; 190 191 if (is_file($srcAbs)) { 192 $this->appendFile($srcAbs, $archiveRel); 193 return; 194 } 195 196 try { 197 $it = new RecursiveIteratorIterator( 198 new RecursiveDirectoryIterator($srcAbs, FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS), 199 RecursiveIteratorIterator::LEAVES_ONLY 200 ); 201 } catch (Exception $e) { 202 return; 203 } 204 205 $srcRoot = rtrim($srcAbs, '/'); 206 $rootLen = strlen($srcRoot) + 1; 207 foreach ($it as $info) { 208 try { 209 if (!$info->isFile()) continue; 210 if (!$info->isReadable()) continue; 211 $abs = $info->getPathname(); 212 $rel = substr($abs, $rootLen); 213 // Normalize Windows-style separators just in case. 214 $rel = str_replace('\\', '/', $rel); 215 216 if ($this->isIgnored($rel)) continue; 217 218 $this->appendFile($abs, $archiveRel . '/' . $rel); 219 } catch (Exception $e) { 220 // Skip unreadable / vanished files silently. 221 continue; 222 } 223 } 224 } 225 226 /** 227 * Per-tree filename ignores. Cache/lock/tmp/log are noisy and not useful for a restore. 228 * `_dummy` are placeholder files DokuWiki ships to keep empty dirs in tarballs. 229 */ 230 protected function isIgnored($relPath) 231 { 232 $base = basename($relPath); 233 if ($base === '_dummy') return true; 234 if ($base === '.DS_Store') return true; 235 if ($base === 'Thumbs.db') return true; 236 // The plugin's own scratch file - shouldn't exist, but belt and suspenders. 237 if (strpos($base, 'sitebackup_tmp_') === 0) return true; 238 return false; 239 } 240 241 protected function appendFile($abs, $archiveRel) 242 { 243 $size = @filesize($abs); 244 if ($size === false) $size = 0; 245 $this->fileList[] = [$abs, $archiveRel, $size]; 246 $this->totalBytes += $size; 247 } 248 249 /* ----------------------------------------------------------------- * 250 * Preview 251 * ----------------------------------------------------------------- */ 252 253 protected function renderPreview() 254 { 255 echo '<h2>Preview</h2>'; 256 echo '<p>' . count($this->fileList) . ' files, ' 257 . hsc($this->humanBytes($this->totalBytes)) . ' uncompressed.</p>'; 258 259 // Per-top-level summary so the user can see what each section costs. 260 $perRoot = []; 261 foreach ($this->fileList as [$abs, $rel, $size]) { 262 $parts = explode('/', $rel, 4); 263 $top = isset($parts[1]) ? ($parts[0] . '/' . $parts[1]) : $parts[0]; 264 if (!isset($perRoot[$top])) $perRoot[$top] = ['count' => 0, 'bytes' => 0]; 265 $perRoot[$top]['count']++; 266 $perRoot[$top]['bytes'] += $size; 267 } 268 ksort($perRoot); 269 270 echo '<table class="inline"><thead><tr><th>Section</th><th>Files</th><th>Size</th></tr></thead><tbody>'; 271 foreach ($perRoot as $section => $stats) { 272 echo '<tr><td><code>' . hsc($section) . '</code></td>' 273 . '<td style="text-align:right;">' . (int)$stats['count'] . '</td>' 274 . '<td style="text-align:right;">' . hsc($this->humanBytes($stats['bytes'])) . '</td></tr>'; 275 } 276 echo '</tbody></table>'; 277 echo '<p>Click <em>Download tar.gz</em> above to create and download the archive ' 278 . '(the compressed size will typically be smaller).</p>'; 279 } 280 281 protected function humanBytes($bytes) 282 { 283 $units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; 284 $i = 0; 285 $n = (float)$bytes; 286 while ($n >= 1024 && $i < count($units) - 1) { 287 $n /= 1024; 288 $i++; 289 } 290 return sprintf($i === 0 ? '%d %s' : '%.2f %s', $n, $units[$i]); 291 } 292 293 /* ----------------------------------------------------------------- * 294 * Archive creation + streaming 295 * ----------------------------------------------------------------- */ 296 297 protected function streamArchive() 298 { 299 global $conf; 300 301 if (!$this->fileList) { 302 // Fall through to html() which will just show the form again. 303 return; 304 } 305 306 @set_time_limit(0); 307 @ignore_user_abort(true); 308 // PHP 8.x: gzopen and tar building don't need huge memory; bump modestly just in case. 309 @ini_set('memory_limit', '256M'); 310 311 $tmpDir = $conf['tmpdir']; 312 if (!is_dir($tmpDir) || !is_writable($tmpDir)) { 313 msg('Site Backup: temp directory is not writable: ' . hsc($tmpDir), -1); 314 return; 315 } 316 317 // Hostname for filename - sanitize aggressively. 318 $host = $_SERVER['HTTP_HOST'] ?? 'wiki'; 319 $host = preg_replace('/[^a-zA-Z0-9._-]+/', '_', $host); 320 $stamp = date('Ymd-His'); 321 $prefix = $host . '-backup-' . $stamp; // dir name inside the archive 322 $filename = $prefix . '.tar.gz'; // download filename 323 $tmpFile = $tmpDir . '/sitebackup_tmp_' . bin2hex(random_bytes(8)) . '.tar.gz'; 324 325 try { 326 $tar = new Tar(); 327 $tar->setCompression(6, Archive::COMPRESS_GZIP); 328 $tar->create($tmpFile); 329 330 foreach ($this->fileList as [$abs, $rel, $size]) { 331 try { 332 $tar->addFile($abs, $prefix . '/' . $rel); 333 } catch (Exception $e) { 334 // Skip individual broken files rather than aborting the whole backup. 335 continue; 336 } 337 } 338 $tar->close(); 339 } catch (ArchiveIOException $e) { 340 @unlink($tmpFile); 341 msg('Site Backup: could not create archive: ' . hsc($e->getMessage()), -1); 342 return; 343 } 344 345 if (!is_file($tmpFile) || filesize($tmpFile) === 0) { 346 @unlink($tmpFile); 347 msg('Site Backup: archive was empty or could not be written.', -1); 348 return; 349 } 350 351 // Stream out. We bypass DokuWiki's normal output by sending headers and 352 // exiting after writing the file body. 353 $size = filesize($tmpFile); 354 355 // Clear any output buffering DokuWiki / extensions may have started. 356 while (ob_get_level() > 0) { 357 @ob_end_clean(); 358 } 359 360 header('Content-Type: application/gzip'); 361 header('Content-Disposition: attachment; filename="' . $filename . '"'); 362 header('Content-Length: ' . $size); 363 header('Cache-Control: no-store, no-cache, must-revalidate'); 364 header('Pragma: no-cache'); 365 366 $fp = fopen($tmpFile, 'rb'); 367 if ($fp) { 368 while (!feof($fp)) { 369 $chunk = fread($fp, 1024 * 256); 370 if ($chunk === false) break; 371 echo $chunk; 372 @flush(); 373 } 374 fclose($fp); 375 } 376 @unlink($tmpFile); 377 exit; 378 } 379} 380