129ed7b46Stracker-user<?php 2b484d5bcStracker-userif (!defined('DOKU_INC')) die(); 3b484d5bcStracker-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. 35*c874c2c0Stracker-user// The class lives in PatchedTar.php and is autoloaded via DokuWiki's PSR-4 loader 36*c874c2c0Stracker-user// (dokuwiki\plugin\sitebackup namespace -> lib/plugins/sitebackup/). 37a33f8e80Stracker-useruse dokuwiki\plugin\sitebackup\PatchedTar as Tar; 38a33f8e80Stracker-user 3929ed7b46Stracker-userclass admin_plugin_sitebackup extends AdminPlugin 4029ed7b46Stracker-user{ 418d8c8007Stracker-user /** Prefix used for the temp archive filename in data/tmp/. */ 428d8c8007Stracker-user const TMP_PREFIX = 'sitebackup_tmp_'; 438d8c8007Stracker-user 448d8c8007Stracker-user /** Max age (seconds) of leftover temp files before sweep removes them. */ 458d8c8007Stracker-user const TMP_STALE_AGE = 3600; 468d8c8007Stracker-user 478d8c8007Stracker-user /** @var array list of [absolute path, archive-relative path, size] of files to include */ 4829ed7b46Stracker-user protected $fileList = []; 4929ed7b46Stracker-user 5029ed7b46Stracker-user /** @var int total uncompressed size of selected files */ 5129ed7b46Stracker-user protected $totalBytes = 0; 5229ed7b46Stracker-user 53b484d5bcStracker-user /** 54*c874c2c0Stracker-user * Tracks real paths already added to the archive to prevent double-inclusion 55*c874c2c0Stracker-user * via symlinks pointing to the same file. 56*c874c2c0Stracker-user * 57*c874c2c0Stracker-user * @var array<string, true> 58*c874c2c0Stracker-user */ 59*c874c2c0Stracker-user protected $visitedPaths = []; 60*c874c2c0Stracker-user 61*c874c2c0Stracker-user /** 62b484d5bcStracker-user * @return bool 63b484d5bcStracker-user */ 64b484d5bcStracker-user public function forAdminOnly(): bool 6529ed7b46Stracker-user { 6629ed7b46Stracker-user return true; 6729ed7b46Stracker-user } 6829ed7b46Stracker-user 69b484d5bcStracker-user /** 70b484d5bcStracker-user * @return int 71b484d5bcStracker-user */ 72b484d5bcStracker-user public function getMenuSort(): int 7329ed7b46Stracker-user { 7429ed7b46Stracker-user return 1000; 7529ed7b46Stracker-user } 7629ed7b46Stracker-user 7729ed7b46Stracker-user /** 7829ed7b46Stracker-user * Dispatch based on the submitted action. 798d8c8007Stracker-user * Valid actions: "preview" (build file list, render summary table), 808d8c8007Stracker-user * "download" (build archive, stream as tar.gz). 8129ed7b46Stracker-user */ 82b484d5bcStracker-user public function handle(): void 8329ed7b46Stracker-user { 8429ed7b46Stracker-user global $INPUT; 858d8c8007Stracker-user 868d8c8007Stracker-user // Sweep stale temp files from previous runs every time we enter the page. 878d8c8007Stracker-user $this->sweepStaleTempFiles(); 888d8c8007Stracker-user 8929ed7b46Stracker-user if (!$INPUT->has('sitebackup_action')) return; 9029ed7b46Stracker-user if (!checkSecurityToken()) return; 9129ed7b46Stracker-user 9229ed7b46Stracker-user $action = $INPUT->str('sitebackup_action'); 9329ed7b46Stracker-user if ($action !== 'preview' && $action !== 'download') return; 9429ed7b46Stracker-user 958d8c8007Stracker-user // Download MUST be POST. Refuse GET / HEAD / etc. so a stray link, browser 968d8c8007Stracker-user // prefetch, or curious co-admin pasting a URL can't trigger a backup. 97b484d5bcStracker-user if ($action === 'download' && $INPUT->server->str('REQUEST_METHOD', 'GET') !== 'POST') { 98*c874c2c0Stracker-user msg($this->getLang('err_post'), -1); 998d8c8007Stracker-user return; 1008d8c8007Stracker-user } 1018d8c8007Stracker-user 10229ed7b46Stracker-user $this->collectFiles(); 10329ed7b46Stracker-user 10429ed7b46Stracker-user if ($action === 'download') { 10529ed7b46Stracker-user $this->streamArchive(); 1068d8c8007Stracker-user // streamArchive() exits on success. If it returns, an error was shown 1078d8c8007Stracker-user // via msg() and we fall through to html() so the user sees the form. 10829ed7b46Stracker-user } 10929ed7b46Stracker-user } 11029ed7b46Stracker-user 111b484d5bcStracker-user /** 112b484d5bcStracker-user * Render the admin page: intro, form, and (if $fileList is populated) preview table. 113b484d5bcStracker-user */ 114b484d5bcStracker-user public function html(): void 11529ed7b46Stracker-user { 116*c874c2c0Stracker-user echo '<h1>' . hsc($this->getLang('menu')) . '</h1>'; 117*c874c2c0Stracker-user echo '<p>' . $this->getLang('intro') . '</p>'; 11829ed7b46Stracker-user echo '<p style="background:#fff3cd; border:1px solid #ffeeba; padding:8px; border-radius:4px;">' 119*c874c2c0Stracker-user . '<strong>' . hsc($this->getLang('warn_title')) . '</strong> ' 120*c874c2c0Stracker-user . $this->getLang('warn_body') 12129ed7b46Stracker-user . '</p>'; 12229ed7b46Stracker-user 12329ed7b46Stracker-user $this->renderForm(); 12429ed7b46Stracker-user 12529ed7b46Stracker-user if ($this->fileList) { 12629ed7b46Stracker-user $this->renderPreview(); 12729ed7b46Stracker-user } 12829ed7b46Stracker-user } 12929ed7b46Stracker-user 13029ed7b46Stracker-user /* ----------------------------------------------------------------- * 13129ed7b46Stracker-user * Form 13229ed7b46Stracker-user * ----------------------------------------------------------------- */ 13329ed7b46Stracker-user 134b484d5bcStracker-user /** 135b484d5bcStracker-user * Render the selection form with checkboxes for each backup section. 136b484d5bcStracker-user */ 137b484d5bcStracker-user protected function renderForm(): void 13829ed7b46Stracker-user { 13929ed7b46Stracker-user global $INPUT; 14029ed7b46Stracker-user 14129ed7b46Stracker-user $hasSubmitted = $INPUT->has('sitebackup_action'); 14229ed7b46Stracker-user $defaults = [ 14329ed7b46Stracker-user 'pages' => true, 14429ed7b46Stracker-user 'media' => true, 14529ed7b46Stracker-user 'meta' => true, 14629ed7b46Stracker-user 'media_meta' => true, 14729ed7b46Stracker-user 'attic' => false, 14829ed7b46Stracker-user 'media_attic' => false, 14929ed7b46Stracker-user 'index' => false, 15029ed7b46Stracker-user 'conf' => true, 15129ed7b46Stracker-user 'plugins' => true, 15229ed7b46Stracker-user 'tpl' => true, 15329ed7b46Stracker-user ]; 15429ed7b46Stracker-user $sel = []; 15529ed7b46Stracker-user foreach ($defaults as $k => $def) { 15629ed7b46Stracker-user $sel[$k] = $hasSubmitted ? $INPUT->bool('sb_' . $k, false) : $def; 15729ed7b46Stracker-user } 15829ed7b46Stracker-user 15929ed7b46Stracker-user $form = new Form(['method' => 'POST', 'id' => 'sitebackup_form']); 16029ed7b46Stracker-user $form->setHiddenField('do', 'admin'); 16129ed7b46Stracker-user $form->setHiddenField('page', 'sitebackup'); 16229ed7b46Stracker-user 163723bf90eStracker-user $style = 'text-align: left; padding: 0 1em .5em 1em; margin: 1em 0;'; 164723bf90eStracker-user 165*c874c2c0Stracker-user $form->addFieldsetOpen($this->getLang('fs_content'))->attr('style', $style); 166*c874c2c0Stracker-user $this->addCheckboxRow($form, 'sb_pages', $this->getLang('opt_pages'), $sel['pages']); 167*c874c2c0Stracker-user $this->addCheckboxRow($form, 'sb_media', $this->getLang('opt_media'), $sel['media']); 168*c874c2c0Stracker-user $this->addCheckboxRow($form, 'sb_meta', $this->getLang('opt_meta'), $sel['meta']); 169*c874c2c0Stracker-user $this->addCheckboxRow($form, 'sb_media_meta', $this->getLang('opt_media_meta'), $sel['media_meta']); 170*c874c2c0Stracker-user $this->addCheckboxRow($form, 'sb_attic', $this->getLang('opt_attic'), $sel['attic']); 171*c874c2c0Stracker-user $this->addCheckboxRow($form, 'sb_media_attic', $this->getLang('opt_media_attic'), $sel['media_attic']); 172*c874c2c0Stracker-user $this->addCheckboxRow($form, 'sb_index', $this->getLang('opt_index'), $sel['index']); 17329ed7b46Stracker-user $form->addFieldsetClose(); 17429ed7b46Stracker-user 175*c874c2c0Stracker-user $form->addFieldsetOpen($this->getLang('fs_code'))->attr('style', $style); 176*c874c2c0Stracker-user $this->addCheckboxRow($form, 'sb_conf', $this->getLang('opt_conf'), $sel['conf']); 177*c874c2c0Stracker-user $this->addCheckboxRow($form, 'sb_plugins', $this->getLang('opt_plugins'), $sel['plugins']); 178*c874c2c0Stracker-user $this->addCheckboxRow($form, 'sb_tpl', $this->getLang('opt_tpl'), $sel['tpl']); 17929ed7b46Stracker-user $form->addFieldsetClose(); 18029ed7b46Stracker-user 18129ed7b46Stracker-user $form->addTagOpen('p'); 182*c874c2c0Stracker-user $form->addButton('sitebackup_action', $this->getLang('btn_preview'))->val('preview'); 183723bf90eStracker-user $form->addHTML(' '); 184*c874c2c0Stracker-user $form->addButton('sitebackup_action', $this->getLang('btn_download'))->val('download'); 18529ed7b46Stracker-user $form->addTagClose('p'); 18629ed7b46Stracker-user 18729ed7b46Stracker-user echo $form->toHTML(); 18829ed7b46Stracker-user } 18929ed7b46Stracker-user 190b484d5bcStracker-user /** 191b484d5bcStracker-user * Add a labelled checkbox row to the form. 192b484d5bcStracker-user * 193b484d5bcStracker-user * @param Form $form 194b484d5bcStracker-user * @param string $name field name 195b484d5bcStracker-user * @param string $label display label 196b484d5bcStracker-user * @param bool $checked whether the checkbox is pre-checked 197b484d5bcStracker-user */ 198b484d5bcStracker-user protected function addCheckboxRow(Form $form, string $name, string $label, bool $checked): void 19929ed7b46Stracker-user { 200723bf90eStracker-user $form->addTagOpen('div')->attr('style', 'margin:.4em 0;'); 20129ed7b46Stracker-user $cb = $form->addCheckbox($name, ' ' . $label); 20229ed7b46Stracker-user $cb->val('1'); 20329ed7b46Stracker-user if ($checked) $cb->attr('checked', 'checked'); 20429ed7b46Stracker-user $form->addTagClose('div'); 20529ed7b46Stracker-user } 20629ed7b46Stracker-user 20729ed7b46Stracker-user /* ----------------------------------------------------------------- * 20829ed7b46Stracker-user * File collection 20929ed7b46Stracker-user * ----------------------------------------------------------------- */ 21029ed7b46Stracker-user 211b484d5bcStracker-user /** 212b484d5bcStracker-user * Build $this->fileList from the selected checkboxes in the current request. 213b484d5bcStracker-user */ 214b484d5bcStracker-user protected function collectFiles(): void 21529ed7b46Stracker-user { 21629ed7b46Stracker-user global $INPUT, $conf; 21729ed7b46Stracker-user 218*c874c2c0Stracker-user $this->fileList = []; 219*c874c2c0Stracker-user $this->totalBytes = 0; 220*c874c2c0Stracker-user $this->visitedPaths = []; 221*c874c2c0Stracker-user 2228d8c8007Stracker-user // Use $conf[...] for the data dirs so relocated savedir installs still work. 22329ed7b46Stracker-user $roots = [ 22429ed7b46Stracker-user 'sb_pages' => [$conf['datadir'], 'data/pages'], 22529ed7b46Stracker-user 'sb_media' => [$conf['mediadir'], 'data/media'], 22629ed7b46Stracker-user 'sb_meta' => [$conf['metadir'], 'data/meta'], 22729ed7b46Stracker-user 'sb_media_meta' => [$conf['mediametadir'], 'data/media_meta'], 22829ed7b46Stracker-user 'sb_attic' => [$conf['olddir'], 'data/attic'], 22929ed7b46Stracker-user 'sb_media_attic' => [$conf['mediaolddir'], 'data/media_attic'], 23029ed7b46Stracker-user 'sb_index' => [$conf['indexdir'], 'data/index'], 23129ed7b46Stracker-user 'sb_conf' => [rtrim(DOKU_CONF, '/'), 'conf'], 23229ed7b46Stracker-user 'sb_plugins' => [rtrim(DOKU_PLUGIN, '/'), 'lib/plugins'], 23329ed7b46Stracker-user 'sb_tpl' => [DOKU_INC . 'lib/tpl', 'lib/tpl'], 23429ed7b46Stracker-user ]; 23529ed7b46Stracker-user 23629ed7b46Stracker-user foreach ($roots as $field => $pair) { 23729ed7b46Stracker-user if (!$INPUT->bool($field, false)) continue; 23829ed7b46Stracker-user [$srcAbs, $archiveRel] = $pair; 23929ed7b46Stracker-user $this->walkInto($srcAbs, $archiveRel); 24029ed7b46Stracker-user } 24129ed7b46Stracker-user } 24229ed7b46Stracker-user 243b484d5bcStracker-user /** 244b484d5bcStracker-user * Recursively enumerate all readable files under $srcAbs and append them to $this->fileList. 245b484d5bcStracker-user * 246b484d5bcStracker-user * @param string $srcAbs absolute filesystem path (file or directory) 247b484d5bcStracker-user * @param string $archiveRel path prefix to use inside the archive 248b484d5bcStracker-user */ 249b484d5bcStracker-user protected function walkInto(string $srcAbs, string $archiveRel): void 25029ed7b46Stracker-user { 25129ed7b46Stracker-user if (!file_exists($srcAbs)) return; 25229ed7b46Stracker-user 25329ed7b46Stracker-user if (is_file($srcAbs)) { 25429ed7b46Stracker-user $this->appendFile($srcAbs, $archiveRel); 25529ed7b46Stracker-user return; 25629ed7b46Stracker-user } 25729ed7b46Stracker-user 25829ed7b46Stracker-user try { 25929ed7b46Stracker-user $it = new RecursiveIteratorIterator( 2608d8c8007Stracker-user new RecursiveDirectoryIterator( 2618d8c8007Stracker-user $srcAbs, 2628d8c8007Stracker-user FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS 2638d8c8007Stracker-user ), 26429ed7b46Stracker-user RecursiveIteratorIterator::LEAVES_ONLY 26529ed7b46Stracker-user ); 26629ed7b46Stracker-user } catch (Exception $e) { 26729ed7b46Stracker-user return; 26829ed7b46Stracker-user } 26929ed7b46Stracker-user 27029ed7b46Stracker-user $srcRoot = rtrim($srcAbs, '/'); 27129ed7b46Stracker-user $rootLen = strlen($srcRoot) + 1; 27229ed7b46Stracker-user foreach ($it as $info) { 27329ed7b46Stracker-user try { 2748d8c8007Stracker-user if (!$info->isFile() || !$info->isReadable()) continue; 275*c874c2c0Stracker-user 276*c874c2c0Stracker-user // Skip files already included via a different symlink path. 277*c874c2c0Stracker-user $realPath = $info->getRealPath(); 278*c874c2c0Stracker-user if ($realPath === false) continue; 279*c874c2c0Stracker-user if (isset($this->visitedPaths[$realPath])) continue; 280*c874c2c0Stracker-user $this->visitedPaths[$realPath] = true; 281*c874c2c0Stracker-user 28229ed7b46Stracker-user $abs = $info->getPathname(); 2838d8c8007Stracker-user $rel = str_replace('\\', '/', substr($abs, $rootLen)); 28429ed7b46Stracker-user 2858d8c8007Stracker-user if ($this->isIgnored($archiveRel, $rel)) continue; 28629ed7b46Stracker-user 28729ed7b46Stracker-user $this->appendFile($abs, $archiveRel . '/' . $rel); 28829ed7b46Stracker-user } catch (Exception $e) { 28929ed7b46Stracker-user continue; 29029ed7b46Stracker-user } 29129ed7b46Stracker-user } 29229ed7b46Stracker-user } 29329ed7b46Stracker-user 29429ed7b46Stracker-user /** 295b484d5bcStracker-user * Return true if a file should be excluded from the archive. 296b484d5bcStracker-user * Hardcoded (no config) to keep the plugin small. 2978d8c8007Stracker-user * 298b484d5bcStracker-user * @param string $archiveRel top-level archive branch, e.g. "conf" or "lib/plugins" 2998d8c8007Stracker-user * @param string $rel path within that branch 300b484d5bcStracker-user * @return bool 30129ed7b46Stracker-user */ 302b484d5bcStracker-user protected function isIgnored(string $archiveRel, string $rel): bool 30329ed7b46Stracker-user { 3048d8c8007Stracker-user $base = basename($rel); 3058d8c8007Stracker-user 3068d8c8007Stracker-user // Universal noise. 30729ed7b46Stracker-user if ($base === '_dummy') return true; 30829ed7b46Stracker-user if ($base === '.DS_Store') return true; 30929ed7b46Stracker-user if ($base === 'Thumbs.db') return true; 3108d8c8007Stracker-user 3118d8c8007Stracker-user // Belt-and-suspenders: never include our own scratch files even if 3128d8c8007Stracker-user // someone pointed savedir at an unusual location. 313b484d5bcStracker-user if (str_starts_with($base, self::TMP_PREFIX)) return true; 3148d8c8007Stracker-user 3158d8c8007Stracker-user // Skip VCS metadata anywhere in any branch. Local clones / checkouts 3168d8c8007Stracker-user // can be huge and aren't part of "live" state. 3178d8c8007Stracker-user $segments = explode('/', $rel); 3188d8c8007Stracker-user foreach ($segments as $seg) { 3198d8c8007Stracker-user if ($seg === '.git') return true; 3208d8c8007Stracker-user if ($seg === '.svn') return true; 3218d8c8007Stracker-user if ($seg === '.hg') return true; 3228d8c8007Stracker-user } 3238d8c8007Stracker-user 3248d8c8007Stracker-user // conf/ branch: drop *.dist / *.example / *.bak sample files. They're 3258d8c8007Stracker-user // shipped with DokuWiki and templates, not real configuration. 3268d8c8007Stracker-user if ($archiveRel === 'conf') { 3278d8c8007Stracker-user if (preg_match('/\.(dist|example|bak)$/i', $base)) return true; 3288d8c8007Stracker-user } 3298d8c8007Stracker-user 33029ed7b46Stracker-user return false; 33129ed7b46Stracker-user } 33229ed7b46Stracker-user 333b484d5bcStracker-user /** 334b484d5bcStracker-user * Append a single file entry to the file list. 335b484d5bcStracker-user * 336b484d5bcStracker-user * @param string $abs absolute filesystem path 337b484d5bcStracker-user * @param string $archiveRel path inside the archive 338b484d5bcStracker-user */ 339b484d5bcStracker-user protected function appendFile(string $abs, string $archiveRel): void 34029ed7b46Stracker-user { 341b484d5bcStracker-user $size = filesize($abs); 34229ed7b46Stracker-user if ($size === false) $size = 0; 34329ed7b46Stracker-user $this->fileList[] = [$abs, $archiveRel, $size]; 34429ed7b46Stracker-user $this->totalBytes += $size; 34529ed7b46Stracker-user } 34629ed7b46Stracker-user 34729ed7b46Stracker-user /* ----------------------------------------------------------------- * 34829ed7b46Stracker-user * Preview 34929ed7b46Stracker-user * ----------------------------------------------------------------- */ 35029ed7b46Stracker-user 351b484d5bcStracker-user /** 352b484d5bcStracker-user * Render a summary table grouping files by top-level archive section. 353b484d5bcStracker-user */ 354b484d5bcStracker-user protected function renderPreview(): void 35529ed7b46Stracker-user { 356*c874c2c0Stracker-user echo '<h2>' . hsc($this->getLang('preview_head')) . '</h2>'; 357*c874c2c0Stracker-user echo '<p>' . sprintf( 358*c874c2c0Stracker-user $this->getLang('preview_summary'), 359*c874c2c0Stracker-user count($this->fileList), 360*c874c2c0Stracker-user hsc($this->humanBytes($this->totalBytes)) 361*c874c2c0Stracker-user ) . '</p>'; 36229ed7b46Stracker-user 36329ed7b46Stracker-user $perRoot = []; 36429ed7b46Stracker-user foreach ($this->fileList as [$abs, $rel, $size]) { 36529ed7b46Stracker-user $parts = explode('/', $rel, 4); 36629ed7b46Stracker-user $top = isset($parts[1]) ? ($parts[0] . '/' . $parts[1]) : $parts[0]; 36729ed7b46Stracker-user if (!isset($perRoot[$top])) $perRoot[$top] = ['count' => 0, 'bytes' => 0]; 36829ed7b46Stracker-user $perRoot[$top]['count']++; 36929ed7b46Stracker-user $perRoot[$top]['bytes'] += $size; 37029ed7b46Stracker-user } 37129ed7b46Stracker-user ksort($perRoot); 37229ed7b46Stracker-user 373*c874c2c0Stracker-user echo '<table class="inline"><thead><tr>' 374*c874c2c0Stracker-user . '<th>' . hsc($this->getLang('col_section')) . '</th>' 375*c874c2c0Stracker-user . '<th style="text-align:right;">' . hsc($this->getLang('col_files')) . '</th>' 376*c874c2c0Stracker-user . '<th style="text-align:right;">' . hsc($this->getLang('col_size')) . '</th>' 377*c874c2c0Stracker-user . '</tr></thead><tbody>'; 37829ed7b46Stracker-user foreach ($perRoot as $section => $stats) { 37929ed7b46Stracker-user echo '<tr><td><code>' . hsc($section) . '</code></td>' 38029ed7b46Stracker-user . '<td style="text-align:right;">' . (int)$stats['count'] . '</td>' 38129ed7b46Stracker-user . '<td style="text-align:right;">' . hsc($this->humanBytes($stats['bytes'])) . '</td></tr>'; 38229ed7b46Stracker-user } 38329ed7b46Stracker-user echo '</tbody></table>'; 384*c874c2c0Stracker-user echo '<p>' . $this->getLang('preview_hint') . '</p>'; 38529ed7b46Stracker-user } 38629ed7b46Stracker-user 387b484d5bcStracker-user /** 388b484d5bcStracker-user * Format a byte count as a human-readable string (B, KiB, MiB, GiB, TiB). 389b484d5bcStracker-user * 390b484d5bcStracker-user * @param int $bytes 391b484d5bcStracker-user * @return string 392b484d5bcStracker-user */ 393b484d5bcStracker-user protected function humanBytes(int $bytes): string 39429ed7b46Stracker-user { 39529ed7b46Stracker-user $units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; 39629ed7b46Stracker-user $i = 0; 39729ed7b46Stracker-user $n = (float)$bytes; 39829ed7b46Stracker-user while ($n >= 1024 && $i < count($units) - 1) { 39929ed7b46Stracker-user $n /= 1024; 40029ed7b46Stracker-user $i++; 40129ed7b46Stracker-user } 40229ed7b46Stracker-user return sprintf($i === 0 ? '%d %s' : '%.2f %s', $n, $units[$i]); 40329ed7b46Stracker-user } 40429ed7b46Stracker-user 40529ed7b46Stracker-user /* ----------------------------------------------------------------- * 40629ed7b46Stracker-user * Archive creation + streaming 40729ed7b46Stracker-user * ----------------------------------------------------------------- */ 40829ed7b46Stracker-user 409b484d5bcStracker-user /** 410b484d5bcStracker-user * Build the archive in data/tmp/, stream it to the browser as a tar.gz download, 411b484d5bcStracker-user * and exit. Returns without exiting only when an error prevents streaming, so the 412b484d5bcStracker-user * caller can fall through to html() and display the form again. 413b484d5bcStracker-user */ 414b484d5bcStracker-user protected function streamArchive(): void 41529ed7b46Stracker-user { 416b484d5bcStracker-user global $conf, $INPUT; 41729ed7b46Stracker-user 4188d8c8007Stracker-user // Defense-in-depth: AdminPlugin framework should have blocked non-admins 4198d8c8007Stracker-user // before we got here, but verify directly anyway. 4208d8c8007Stracker-user if (!auth_isadmin()) { 421*c874c2c0Stracker-user msg($this->getLang('err_admin'), -1); 4228d8c8007Stracker-user return; 4238d8c8007Stracker-user } 4248d8c8007Stracker-user 42529ed7b46Stracker-user if (!$this->fileList) { 426*c874c2c0Stracker-user msg($this->getLang('err_empty'), -1); 42729ed7b46Stracker-user return; 42829ed7b46Stracker-user } 42929ed7b46Stracker-user 430b484d5bcStracker-user set_time_limit(0); 431b484d5bcStracker-user ignore_user_abort(true); 432*c874c2c0Stracker-user 433*c874c2c0Stracker-user // Only raise the memory limit, never lower it. 434*c874c2c0Stracker-user $rawLimit = ini_get('memory_limit'); 435*c874c2c0Stracker-user $unit = strtolower(substr($rawLimit, -1)); 436*c874c2c0Stracker-user $limitVal = (int)$rawLimit; 437*c874c2c0Stracker-user switch ($unit) { 438*c874c2c0Stracker-user case 'g': $limitBytes = $limitVal * 1073741824; break; 439*c874c2c0Stracker-user case 'm': $limitBytes = $limitVal * 1048576; break; 440*c874c2c0Stracker-user case 'k': $limitBytes = $limitVal * 1024; break; 441*c874c2c0Stracker-user default: $limitBytes = $limitVal; break; 442*c874c2c0Stracker-user } 443*c874c2c0Stracker-user if ($limitBytes !== -1 && $limitBytes < 268435456) { 444b484d5bcStracker-user ini_set('memory_limit', '256M'); 445*c874c2c0Stracker-user } 44629ed7b46Stracker-user 44729ed7b46Stracker-user $tmpDir = $conf['tmpdir']; 44829ed7b46Stracker-user if (!is_dir($tmpDir) || !is_writable($tmpDir)) { 449*c874c2c0Stracker-user msg(sprintf($this->getLang('err_tmp'), hsc($tmpDir)), -1); 45029ed7b46Stracker-user return; 45129ed7b46Stracker-user } 45229ed7b46Stracker-user 4538d8c8007Stracker-user // Build a hard-to-guess filename. 16 hex chars = 64 bits of entropy from 4548d8c8007Stracker-user // a CSPRNG. The file also lives under data/.htaccess deny-all so even a 4558d8c8007Stracker-user // guess wouldn't be enough. 456b484d5bcStracker-user $host = $INPUT->server->str('HTTP_HOST', 'wiki'); 45729ed7b46Stracker-user $host = preg_replace('/[^a-zA-Z0-9._-]+/', '_', $host); 45829ed7b46Stracker-user $stamp = date('Ymd-His'); 4598d8c8007Stracker-user $archiveDir = $host . '-backup-' . $stamp; // dir inside the tar 4608d8c8007Stracker-user $downloadName = $archiveDir . '.tar.gz'; // browser filename 4618d8c8007Stracker-user $tmpFile = $tmpDir . '/' . self::TMP_PREFIX . bin2hex(random_bytes(8)) . '.tar.gz'; 4628d8c8007Stracker-user 4638d8c8007Stracker-user // Guarantee the temp file is deleted even on connection abort, fatal 4648d8c8007Stracker-user // error, or `exit` from within the streaming loop. 4658d8c8007Stracker-user register_shutdown_function(function () use ($tmpFile) { 466b484d5bcStracker-user if (is_file($tmpFile)) unlink($tmpFile); 4678d8c8007Stracker-user }); 4688d8c8007Stracker-user 469b484d5bcStracker-user $oldUmask = umask(0077); 47029ed7b46Stracker-user 47129ed7b46Stracker-user try { 47229ed7b46Stracker-user $tar = new Tar(); 47329ed7b46Stracker-user $tar->setCompression(6, Archive::COMPRESS_GZIP); 47429ed7b46Stracker-user $tar->create($tmpFile); 47529ed7b46Stracker-user 4768d8c8007Stracker-user // Belt-and-suspenders: explicitly chmod once created, in case the 4778d8c8007Stracker-user // umask wasn't honored (some filesystems / wrappers ignore it). 478b484d5bcStracker-user chmod($tmpFile, 0600); 4798d8c8007Stracker-user 48029ed7b46Stracker-user foreach ($this->fileList as [$abs, $rel, $size]) { 48129ed7b46Stracker-user try { 4828d8c8007Stracker-user $tar->addFile($abs, $archiveDir . '/' . $rel); 48329ed7b46Stracker-user } catch (Exception $e) { 4848d8c8007Stracker-user // Skip individual broken files rather than failing the whole backup. 48529ed7b46Stracker-user continue; 48629ed7b46Stracker-user } 48729ed7b46Stracker-user } 48829ed7b46Stracker-user $tar->close(); 48929ed7b46Stracker-user } catch (ArchiveIOException $e) { 490b484d5bcStracker-user umask($oldUmask); 491b484d5bcStracker-user if (is_file($tmpFile)) unlink($tmpFile); 492*c874c2c0Stracker-user msg(sprintf($this->getLang('err_create'), hsc($e->getMessage())), -1); 49329ed7b46Stracker-user return; 49429ed7b46Stracker-user } 49529ed7b46Stracker-user 496b484d5bcStracker-user umask($oldUmask); 4978d8c8007Stracker-user 49829ed7b46Stracker-user if (!is_file($tmpFile) || filesize($tmpFile) === 0) { 499b484d5bcStracker-user if (is_file($tmpFile)) unlink($tmpFile); 500*c874c2c0Stracker-user msg($this->getLang('err_archive'), -1); 50129ed7b46Stracker-user return; 50229ed7b46Stracker-user } 50329ed7b46Stracker-user 50429ed7b46Stracker-user $size = filesize($tmpFile); 50529ed7b46Stracker-user 5068d8c8007Stracker-user // Clear any output buffering DokuWiki / extensions may have started so 5078d8c8007Stracker-user // headers + binary body go out cleanly. 50829ed7b46Stracker-user while (ob_get_level() > 0) { 509b484d5bcStracker-user ob_end_clean(); 51029ed7b46Stracker-user } 51129ed7b46Stracker-user 51229ed7b46Stracker-user header('Content-Type: application/gzip'); 5138d8c8007Stracker-user header('Content-Disposition: attachment; filename="' . $downloadName . '"'); 51429ed7b46Stracker-user header('Content-Length: ' . $size); 5158d8c8007Stracker-user header('Cache-Control: no-store, no-cache, must-revalidate, private'); 51629ed7b46Stracker-user header('Pragma: no-cache'); 5178d8c8007Stracker-user header('X-Content-Type-Options: nosniff'); 51829ed7b46Stracker-user 51929ed7b46Stracker-user $fp = fopen($tmpFile, 'rb'); 52029ed7b46Stracker-user if ($fp) { 52129ed7b46Stracker-user while (!feof($fp)) { 52229ed7b46Stracker-user $chunk = fread($fp, 1024 * 256); 52329ed7b46Stracker-user if ($chunk === false) break; 52429ed7b46Stracker-user echo $chunk; 525b484d5bcStracker-user flush(); 52629ed7b46Stracker-user } 52729ed7b46Stracker-user fclose($fp); 52829ed7b46Stracker-user } 529b484d5bcStracker-user unlink($tmpFile); 53029ed7b46Stracker-user exit; 53129ed7b46Stracker-user } 5328d8c8007Stracker-user 5338d8c8007Stracker-user /** 5348d8c8007Stracker-user * Remove leftover temp archives from prior runs that died before unlink. 5358d8c8007Stracker-user * Anything matching our prefix older than TMP_STALE_AGE is fair game. 5368d8c8007Stracker-user */ 537b484d5bcStracker-user protected function sweepStaleTempFiles(): void 5388d8c8007Stracker-user { 5398d8c8007Stracker-user global $conf; 5408d8c8007Stracker-user $tmpDir = $conf['tmpdir'] ?? null; 5418d8c8007Stracker-user if (!$tmpDir || !is_dir($tmpDir)) return; 5428d8c8007Stracker-user 5438d8c8007Stracker-user $cutoff = time() - self::TMP_STALE_AGE; 5448d8c8007Stracker-user $pattern = $tmpDir . '/' . self::TMP_PREFIX . '*'; 545b484d5bcStracker-user foreach ((array) glob($pattern) as $stale) { 5468d8c8007Stracker-user if (!is_file($stale)) continue; 547b484d5bcStracker-user $mtime = filemtime($stale); 5488d8c8007Stracker-user if ($mtime !== false && $mtime < $cutoff) { 549b484d5bcStracker-user unlink($stale); 5508d8c8007Stracker-user } 5518d8c8007Stracker-user } 5528d8c8007Stracker-user } 55329ed7b46Stracker-user} 554