xref: /plugin/sitebackup/admin.php (revision c874c2c0a464f5afa1b4711c21d732aad51b8080)
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(' &nbsp;&nbsp; ');
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