sweepStaleTempFiles(); if (!$INPUT->has('sitebackup_action')) return; if (!checkSecurityToken()) return; $action = $INPUT->str('sitebackup_action'); if ($action !== 'preview' && $action !== 'download') return; // Download MUST be POST. Refuse GET / HEAD / etc. so a stray link, browser // prefetch, or curious co-admin pasting a URL can't trigger a backup. if ($action === 'download' && ($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { msg('Site Backup: download must be submitted via POST.', -1); return; } $this->collectFiles(); if ($action === 'download') { $this->streamArchive(); // streamArchive() exits on success. If it returns, an error was shown // via msg() and we fall through to html() so the user sees the form. } } public function html() { echo '
Select what to include, click Preview to see the file list and total size, ' . 'then Download tar.gz to receive the archive in your browser.
'; echo ''
. 'Sensitive content warning. The archive may contain password hashes '
. '(conf/users.auth.php), ACL rules, and any secrets stored in '
. 'conf/local.php (DB credentials, SMTP passwords, API keys). '
. 'Treat the download like a credential.'
. '
' . count($this->fileList) . ' files, ' . hsc($this->humanBytes($this->totalBytes)) . ' uncompressed.
'; $perRoot = []; foreach ($this->fileList as [$abs, $rel, $size]) { $parts = explode('/', $rel, 4); $top = isset($parts[1]) ? ($parts[0] . '/' . $parts[1]) : $parts[0]; if (!isset($perRoot[$top])) $perRoot[$top] = ['count' => 0, 'bytes' => 0]; $perRoot[$top]['count']++; $perRoot[$top]['bytes'] += $size; } ksort($perRoot); echo '| Section | Files | Size |
|---|---|---|
' . hsc($section) . ' | '
. '' . (int)$stats['count'] . ' | ' . '' . hsc($this->humanBytes($stats['bytes'])) . ' |
Click Download tar.gz above to create and download the archive ' . '(compressed size will typically be smaller).
'; } protected function humanBytes($bytes) { $units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; $i = 0; $n = (float)$bytes; while ($n >= 1024 && $i < count($units) - 1) { $n /= 1024; $i++; } return sprintf($i === 0 ? '%d %s' : '%.2f %s', $n, $units[$i]); } /* ----------------------------------------------------------------- * * Archive creation + streaming * ----------------------------------------------------------------- */ protected function streamArchive() { global $conf; // Defense-in-depth: AdminPlugin framework should have blocked non-admins // before we got here, but verify directly anyway. if (!auth_isadmin()) { msg('Site Backup: admin access required.', -1); return; } if (!$this->fileList) { msg('Site Backup: nothing selected.', -1); return; } @set_time_limit(0); @ignore_user_abort(true); @ini_set('memory_limit', '256M'); $tmpDir = $conf['tmpdir']; if (!is_dir($tmpDir) || !is_writable($tmpDir)) { msg('Site Backup: temp directory is not writable: ' . hsc($tmpDir), -1); return; } // Build a hard-to-guess filename. 16 hex chars = 64 bits of entropy from // a CSPRNG. The file also lives under data/.htaccess deny-all so even a // guess wouldn't be enough. $host = $_SERVER['HTTP_HOST'] ?? 'wiki'; $host = preg_replace('/[^a-zA-Z0-9._-]+/', '_', $host); $stamp = date('Ymd-His'); $archiveDir = $host . '-backup-' . $stamp; // dir inside the tar $downloadName = $archiveDir . '.tar.gz'; // browser filename $tmpFile = $tmpDir . '/' . self::TMP_PREFIX . bin2hex(random_bytes(8)) . '.tar.gz'; // Guarantee the temp file is deleted even on connection abort, fatal // error, or `exit` from within the streaming loop. register_shutdown_function(function () use ($tmpFile) { if (is_file($tmpFile)) @unlink($tmpFile); }); $oldUmask = @umask(0077); try { $tar = new Tar(); $tar->setCompression(6, Archive::COMPRESS_GZIP); $tar->create($tmpFile); // Belt-and-suspenders: explicitly chmod once created, in case the // umask wasn't honored (some filesystems / wrappers ignore it). @chmod($tmpFile, 0600); foreach ($this->fileList as [$abs, $rel, $size]) { try { $tar->addFile($abs, $archiveDir . '/' . $rel); } catch (Exception $e) { // Skip individual broken files rather than failing the whole backup. continue; } } $tar->close(); } catch (ArchiveIOException $e) { @umask($oldUmask); @unlink($tmpFile); msg('Site Backup: could not create archive: ' . hsc($e->getMessage()), -1); return; } @umask($oldUmask); if (!is_file($tmpFile) || filesize($tmpFile) === 0) { @unlink($tmpFile); msg('Site Backup: archive was empty or could not be written.', -1); return; } $size = filesize($tmpFile); // Clear any output buffering DokuWiki / extensions may have started so // headers + binary body go out cleanly. while (ob_get_level() > 0) { @ob_end_clean(); } header('Content-Type: application/gzip'); header('Content-Disposition: attachment; filename="' . $downloadName . '"'); header('Content-Length: ' . $size); header('Cache-Control: no-store, no-cache, must-revalidate, private'); header('Pragma: no-cache'); header('X-Content-Type-Options: nosniff'); $fp = fopen($tmpFile, 'rb'); if ($fp) { while (!feof($fp)) { $chunk = fread($fp, 1024 * 256); if ($chunk === false) break; echo $chunk; @flush(); } fclose($fp); } @unlink($tmpFile); exit; } /** * Remove leftover temp archives from prior runs that died before unlink. * Anything matching our prefix older than TMP_STALE_AGE is fair game. */ protected function sweepStaleTempFiles() { global $conf; $tmpDir = $conf['tmpdir'] ?? null; if (!$tmpDir || !is_dir($tmpDir)) return; $cutoff = time() - self::TMP_STALE_AGE; $pattern = $tmpDir . '/' . self::TMP_PREFIX . '*'; foreach ((array) @glob($pattern) as $stale) { if (!is_file($stale)) continue; $mtime = @filemtime($stale); if ($mtime !== false && $mtime < $cutoff) { @unlink($stale); } } } }