129ed7b46Stracker-user<?php 2*b484d5bcStracker-userif (!defined('DOKU_INC')) die(); 3*b484d5bcStracker-user 429ed7b46Stracker-user/** 529ed7b46Stracker-user * Site Backup admin plugin for DokuWiki. 629ed7b46Stracker-user * 78d8c8007Stracker-user * Streams a tar.gz of selected wiki parts (pages, media, conf, lib/plugins, lib/tpl) 88d8c8007Stracker-user * to the admin's browser. The archive is built in data/tmp/ with a random filename, 98d8c8007Stracker-user * streamed out, and deleted immediately. Nothing persists on the server. 1029ed7b46Stracker-user * 118d8c8007Stracker-user * Security model: 128d8c8007Stracker-user * - Admin-only: DokuWiki's AdminPlugin framework enforces auth_isadmin() before 138d8c8007Stracker-user * handle()/html() are invoked because forAdminOnly() returns true. A second 148d8c8007Stracker-user * explicit check inside streamArchive() guards against any framework bypass. 158d8c8007Stracker-user * - The temp archive lives in $conf['tmpdir'] (data/tmp/), which DokuWiki ships 168d8c8007Stracker-user * with a deny-all .htaccess; it cannot be fetched directly even if the path 178d8c8007Stracker-user * were known. 188d8c8007Stracker-user * - Filename uses 64 bits of CSPRNG randomness, file is chmod'd to 0600, and is 198d8c8007Stracker-user * deleted both at the natural end of streamArchive() and via a shutdown 208d8c8007Stracker-user * function in case the connection is aborted partway. 218d8c8007Stracker-user * - Stale temp files from previous runs (older than 1 hour) are swept on each 228d8c8007Stracker-user * invocation, so even a crash-during-stream leaves nothing for long. 238d8c8007Stracker-user * 248d8c8007Stracker-user * Treat downloaded archives as credentials: they may include conf/users.auth.php 258d8c8007Stracker-user * (password hashes), ACL rules, and any secrets stored in conf/local.php. 2629ed7b46Stracker-user */ 2729ed7b46Stracker-user 2829ed7b46Stracker-useruse dokuwiki\Extension\AdminPlugin; 2929ed7b46Stracker-useruse dokuwiki\Form\Form; 3029ed7b46Stracker-useruse splitbrain\PHPArchive\Archive; 3129ed7b46Stracker-useruse splitbrain\PHPArchive\ArchiveIOException; 3229ed7b46Stracker-user 33a33f8e80Stracker-user// PatchedTar fixes splitbrain/php-archive PR #38 (mtime bug) for the version 34a33f8e80Stracker-user// of the library vendored with DokuWiki Librarian. 35a33f8e80Stracker-userrequire_once __DIR__ . '/PatchedTar.php'; 36a33f8e80Stracker-useruse dokuwiki\plugin\sitebackup\PatchedTar as Tar; 37a33f8e80Stracker-user 3829ed7b46Stracker-userclass admin_plugin_sitebackup extends AdminPlugin 3929ed7b46Stracker-user{ 408d8c8007Stracker-user /** Prefix used for the temp archive filename in data/tmp/. */ 418d8c8007Stracker-user const TMP_PREFIX = 'sitebackup_tmp_'; 428d8c8007Stracker-user 438d8c8007Stracker-user /** Max age (seconds) of leftover temp files before sweep removes them. */ 448d8c8007Stracker-user const TMP_STALE_AGE = 3600; 458d8c8007Stracker-user 468d8c8007Stracker-user /** @var array list of [absolute path, archive-relative path, size] of files to include */ 4729ed7b46Stracker-user protected $fileList = []; 4829ed7b46Stracker-user 4929ed7b46Stracker-user /** @var int total uncompressed size of selected files */ 5029ed7b46Stracker-user protected $totalBytes = 0; 5129ed7b46Stracker-user 52*b484d5bcStracker-user /** 53*b484d5bcStracker-user * @return bool 54*b484d5bcStracker-user */ 55*b484d5bcStracker-user public function forAdminOnly(): bool 5629ed7b46Stracker-user { 5729ed7b46Stracker-user return true; 5829ed7b46Stracker-user } 5929ed7b46Stracker-user 60*b484d5bcStracker-user /** 61*b484d5bcStracker-user * @return int 62*b484d5bcStracker-user */ 63*b484d5bcStracker-user public function getMenuSort(): int 6429ed7b46Stracker-user { 6529ed7b46Stracker-user return 1000; 6629ed7b46Stracker-user } 6729ed7b46Stracker-user 6829ed7b46Stracker-user /** 6929ed7b46Stracker-user * Dispatch based on the submitted action. 708d8c8007Stracker-user * Valid actions: "preview" (build file list, render summary table), 718d8c8007Stracker-user * "download" (build archive, stream as tar.gz). 7229ed7b46Stracker-user */ 73*b484d5bcStracker-user public function handle(): void 7429ed7b46Stracker-user { 7529ed7b46Stracker-user global $INPUT; 768d8c8007Stracker-user 778d8c8007Stracker-user // Sweep stale temp files from previous runs every time we enter the page. 788d8c8007Stracker-user $this->sweepStaleTempFiles(); 798d8c8007Stracker-user 8029ed7b46Stracker-user if (!$INPUT->has('sitebackup_action')) return; 8129ed7b46Stracker-user if (!checkSecurityToken()) return; 8229ed7b46Stracker-user 8329ed7b46Stracker-user $action = $INPUT->str('sitebackup_action'); 8429ed7b46Stracker-user if ($action !== 'preview' && $action !== 'download') return; 8529ed7b46Stracker-user 868d8c8007Stracker-user // Download MUST be POST. Refuse GET / HEAD / etc. so a stray link, browser 878d8c8007Stracker-user // prefetch, or curious co-admin pasting a URL can't trigger a backup. 88*b484d5bcStracker-user if ($action === 'download' && $INPUT->server->str('REQUEST_METHOD', 'GET') !== 'POST') { 898d8c8007Stracker-user msg('Site Backup: download must be submitted via POST.', -1); 908d8c8007Stracker-user return; 918d8c8007Stracker-user } 928d8c8007Stracker-user 9329ed7b46Stracker-user $this->collectFiles(); 9429ed7b46Stracker-user 9529ed7b46Stracker-user if ($action === 'download') { 9629ed7b46Stracker-user $this->streamArchive(); 978d8c8007Stracker-user // streamArchive() exits on success. If it returns, an error was shown 988d8c8007Stracker-user // via msg() and we fall through to html() so the user sees the form. 9929ed7b46Stracker-user } 10029ed7b46Stracker-user } 10129ed7b46Stracker-user 102*b484d5bcStracker-user /** 103*b484d5bcStracker-user * Render the admin page: intro, form, and (if $fileList is populated) preview table. 104*b484d5bcStracker-user */ 105*b484d5bcStracker-user public function html(): void 10629ed7b46Stracker-user { 10729ed7b46Stracker-user echo '<h1>Site Backup</h1>'; 10829ed7b46Stracker-user echo '<p>Select what to include, click <em>Preview</em> to see the file list and total size, ' 1098d8c8007Stracker-user . 'then <em>Download tar.gz</em> to receive the archive in your browser.</p>'; 11029ed7b46Stracker-user echo '<p style="background:#fff3cd; border:1px solid #ffeeba; padding:8px; border-radius:4px;">' 1118d8c8007Stracker-user . '<strong>Sensitive content warning.</strong> The archive may contain password hashes ' 11229ed7b46Stracker-user . '(<code>conf/users.auth.php</code>), ACL rules, and any secrets stored in ' 1138d8c8007Stracker-user . '<code>conf/local.php</code> (DB credentials, SMTP passwords, API keys). ' 1148d8c8007Stracker-user . 'Treat the download like a credential.' 11529ed7b46Stracker-user . '</p>'; 11629ed7b46Stracker-user 11729ed7b46Stracker-user $this->renderForm(); 11829ed7b46Stracker-user 11929ed7b46Stracker-user if ($this->fileList) { 12029ed7b46Stracker-user $this->renderPreview(); 12129ed7b46Stracker-user } 12229ed7b46Stracker-user } 12329ed7b46Stracker-user 12429ed7b46Stracker-user /* ----------------------------------------------------------------- * 12529ed7b46Stracker-user * Form 12629ed7b46Stracker-user * ----------------------------------------------------------------- */ 12729ed7b46Stracker-user 128*b484d5bcStracker-user /** 129*b484d5bcStracker-user * Render the selection form with checkboxes for each backup section. 130*b484d5bcStracker-user */ 131*b484d5bcStracker-user protected function renderForm(): void 13229ed7b46Stracker-user { 13329ed7b46Stracker-user global $INPUT; 13429ed7b46Stracker-user 13529ed7b46Stracker-user $hasSubmitted = $INPUT->has('sitebackup_action'); 13629ed7b46Stracker-user $defaults = [ 13729ed7b46Stracker-user 'pages' => true, 13829ed7b46Stracker-user 'media' => true, 13929ed7b46Stracker-user 'meta' => true, 14029ed7b46Stracker-user 'media_meta' => true, 14129ed7b46Stracker-user 'attic' => false, 14229ed7b46Stracker-user 'media_attic' => false, 14329ed7b46Stracker-user 'index' => false, 14429ed7b46Stracker-user 'conf' => true, 14529ed7b46Stracker-user 'plugins' => true, 14629ed7b46Stracker-user 'tpl' => true, 14729ed7b46Stracker-user ]; 14829ed7b46Stracker-user $sel = []; 14929ed7b46Stracker-user foreach ($defaults as $k => $def) { 15029ed7b46Stracker-user $sel[$k] = $hasSubmitted ? $INPUT->bool('sb_' . $k, false) : $def; 15129ed7b46Stracker-user } 15229ed7b46Stracker-user 15329ed7b46Stracker-user $form = new Form(['method' => 'POST', 'id' => 'sitebackup_form']); 15429ed7b46Stracker-user $form->setHiddenField('do', 'admin'); 15529ed7b46Stracker-user $form->setHiddenField('page', 'sitebackup'); 15629ed7b46Stracker-user 157723bf90eStracker-user $style = 'text-align: left; padding: 0 1em .5em 1em; margin: 1em 0;'; 158723bf90eStracker-user 159723bf90eStracker-user $form->addFieldsetOpen('Wiki content')->attr('style', $style); 16029ed7b46Stracker-user $this->addCheckboxRow($form, 'sb_pages', 'Pages (data/pages)', $sel['pages']); 16129ed7b46Stracker-user $this->addCheckboxRow($form, 'sb_media', 'Media files (data/media)', $sel['media']); 16229ed7b46Stracker-user $this->addCheckboxRow($form, 'sb_meta', 'Page metadata (data/meta)', $sel['meta']); 16329ed7b46Stracker-user $this->addCheckboxRow($form, 'sb_media_meta', 'Media metadata (data/media_meta)', $sel['media_meta']); 16429ed7b46Stracker-user $this->addCheckboxRow($form, 'sb_attic', 'Page revisions (data/attic) - can be large', $sel['attic']); 16529ed7b46Stracker-user $this->addCheckboxRow($form, 'sb_media_attic', 'Media revisions (data/media_attic)', $sel['media_attic']); 16629ed7b46Stracker-user $this->addCheckboxRow($form, 'sb_index', 'Search index (data/index) - rebuildable', $sel['index']); 16729ed7b46Stracker-user $form->addFieldsetClose(); 16829ed7b46Stracker-user 169723bf90eStracker-user $form->addFieldsetOpen('Configuration & code')->attr('style', $style); 17029ed7b46Stracker-user $this->addCheckboxRow($form, 'sb_conf', 'Configuration (conf/) - includes secrets', $sel['conf']); 17129ed7b46Stracker-user $this->addCheckboxRow($form, 'sb_plugins', 'Plugins source (lib/plugins/)', $sel['plugins']); 17229ed7b46Stracker-user $this->addCheckboxRow($form, 'sb_tpl', 'Templates source (lib/tpl/)', $sel['tpl']); 17329ed7b46Stracker-user $form->addFieldsetClose(); 17429ed7b46Stracker-user 17529ed7b46Stracker-user $form->addTagOpen('p'); 17629ed7b46Stracker-user $form->addButton('sitebackup_action', 'Preview')->val('preview'); 177723bf90eStracker-user $form->addHTML(' '); 17829ed7b46Stracker-user $form->addButton('sitebackup_action', 'Download tar.gz')->val('download'); 17929ed7b46Stracker-user $form->addTagClose('p'); 18029ed7b46Stracker-user 18129ed7b46Stracker-user echo $form->toHTML(); 18229ed7b46Stracker-user } 18329ed7b46Stracker-user 184*b484d5bcStracker-user /** 185*b484d5bcStracker-user * Add a labelled checkbox row to the form. 186*b484d5bcStracker-user * 187*b484d5bcStracker-user * @param Form $form 188*b484d5bcStracker-user * @param string $name field name 189*b484d5bcStracker-user * @param string $label display label 190*b484d5bcStracker-user * @param bool $checked whether the checkbox is pre-checked 191*b484d5bcStracker-user */ 192*b484d5bcStracker-user protected function addCheckboxRow(Form $form, string $name, string $label, bool $checked): void 19329ed7b46Stracker-user { 194723bf90eStracker-user $form->addTagOpen('div')->attr('style', 'margin:.4em 0;'); 19529ed7b46Stracker-user $cb = $form->addCheckbox($name, ' ' . $label); 19629ed7b46Stracker-user $cb->val('1'); 19729ed7b46Stracker-user if ($checked) $cb->attr('checked', 'checked'); 19829ed7b46Stracker-user $form->addTagClose('div'); 19929ed7b46Stracker-user } 20029ed7b46Stracker-user 20129ed7b46Stracker-user /* ----------------------------------------------------------------- * 20229ed7b46Stracker-user * File collection 20329ed7b46Stracker-user * ----------------------------------------------------------------- */ 20429ed7b46Stracker-user 205*b484d5bcStracker-user /** 206*b484d5bcStracker-user * Build $this->fileList from the selected checkboxes in the current request. 207*b484d5bcStracker-user */ 208*b484d5bcStracker-user protected function collectFiles(): void 20929ed7b46Stracker-user { 21029ed7b46Stracker-user global $INPUT, $conf; 21129ed7b46Stracker-user 2128d8c8007Stracker-user // Use $conf[...] for the data dirs so relocated savedir installs still work. 21329ed7b46Stracker-user $roots = [ 21429ed7b46Stracker-user 'sb_pages' => [$conf['datadir'], 'data/pages'], 21529ed7b46Stracker-user 'sb_media' => [$conf['mediadir'], 'data/media'], 21629ed7b46Stracker-user 'sb_meta' => [$conf['metadir'], 'data/meta'], 21729ed7b46Stracker-user 'sb_media_meta' => [$conf['mediametadir'], 'data/media_meta'], 21829ed7b46Stracker-user 'sb_attic' => [$conf['olddir'], 'data/attic'], 21929ed7b46Stracker-user 'sb_media_attic' => [$conf['mediaolddir'], 'data/media_attic'], 22029ed7b46Stracker-user 'sb_index' => [$conf['indexdir'], 'data/index'], 22129ed7b46Stracker-user 'sb_conf' => [rtrim(DOKU_CONF, '/'), 'conf'], 22229ed7b46Stracker-user 'sb_plugins' => [rtrim(DOKU_PLUGIN, '/'), 'lib/plugins'], 22329ed7b46Stracker-user 'sb_tpl' => [DOKU_INC . 'lib/tpl', 'lib/tpl'], 22429ed7b46Stracker-user ]; 22529ed7b46Stracker-user 22629ed7b46Stracker-user foreach ($roots as $field => $pair) { 22729ed7b46Stracker-user if (!$INPUT->bool($field, false)) continue; 22829ed7b46Stracker-user [$srcAbs, $archiveRel] = $pair; 22929ed7b46Stracker-user $this->walkInto($srcAbs, $archiveRel); 23029ed7b46Stracker-user } 23129ed7b46Stracker-user } 23229ed7b46Stracker-user 233*b484d5bcStracker-user /** 234*b484d5bcStracker-user * Recursively enumerate all readable files under $srcAbs and append them to $this->fileList. 235*b484d5bcStracker-user * 236*b484d5bcStracker-user * @param string $srcAbs absolute filesystem path (file or directory) 237*b484d5bcStracker-user * @param string $archiveRel path prefix to use inside the archive 238*b484d5bcStracker-user */ 239*b484d5bcStracker-user protected function walkInto(string $srcAbs, string $archiveRel): void 24029ed7b46Stracker-user { 24129ed7b46Stracker-user if (!file_exists($srcAbs)) return; 24229ed7b46Stracker-user 24329ed7b46Stracker-user if (is_file($srcAbs)) { 24429ed7b46Stracker-user $this->appendFile($srcAbs, $archiveRel); 24529ed7b46Stracker-user return; 24629ed7b46Stracker-user } 24729ed7b46Stracker-user 24829ed7b46Stracker-user try { 24929ed7b46Stracker-user $it = new RecursiveIteratorIterator( 2508d8c8007Stracker-user new RecursiveDirectoryIterator( 2518d8c8007Stracker-user $srcAbs, 2528d8c8007Stracker-user FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS 2538d8c8007Stracker-user ), 25429ed7b46Stracker-user RecursiveIteratorIterator::LEAVES_ONLY 25529ed7b46Stracker-user ); 25629ed7b46Stracker-user } catch (Exception $e) { 25729ed7b46Stracker-user return; 25829ed7b46Stracker-user } 25929ed7b46Stracker-user 26029ed7b46Stracker-user $srcRoot = rtrim($srcAbs, '/'); 26129ed7b46Stracker-user $rootLen = strlen($srcRoot) + 1; 26229ed7b46Stracker-user foreach ($it as $info) { 26329ed7b46Stracker-user try { 2648d8c8007Stracker-user if (!$info->isFile() || !$info->isReadable()) continue; 26529ed7b46Stracker-user $abs = $info->getPathname(); 2668d8c8007Stracker-user $rel = str_replace('\\', '/', substr($abs, $rootLen)); 26729ed7b46Stracker-user 2688d8c8007Stracker-user if ($this->isIgnored($archiveRel, $rel)) continue; 26929ed7b46Stracker-user 27029ed7b46Stracker-user $this->appendFile($abs, $archiveRel . '/' . $rel); 27129ed7b46Stracker-user } catch (Exception $e) { 27229ed7b46Stracker-user continue; 27329ed7b46Stracker-user } 27429ed7b46Stracker-user } 27529ed7b46Stracker-user } 27629ed7b46Stracker-user 27729ed7b46Stracker-user /** 278*b484d5bcStracker-user * Return true if a file should be excluded from the archive. 279*b484d5bcStracker-user * Hardcoded (no config) to keep the plugin small. 2808d8c8007Stracker-user * 281*b484d5bcStracker-user * @param string $archiveRel top-level archive branch, e.g. "conf" or "lib/plugins" 2828d8c8007Stracker-user * @param string $rel path within that branch 283*b484d5bcStracker-user * @return bool 28429ed7b46Stracker-user */ 285*b484d5bcStracker-user protected function isIgnored(string $archiveRel, string $rel): bool 28629ed7b46Stracker-user { 2878d8c8007Stracker-user $base = basename($rel); 2888d8c8007Stracker-user 2898d8c8007Stracker-user // Universal noise. 29029ed7b46Stracker-user if ($base === '_dummy') return true; 29129ed7b46Stracker-user if ($base === '.DS_Store') return true; 29229ed7b46Stracker-user if ($base === 'Thumbs.db') return true; 2938d8c8007Stracker-user 2948d8c8007Stracker-user // Belt-and-suspenders: never include our own scratch files even if 2958d8c8007Stracker-user // someone pointed savedir at an unusual location. 296*b484d5bcStracker-user if (str_starts_with($base, self::TMP_PREFIX)) return true; 2978d8c8007Stracker-user 2988d8c8007Stracker-user // Skip VCS metadata anywhere in any branch. Local clones / checkouts 2998d8c8007Stracker-user // can be huge and aren't part of "live" state. 3008d8c8007Stracker-user $segments = explode('/', $rel); 3018d8c8007Stracker-user foreach ($segments as $seg) { 3028d8c8007Stracker-user if ($seg === '.git') return true; 3038d8c8007Stracker-user if ($seg === '.svn') return true; 3048d8c8007Stracker-user if ($seg === '.hg') return true; 3058d8c8007Stracker-user } 3068d8c8007Stracker-user 3078d8c8007Stracker-user // conf/ branch: drop *.dist / *.example / *.bak sample files. They're 3088d8c8007Stracker-user // shipped with DokuWiki and templates, not real configuration. 3098d8c8007Stracker-user if ($archiveRel === 'conf') { 3108d8c8007Stracker-user if (preg_match('/\.(dist|example|bak)$/i', $base)) return true; 3118d8c8007Stracker-user } 3128d8c8007Stracker-user 31329ed7b46Stracker-user return false; 31429ed7b46Stracker-user } 31529ed7b46Stracker-user 316*b484d5bcStracker-user /** 317*b484d5bcStracker-user * Append a single file entry to the file list. 318*b484d5bcStracker-user * 319*b484d5bcStracker-user * @param string $abs absolute filesystem path 320*b484d5bcStracker-user * @param string $archiveRel path inside the archive 321*b484d5bcStracker-user */ 322*b484d5bcStracker-user protected function appendFile(string $abs, string $archiveRel): void 32329ed7b46Stracker-user { 324*b484d5bcStracker-user $size = filesize($abs); 32529ed7b46Stracker-user if ($size === false) $size = 0; 32629ed7b46Stracker-user $this->fileList[] = [$abs, $archiveRel, $size]; 32729ed7b46Stracker-user $this->totalBytes += $size; 32829ed7b46Stracker-user } 32929ed7b46Stracker-user 33029ed7b46Stracker-user /* ----------------------------------------------------------------- * 33129ed7b46Stracker-user * Preview 33229ed7b46Stracker-user * ----------------------------------------------------------------- */ 33329ed7b46Stracker-user 334*b484d5bcStracker-user /** 335*b484d5bcStracker-user * Render a summary table grouping files by top-level archive section. 336*b484d5bcStracker-user */ 337*b484d5bcStracker-user protected function renderPreview(): void 33829ed7b46Stracker-user { 33929ed7b46Stracker-user echo '<h2>Preview</h2>'; 34029ed7b46Stracker-user echo '<p>' . count($this->fileList) . ' files, ' 34129ed7b46Stracker-user . hsc($this->humanBytes($this->totalBytes)) . ' uncompressed.</p>'; 34229ed7b46Stracker-user 34329ed7b46Stracker-user $perRoot = []; 34429ed7b46Stracker-user foreach ($this->fileList as [$abs, $rel, $size]) { 34529ed7b46Stracker-user $parts = explode('/', $rel, 4); 34629ed7b46Stracker-user $top = isset($parts[1]) ? ($parts[0] . '/' . $parts[1]) : $parts[0]; 34729ed7b46Stracker-user if (!isset($perRoot[$top])) $perRoot[$top] = ['count' => 0, 'bytes' => 0]; 34829ed7b46Stracker-user $perRoot[$top]['count']++; 34929ed7b46Stracker-user $perRoot[$top]['bytes'] += $size; 35029ed7b46Stracker-user } 35129ed7b46Stracker-user ksort($perRoot); 35229ed7b46Stracker-user 353723bf90eStracker-user echo '<table class="inline"><thead><tr><th>Section</th><th style="text-align:right;">Files</th><th style="text-align:right;">Size</th></tr></thead><tbody>'; 35429ed7b46Stracker-user foreach ($perRoot as $section => $stats) { 35529ed7b46Stracker-user echo '<tr><td><code>' . hsc($section) . '</code></td>' 35629ed7b46Stracker-user . '<td style="text-align:right;">' . (int)$stats['count'] . '</td>' 35729ed7b46Stracker-user . '<td style="text-align:right;">' . hsc($this->humanBytes($stats['bytes'])) . '</td></tr>'; 35829ed7b46Stracker-user } 35929ed7b46Stracker-user echo '</tbody></table>'; 36029ed7b46Stracker-user echo '<p>Click <em>Download tar.gz</em> above to create and download the archive ' 3618d8c8007Stracker-user . '(compressed size will typically be smaller).</p>'; 36229ed7b46Stracker-user } 36329ed7b46Stracker-user 364*b484d5bcStracker-user /** 365*b484d5bcStracker-user * Format a byte count as a human-readable string (B, KiB, MiB, GiB, TiB). 366*b484d5bcStracker-user * 367*b484d5bcStracker-user * @param int $bytes 368*b484d5bcStracker-user * @return string 369*b484d5bcStracker-user */ 370*b484d5bcStracker-user protected function humanBytes(int $bytes): string 37129ed7b46Stracker-user { 37229ed7b46Stracker-user $units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; 37329ed7b46Stracker-user $i = 0; 37429ed7b46Stracker-user $n = (float)$bytes; 37529ed7b46Stracker-user while ($n >= 1024 && $i < count($units) - 1) { 37629ed7b46Stracker-user $n /= 1024; 37729ed7b46Stracker-user $i++; 37829ed7b46Stracker-user } 37929ed7b46Stracker-user return sprintf($i === 0 ? '%d %s' : '%.2f %s', $n, $units[$i]); 38029ed7b46Stracker-user } 38129ed7b46Stracker-user 38229ed7b46Stracker-user /* ----------------------------------------------------------------- * 38329ed7b46Stracker-user * Archive creation + streaming 38429ed7b46Stracker-user * ----------------------------------------------------------------- */ 38529ed7b46Stracker-user 386*b484d5bcStracker-user /** 387*b484d5bcStracker-user * Build the archive in data/tmp/, stream it to the browser as a tar.gz download, 388*b484d5bcStracker-user * and exit. Returns without exiting only when an error prevents streaming, so the 389*b484d5bcStracker-user * caller can fall through to html() and display the form again. 390*b484d5bcStracker-user */ 391*b484d5bcStracker-user protected function streamArchive(): void 39229ed7b46Stracker-user { 393*b484d5bcStracker-user global $conf, $INPUT; 39429ed7b46Stracker-user 3958d8c8007Stracker-user // Defense-in-depth: AdminPlugin framework should have blocked non-admins 3968d8c8007Stracker-user // before we got here, but verify directly anyway. 3978d8c8007Stracker-user if (!auth_isadmin()) { 3988d8c8007Stracker-user msg('Site Backup: admin access required.', -1); 3998d8c8007Stracker-user return; 4008d8c8007Stracker-user } 4018d8c8007Stracker-user 40229ed7b46Stracker-user if (!$this->fileList) { 4038d8c8007Stracker-user msg('Site Backup: nothing selected.', -1); 40429ed7b46Stracker-user return; 40529ed7b46Stracker-user } 40629ed7b46Stracker-user 407*b484d5bcStracker-user set_time_limit(0); 408*b484d5bcStracker-user ignore_user_abort(true); 409*b484d5bcStracker-user ini_set('memory_limit', '256M'); 41029ed7b46Stracker-user 41129ed7b46Stracker-user $tmpDir = $conf['tmpdir']; 41229ed7b46Stracker-user if (!is_dir($tmpDir) || !is_writable($tmpDir)) { 41329ed7b46Stracker-user msg('Site Backup: temp directory is not writable: ' . hsc($tmpDir), -1); 41429ed7b46Stracker-user return; 41529ed7b46Stracker-user } 41629ed7b46Stracker-user 4178d8c8007Stracker-user // Build a hard-to-guess filename. 16 hex chars = 64 bits of entropy from 4188d8c8007Stracker-user // a CSPRNG. The file also lives under data/.htaccess deny-all so even a 4198d8c8007Stracker-user // guess wouldn't be enough. 420*b484d5bcStracker-user $host = $INPUT->server->str('HTTP_HOST', 'wiki'); 42129ed7b46Stracker-user $host = preg_replace('/[^a-zA-Z0-9._-]+/', '_', $host); 42229ed7b46Stracker-user $stamp = date('Ymd-His'); 4238d8c8007Stracker-user $archiveDir = $host . '-backup-' . $stamp; // dir inside the tar 4248d8c8007Stracker-user $downloadName = $archiveDir . '.tar.gz'; // browser filename 4258d8c8007Stracker-user $tmpFile = $tmpDir . '/' . self::TMP_PREFIX . bin2hex(random_bytes(8)) . '.tar.gz'; 4268d8c8007Stracker-user 4278d8c8007Stracker-user // Guarantee the temp file is deleted even on connection abort, fatal 4288d8c8007Stracker-user // error, or `exit` from within the streaming loop. 4298d8c8007Stracker-user register_shutdown_function(function () use ($tmpFile) { 430*b484d5bcStracker-user if (is_file($tmpFile)) unlink($tmpFile); 4318d8c8007Stracker-user }); 4328d8c8007Stracker-user 433*b484d5bcStracker-user $oldUmask = umask(0077); 43429ed7b46Stracker-user 43529ed7b46Stracker-user try { 43629ed7b46Stracker-user $tar = new Tar(); 43729ed7b46Stracker-user $tar->setCompression(6, Archive::COMPRESS_GZIP); 43829ed7b46Stracker-user $tar->create($tmpFile); 43929ed7b46Stracker-user 4408d8c8007Stracker-user // Belt-and-suspenders: explicitly chmod once created, in case the 4418d8c8007Stracker-user // umask wasn't honored (some filesystems / wrappers ignore it). 442*b484d5bcStracker-user chmod($tmpFile, 0600); 4438d8c8007Stracker-user 44429ed7b46Stracker-user foreach ($this->fileList as [$abs, $rel, $size]) { 44529ed7b46Stracker-user try { 4468d8c8007Stracker-user $tar->addFile($abs, $archiveDir . '/' . $rel); 44729ed7b46Stracker-user } catch (Exception $e) { 4488d8c8007Stracker-user // Skip individual broken files rather than failing the whole backup. 44929ed7b46Stracker-user continue; 45029ed7b46Stracker-user } 45129ed7b46Stracker-user } 45229ed7b46Stracker-user $tar->close(); 45329ed7b46Stracker-user } catch (ArchiveIOException $e) { 454*b484d5bcStracker-user umask($oldUmask); 455*b484d5bcStracker-user if (is_file($tmpFile)) unlink($tmpFile); 45629ed7b46Stracker-user msg('Site Backup: could not create archive: ' . hsc($e->getMessage()), -1); 45729ed7b46Stracker-user return; 45829ed7b46Stracker-user } 45929ed7b46Stracker-user 460*b484d5bcStracker-user umask($oldUmask); 4618d8c8007Stracker-user 46229ed7b46Stracker-user if (!is_file($tmpFile) || filesize($tmpFile) === 0) { 463*b484d5bcStracker-user if (is_file($tmpFile)) unlink($tmpFile); 46429ed7b46Stracker-user msg('Site Backup: archive was empty or could not be written.', -1); 46529ed7b46Stracker-user return; 46629ed7b46Stracker-user } 46729ed7b46Stracker-user 46829ed7b46Stracker-user $size = filesize($tmpFile); 46929ed7b46Stracker-user 4708d8c8007Stracker-user // Clear any output buffering DokuWiki / extensions may have started so 4718d8c8007Stracker-user // headers + binary body go out cleanly. 47229ed7b46Stracker-user while (ob_get_level() > 0) { 473*b484d5bcStracker-user ob_end_clean(); 47429ed7b46Stracker-user } 47529ed7b46Stracker-user 47629ed7b46Stracker-user header('Content-Type: application/gzip'); 4778d8c8007Stracker-user header('Content-Disposition: attachment; filename="' . $downloadName . '"'); 47829ed7b46Stracker-user header('Content-Length: ' . $size); 4798d8c8007Stracker-user header('Cache-Control: no-store, no-cache, must-revalidate, private'); 48029ed7b46Stracker-user header('Pragma: no-cache'); 4818d8c8007Stracker-user header('X-Content-Type-Options: nosniff'); 48229ed7b46Stracker-user 48329ed7b46Stracker-user $fp = fopen($tmpFile, 'rb'); 48429ed7b46Stracker-user if ($fp) { 48529ed7b46Stracker-user while (!feof($fp)) { 48629ed7b46Stracker-user $chunk = fread($fp, 1024 * 256); 48729ed7b46Stracker-user if ($chunk === false) break; 48829ed7b46Stracker-user echo $chunk; 489*b484d5bcStracker-user flush(); 49029ed7b46Stracker-user } 49129ed7b46Stracker-user fclose($fp); 49229ed7b46Stracker-user } 493*b484d5bcStracker-user unlink($tmpFile); 49429ed7b46Stracker-user exit; 49529ed7b46Stracker-user } 4968d8c8007Stracker-user 4978d8c8007Stracker-user /** 4988d8c8007Stracker-user * Remove leftover temp archives from prior runs that died before unlink. 4998d8c8007Stracker-user * Anything matching our prefix older than TMP_STALE_AGE is fair game. 5008d8c8007Stracker-user */ 501*b484d5bcStracker-user protected function sweepStaleTempFiles(): void 5028d8c8007Stracker-user { 5038d8c8007Stracker-user global $conf; 5048d8c8007Stracker-user $tmpDir = $conf['tmpdir'] ?? null; 5058d8c8007Stracker-user if (!$tmpDir || !is_dir($tmpDir)) return; 5068d8c8007Stracker-user 5078d8c8007Stracker-user $cutoff = time() - self::TMP_STALE_AGE; 5088d8c8007Stracker-user $pattern = $tmpDir . '/' . self::TMP_PREFIX . '*'; 509*b484d5bcStracker-user foreach ((array) glob($pattern) as $stale) { 5108d8c8007Stracker-user if (!is_file($stale)) continue; 511*b484d5bcStracker-user $mtime = filemtime($stale); 5128d8c8007Stracker-user if ($mtime !== false && $mtime < $cutoff) { 513*b484d5bcStracker-user unlink($stale); 5148d8c8007Stracker-user } 5158d8c8007Stracker-user } 5168d8c8007Stracker-user } 51729ed7b46Stracker-user} 518