xref: /plugin/sitebackup/admin.php (revision 8d8c8007190b12b07a67759b47516b04c81b73ac)
129ed7b46Stracker-user<?php
229ed7b46Stracker-user/**
329ed7b46Stracker-user * Site Backup admin plugin for DokuWiki.
429ed7b46Stracker-user *
5*8d8c8007Stracker-user * Streams a tar.gz of selected wiki parts (pages, media, conf, lib/plugins, lib/tpl)
6*8d8c8007Stracker-user * to the admin's browser. The archive is built in data/tmp/ with a random filename,
7*8d8c8007Stracker-user * streamed out, and deleted immediately. Nothing persists on the server.
829ed7b46Stracker-user *
9*8d8c8007Stracker-user * Security model:
10*8d8c8007Stracker-user *  - Admin-only: DokuWiki's AdminPlugin framework enforces auth_isadmin() before
11*8d8c8007Stracker-user *    handle()/html() are invoked because forAdminOnly() returns true. A second
12*8d8c8007Stracker-user *    explicit check inside streamArchive() guards against any framework bypass.
13*8d8c8007Stracker-user *  - The temp archive lives in $conf['tmpdir'] (data/tmp/), which DokuWiki ships
14*8d8c8007Stracker-user *    with a deny-all .htaccess; it cannot be fetched directly even if the path
15*8d8c8007Stracker-user *    were known.
16*8d8c8007Stracker-user *  - Filename uses 64 bits of CSPRNG randomness, file is chmod'd to 0600, and is
17*8d8c8007Stracker-user *    deleted both at the natural end of streamArchive() and via a shutdown
18*8d8c8007Stracker-user *    function in case the connection is aborted partway.
19*8d8c8007Stracker-user *  - Stale temp files from previous runs (older than 1 hour) are swept on each
20*8d8c8007Stracker-user *    invocation, so even a crash-during-stream leaves nothing for long.
21*8d8c8007Stracker-user *
22*8d8c8007Stracker-user * Treat downloaded archives as credentials: they may include conf/users.auth.php
23*8d8c8007Stracker-user * (password hashes), ACL rules, and any secrets stored in conf/local.php.
2429ed7b46Stracker-user */
2529ed7b46Stracker-user
2629ed7b46Stracker-useruse dokuwiki\Extension\AdminPlugin;
2729ed7b46Stracker-useruse dokuwiki\Form\Form;
2829ed7b46Stracker-useruse splitbrain\PHPArchive\Tar;
2929ed7b46Stracker-useruse splitbrain\PHPArchive\Archive;
3029ed7b46Stracker-useruse splitbrain\PHPArchive\ArchiveIOException;
3129ed7b46Stracker-user
3229ed7b46Stracker-userclass admin_plugin_sitebackup extends AdminPlugin
3329ed7b46Stracker-user{
34*8d8c8007Stracker-user    /** Prefix used for the temp archive filename in data/tmp/. */
35*8d8c8007Stracker-user    const TMP_PREFIX = 'sitebackup_tmp_';
36*8d8c8007Stracker-user
37*8d8c8007Stracker-user    /** Max age (seconds) of leftover temp files before sweep removes them. */
38*8d8c8007Stracker-user    const TMP_STALE_AGE = 3600;
39*8d8c8007Stracker-user
40*8d8c8007Stracker-user    /** @var array list of [absolute path, archive-relative path, size] of files to include */
4129ed7b46Stracker-user    protected $fileList = [];
4229ed7b46Stracker-user
4329ed7b46Stracker-user    /** @var int total uncompressed size of selected files */
4429ed7b46Stracker-user    protected $totalBytes = 0;
4529ed7b46Stracker-user
4629ed7b46Stracker-user    public function forAdminOnly()
4729ed7b46Stracker-user    {
4829ed7b46Stracker-user        return true;
4929ed7b46Stracker-user    }
5029ed7b46Stracker-user
5129ed7b46Stracker-user    public function getMenuSort()
5229ed7b46Stracker-user    {
5329ed7b46Stracker-user        return 1000;
5429ed7b46Stracker-user    }
5529ed7b46Stracker-user
5629ed7b46Stracker-user    public function getMenuText($language)
5729ed7b46Stracker-user    {
5829ed7b46Stracker-user        return 'Site Backup';
5929ed7b46Stracker-user    }
6029ed7b46Stracker-user
6129ed7b46Stracker-user    /**
6229ed7b46Stracker-user     * Dispatch based on the submitted action.
63*8d8c8007Stracker-user     * Valid actions: "preview" (build file list, render summary table),
64*8d8c8007Stracker-user     *                "download" (build archive, stream as tar.gz).
6529ed7b46Stracker-user     */
6629ed7b46Stracker-user    public function handle()
6729ed7b46Stracker-user    {
6829ed7b46Stracker-user        global $INPUT;
69*8d8c8007Stracker-user
70*8d8c8007Stracker-user        // Sweep stale temp files from previous runs every time we enter the page.
71*8d8c8007Stracker-user        $this->sweepStaleTempFiles();
72*8d8c8007Stracker-user
7329ed7b46Stracker-user        if (!$INPUT->has('sitebackup_action')) return;
7429ed7b46Stracker-user        if (!checkSecurityToken()) return;
7529ed7b46Stracker-user
7629ed7b46Stracker-user        $action = $INPUT->str('sitebackup_action');
7729ed7b46Stracker-user        if ($action !== 'preview' && $action !== 'download') return;
7829ed7b46Stracker-user
79*8d8c8007Stracker-user        // Download MUST be POST. Refuse GET / HEAD / etc. so a stray link, browser
80*8d8c8007Stracker-user        // prefetch, or curious co-admin pasting a URL can't trigger a backup.
81*8d8c8007Stracker-user        if ($action === 'download' && ($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
82*8d8c8007Stracker-user            msg('Site Backup: download must be submitted via POST.', -1);
83*8d8c8007Stracker-user            return;
84*8d8c8007Stracker-user        }
85*8d8c8007Stracker-user
8629ed7b46Stracker-user        $this->collectFiles();
8729ed7b46Stracker-user
8829ed7b46Stracker-user        if ($action === 'download') {
8929ed7b46Stracker-user            $this->streamArchive();
90*8d8c8007Stracker-user            // streamArchive() exits on success. If it returns, an error was shown
91*8d8c8007Stracker-user            // via msg() and we fall through to html() so the user sees the form.
9229ed7b46Stracker-user        }
9329ed7b46Stracker-user    }
9429ed7b46Stracker-user
9529ed7b46Stracker-user    public function html()
9629ed7b46Stracker-user    {
9729ed7b46Stracker-user        echo '<h1>Site Backup</h1>';
9829ed7b46Stracker-user        echo '<p>Select what to include, click <em>Preview</em> to see the file list and total size, '
99*8d8c8007Stracker-user            . 'then <em>Download tar.gz</em> to receive the archive in your browser.</p>';
10029ed7b46Stracker-user        echo '<p style="background:#fff3cd;border:1px solid #ffeeba;padding:8px;border-radius:4px;">'
101*8d8c8007Stracker-user            . '<strong>Sensitive content warning.</strong> The archive may contain password hashes '
10229ed7b46Stracker-user            . '(<code>conf/users.auth.php</code>), ACL rules, and any secrets stored in '
103*8d8c8007Stracker-user            . '<code>conf/local.php</code> (DB credentials, SMTP passwords, API keys). '
104*8d8c8007Stracker-user            . 'Treat the download like a credential.'
10529ed7b46Stracker-user            . '</p>';
10629ed7b46Stracker-user
10729ed7b46Stracker-user        $this->renderForm();
10829ed7b46Stracker-user
10929ed7b46Stracker-user        if ($this->fileList) {
11029ed7b46Stracker-user            $this->renderPreview();
11129ed7b46Stracker-user        }
11229ed7b46Stracker-user    }
11329ed7b46Stracker-user
11429ed7b46Stracker-user    /* ----------------------------------------------------------------- *
11529ed7b46Stracker-user     *  Form
11629ed7b46Stracker-user     * ----------------------------------------------------------------- */
11729ed7b46Stracker-user
11829ed7b46Stracker-user    protected function renderForm()
11929ed7b46Stracker-user    {
12029ed7b46Stracker-user        global $INPUT;
12129ed7b46Stracker-user
12229ed7b46Stracker-user        $hasSubmitted = $INPUT->has('sitebackup_action');
12329ed7b46Stracker-user        $defaults = [
12429ed7b46Stracker-user            'pages'       => true,
12529ed7b46Stracker-user            'media'       => true,
12629ed7b46Stracker-user            'meta'        => true,
12729ed7b46Stracker-user            'media_meta'  => true,
12829ed7b46Stracker-user            'attic'       => false,
12929ed7b46Stracker-user            'media_attic' => false,
13029ed7b46Stracker-user            'index'       => false,
13129ed7b46Stracker-user            'conf'        => true,
13229ed7b46Stracker-user            'plugins'     => true,
13329ed7b46Stracker-user            'tpl'         => true,
13429ed7b46Stracker-user        ];
13529ed7b46Stracker-user        $sel = [];
13629ed7b46Stracker-user        foreach ($defaults as $k => $def) {
13729ed7b46Stracker-user            $sel[$k] = $hasSubmitted ? $INPUT->bool('sb_' . $k, false) : $def;
13829ed7b46Stracker-user        }
13929ed7b46Stracker-user
14029ed7b46Stracker-user        $form = new Form(['method' => 'POST', 'id' => 'sitebackup_form']);
14129ed7b46Stracker-user        $form->setHiddenField('do', 'admin');
14229ed7b46Stracker-user        $form->setHiddenField('page', 'sitebackup');
14329ed7b46Stracker-user
14429ed7b46Stracker-user        $form->addFieldsetOpen('Wiki content');
14529ed7b46Stracker-user        $this->addCheckboxRow($form, 'sb_pages',       'Pages (data/pages)',                          $sel['pages']);
14629ed7b46Stracker-user        $this->addCheckboxRow($form, 'sb_media',       'Media files (data/media)',                    $sel['media']);
14729ed7b46Stracker-user        $this->addCheckboxRow($form, 'sb_meta',        'Page metadata (data/meta)',                   $sel['meta']);
14829ed7b46Stracker-user        $this->addCheckboxRow($form, 'sb_media_meta',  'Media metadata (data/media_meta)',            $sel['media_meta']);
14929ed7b46Stracker-user        $this->addCheckboxRow($form, 'sb_attic',       'Page revisions (data/attic) - can be large',  $sel['attic']);
15029ed7b46Stracker-user        $this->addCheckboxRow($form, 'sb_media_attic', 'Media revisions (data/media_attic)',          $sel['media_attic']);
15129ed7b46Stracker-user        $this->addCheckboxRow($form, 'sb_index',       'Search index (data/index) - rebuildable',     $sel['index']);
15229ed7b46Stracker-user        $form->addFieldsetClose();
15329ed7b46Stracker-user
15429ed7b46Stracker-user        $form->addFieldsetOpen('Configuration & code');
15529ed7b46Stracker-user        $this->addCheckboxRow($form, 'sb_conf',    'Configuration (conf/) - includes secrets',  $sel['conf']);
15629ed7b46Stracker-user        $this->addCheckboxRow($form, 'sb_plugins', 'Plugins source (lib/plugins/)',             $sel['plugins']);
15729ed7b46Stracker-user        $this->addCheckboxRow($form, 'sb_tpl',     'Templates source (lib/tpl/)',               $sel['tpl']);
15829ed7b46Stracker-user        $form->addFieldsetClose();
15929ed7b46Stracker-user
16029ed7b46Stracker-user        $form->addTagOpen('p');
16129ed7b46Stracker-user        $form->addButton('sitebackup_action', 'Preview')->val('preview');
16229ed7b46Stracker-user        $form->addHTML(' ');
16329ed7b46Stracker-user        $form->addButton('sitebackup_action', 'Download tar.gz')->val('download');
16429ed7b46Stracker-user        $form->addTagClose('p');
16529ed7b46Stracker-user
16629ed7b46Stracker-user        echo $form->toHTML();
16729ed7b46Stracker-user    }
16829ed7b46Stracker-user
16929ed7b46Stracker-user    protected function addCheckboxRow(Form $form, $name, $label, $checked)
17029ed7b46Stracker-user    {
17129ed7b46Stracker-user        $form->addTagOpen('div')->attr('style', 'margin:4px 0;');
17229ed7b46Stracker-user        $cb = $form->addCheckbox($name, ' ' . $label);
17329ed7b46Stracker-user        $cb->val('1');
17429ed7b46Stracker-user        if ($checked) $cb->attr('checked', 'checked');
17529ed7b46Stracker-user        $form->addTagClose('div');
17629ed7b46Stracker-user    }
17729ed7b46Stracker-user
17829ed7b46Stracker-user    /* ----------------------------------------------------------------- *
17929ed7b46Stracker-user     *  File collection
18029ed7b46Stracker-user     * ----------------------------------------------------------------- */
18129ed7b46Stracker-user
18229ed7b46Stracker-user    protected function collectFiles()
18329ed7b46Stracker-user    {
18429ed7b46Stracker-user        global $INPUT, $conf;
18529ed7b46Stracker-user
186*8d8c8007Stracker-user        // Use $conf[...] for the data dirs so relocated savedir installs still work.
18729ed7b46Stracker-user        $roots = [
18829ed7b46Stracker-user            'sb_pages'       => [$conf['datadir'],        'data/pages'],
18929ed7b46Stracker-user            'sb_media'       => [$conf['mediadir'],       'data/media'],
19029ed7b46Stracker-user            'sb_meta'        => [$conf['metadir'],        'data/meta'],
19129ed7b46Stracker-user            'sb_media_meta'  => [$conf['mediametadir'],   'data/media_meta'],
19229ed7b46Stracker-user            'sb_attic'       => [$conf['olddir'],         'data/attic'],
19329ed7b46Stracker-user            'sb_media_attic' => [$conf['mediaolddir'],    'data/media_attic'],
19429ed7b46Stracker-user            'sb_index'       => [$conf['indexdir'],       'data/index'],
19529ed7b46Stracker-user            'sb_conf'        => [rtrim(DOKU_CONF, '/'),   'conf'],
19629ed7b46Stracker-user            'sb_plugins'     => [rtrim(DOKU_PLUGIN, '/'), 'lib/plugins'],
19729ed7b46Stracker-user            'sb_tpl'         => [DOKU_INC . 'lib/tpl',    'lib/tpl'],
19829ed7b46Stracker-user        ];
19929ed7b46Stracker-user
20029ed7b46Stracker-user        foreach ($roots as $field => $pair) {
20129ed7b46Stracker-user            if (!$INPUT->bool($field, false)) continue;
20229ed7b46Stracker-user            [$srcAbs, $archiveRel] = $pair;
20329ed7b46Stracker-user            $this->walkInto($srcAbs, $archiveRel);
20429ed7b46Stracker-user        }
20529ed7b46Stracker-user    }
20629ed7b46Stracker-user
20729ed7b46Stracker-user    protected function walkInto($srcAbs, $archiveRel)
20829ed7b46Stracker-user    {
20929ed7b46Stracker-user        if (!file_exists($srcAbs)) return;
21029ed7b46Stracker-user
21129ed7b46Stracker-user        if (is_file($srcAbs)) {
21229ed7b46Stracker-user            $this->appendFile($srcAbs, $archiveRel);
21329ed7b46Stracker-user            return;
21429ed7b46Stracker-user        }
21529ed7b46Stracker-user
21629ed7b46Stracker-user        try {
21729ed7b46Stracker-user            $it = new RecursiveIteratorIterator(
218*8d8c8007Stracker-user                new RecursiveDirectoryIterator(
219*8d8c8007Stracker-user                    $srcAbs,
220*8d8c8007Stracker-user                    FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS
221*8d8c8007Stracker-user                ),
22229ed7b46Stracker-user                RecursiveIteratorIterator::LEAVES_ONLY
22329ed7b46Stracker-user            );
22429ed7b46Stracker-user        } catch (Exception $e) {
22529ed7b46Stracker-user            return;
22629ed7b46Stracker-user        }
22729ed7b46Stracker-user
22829ed7b46Stracker-user        $srcRoot = rtrim($srcAbs, '/');
22929ed7b46Stracker-user        $rootLen = strlen($srcRoot) + 1;
23029ed7b46Stracker-user        foreach ($it as $info) {
23129ed7b46Stracker-user            try {
232*8d8c8007Stracker-user                if (!$info->isFile() || !$info->isReadable()) continue;
23329ed7b46Stracker-user                $abs = $info->getPathname();
234*8d8c8007Stracker-user                $rel = str_replace('\\', '/', substr($abs, $rootLen));
23529ed7b46Stracker-user
236*8d8c8007Stracker-user                if ($this->isIgnored($archiveRel, $rel)) continue;
23729ed7b46Stracker-user
23829ed7b46Stracker-user                $this->appendFile($abs, $archiveRel . '/' . $rel);
23929ed7b46Stracker-user            } catch (Exception $e) {
24029ed7b46Stracker-user                continue;
24129ed7b46Stracker-user            }
24229ed7b46Stracker-user        }
24329ed7b46Stracker-user    }
24429ed7b46Stracker-user
24529ed7b46Stracker-user    /**
246*8d8c8007Stracker-user     * Filename / path-segment ignores. Hardcoded (no config) to keep the plugin small.
247*8d8c8007Stracker-user     *
248*8d8c8007Stracker-user     * @param string $archiveRel  e.g. "conf" or "lib/plugins" - the top-level branch
249*8d8c8007Stracker-user     * @param string $rel         path within that branch
25029ed7b46Stracker-user     */
251*8d8c8007Stracker-user    protected function isIgnored($archiveRel, $rel)
25229ed7b46Stracker-user    {
253*8d8c8007Stracker-user        $base = basename($rel);
254*8d8c8007Stracker-user
255*8d8c8007Stracker-user        // Universal noise.
25629ed7b46Stracker-user        if ($base === '_dummy') return true;
25729ed7b46Stracker-user        if ($base === '.DS_Store') return true;
25829ed7b46Stracker-user        if ($base === 'Thumbs.db') return true;
259*8d8c8007Stracker-user
260*8d8c8007Stracker-user        // Belt-and-suspenders: never include our own scratch files even if
261*8d8c8007Stracker-user        // someone pointed savedir at an unusual location.
262*8d8c8007Stracker-user        if (strpos($base, self::TMP_PREFIX) === 0) return true;
263*8d8c8007Stracker-user
264*8d8c8007Stracker-user        // Skip VCS metadata anywhere in any branch. Local clones / checkouts
265*8d8c8007Stracker-user        // can be huge and aren't part of "live" state.
266*8d8c8007Stracker-user        $segments = explode('/', $rel);
267*8d8c8007Stracker-user        foreach ($segments as $seg) {
268*8d8c8007Stracker-user            if ($seg === '.git') return true;
269*8d8c8007Stracker-user            if ($seg === '.svn') return true;
270*8d8c8007Stracker-user            if ($seg === '.hg') return true;
271*8d8c8007Stracker-user        }
272*8d8c8007Stracker-user
273*8d8c8007Stracker-user        // conf/ branch: drop *.dist / *.example / *.bak sample files. They're
274*8d8c8007Stracker-user        // shipped with DokuWiki and templates, not real configuration.
275*8d8c8007Stracker-user        if ($archiveRel === 'conf') {
276*8d8c8007Stracker-user            if (preg_match('/\.(dist|example|bak)$/i', $base)) return true;
277*8d8c8007Stracker-user        }
278*8d8c8007Stracker-user
27929ed7b46Stracker-user        return false;
28029ed7b46Stracker-user    }
28129ed7b46Stracker-user
28229ed7b46Stracker-user    protected function appendFile($abs, $archiveRel)
28329ed7b46Stracker-user    {
28429ed7b46Stracker-user        $size = @filesize($abs);
28529ed7b46Stracker-user        if ($size === false) $size = 0;
28629ed7b46Stracker-user        $this->fileList[] = [$abs, $archiveRel, $size];
28729ed7b46Stracker-user        $this->totalBytes += $size;
28829ed7b46Stracker-user    }
28929ed7b46Stracker-user
29029ed7b46Stracker-user    /* ----------------------------------------------------------------- *
29129ed7b46Stracker-user     *  Preview
29229ed7b46Stracker-user     * ----------------------------------------------------------------- */
29329ed7b46Stracker-user
29429ed7b46Stracker-user    protected function renderPreview()
29529ed7b46Stracker-user    {
29629ed7b46Stracker-user        echo '<h2>Preview</h2>';
29729ed7b46Stracker-user        echo '<p>' . count($this->fileList) . ' files, '
29829ed7b46Stracker-user            . hsc($this->humanBytes($this->totalBytes)) . ' uncompressed.</p>';
29929ed7b46Stracker-user
30029ed7b46Stracker-user        $perRoot = [];
30129ed7b46Stracker-user        foreach ($this->fileList as [$abs, $rel, $size]) {
30229ed7b46Stracker-user            $parts = explode('/', $rel, 4);
30329ed7b46Stracker-user            $top = isset($parts[1]) ? ($parts[0] . '/' . $parts[1]) : $parts[0];
30429ed7b46Stracker-user            if (!isset($perRoot[$top])) $perRoot[$top] = ['count' => 0, 'bytes' => 0];
30529ed7b46Stracker-user            $perRoot[$top]['count']++;
30629ed7b46Stracker-user            $perRoot[$top]['bytes'] += $size;
30729ed7b46Stracker-user        }
30829ed7b46Stracker-user        ksort($perRoot);
30929ed7b46Stracker-user
31029ed7b46Stracker-user        echo '<table class="inline"><thead><tr><th>Section</th><th>Files</th><th>Size</th></tr></thead><tbody>';
31129ed7b46Stracker-user        foreach ($perRoot as $section => $stats) {
31229ed7b46Stracker-user            echo '<tr><td><code>' . hsc($section) . '</code></td>'
31329ed7b46Stracker-user                . '<td style="text-align:right;">' . (int)$stats['count'] . '</td>'
31429ed7b46Stracker-user                . '<td style="text-align:right;">' . hsc($this->humanBytes($stats['bytes'])) . '</td></tr>';
31529ed7b46Stracker-user        }
31629ed7b46Stracker-user        echo '</tbody></table>';
31729ed7b46Stracker-user        echo '<p>Click <em>Download tar.gz</em> above to create and download the archive '
318*8d8c8007Stracker-user            . '(compressed size will typically be smaller).</p>';
31929ed7b46Stracker-user    }
32029ed7b46Stracker-user
32129ed7b46Stracker-user    protected function humanBytes($bytes)
32229ed7b46Stracker-user    {
32329ed7b46Stracker-user        $units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
32429ed7b46Stracker-user        $i = 0;
32529ed7b46Stracker-user        $n = (float)$bytes;
32629ed7b46Stracker-user        while ($n >= 1024 && $i < count($units) - 1) {
32729ed7b46Stracker-user            $n /= 1024;
32829ed7b46Stracker-user            $i++;
32929ed7b46Stracker-user        }
33029ed7b46Stracker-user        return sprintf($i === 0 ? '%d %s' : '%.2f %s', $n, $units[$i]);
33129ed7b46Stracker-user    }
33229ed7b46Stracker-user
33329ed7b46Stracker-user    /* ----------------------------------------------------------------- *
33429ed7b46Stracker-user     *  Archive creation + streaming
33529ed7b46Stracker-user     * ----------------------------------------------------------------- */
33629ed7b46Stracker-user
33729ed7b46Stracker-user    protected function streamArchive()
33829ed7b46Stracker-user    {
33929ed7b46Stracker-user        global $conf;
34029ed7b46Stracker-user
341*8d8c8007Stracker-user        // Defense-in-depth: AdminPlugin framework should have blocked non-admins
342*8d8c8007Stracker-user        // before we got here, but verify directly anyway.
343*8d8c8007Stracker-user        if (!auth_isadmin()) {
344*8d8c8007Stracker-user            msg('Site Backup: admin access required.', -1);
345*8d8c8007Stracker-user            return;
346*8d8c8007Stracker-user        }
347*8d8c8007Stracker-user
34829ed7b46Stracker-user        if (!$this->fileList) {
349*8d8c8007Stracker-user            msg('Site Backup: nothing selected.', -1);
35029ed7b46Stracker-user            return;
35129ed7b46Stracker-user        }
35229ed7b46Stracker-user
35329ed7b46Stracker-user        @set_time_limit(0);
35429ed7b46Stracker-user        @ignore_user_abort(true);
35529ed7b46Stracker-user        @ini_set('memory_limit', '256M');
35629ed7b46Stracker-user
35729ed7b46Stracker-user        $tmpDir = $conf['tmpdir'];
35829ed7b46Stracker-user        if (!is_dir($tmpDir) || !is_writable($tmpDir)) {
35929ed7b46Stracker-user            msg('Site Backup: temp directory is not writable: ' . hsc($tmpDir), -1);
36029ed7b46Stracker-user            return;
36129ed7b46Stracker-user        }
36229ed7b46Stracker-user
363*8d8c8007Stracker-user        // Build a hard-to-guess filename. 16 hex chars = 64 bits of entropy from
364*8d8c8007Stracker-user        // a CSPRNG. The file also lives under data/.htaccess deny-all so even a
365*8d8c8007Stracker-user        // guess wouldn't be enough.
36629ed7b46Stracker-user        $host = $_SERVER['HTTP_HOST'] ?? 'wiki';
36729ed7b46Stracker-user        $host = preg_replace('/[^a-zA-Z0-9._-]+/', '_', $host);
36829ed7b46Stracker-user        $stamp = date('Ymd-His');
369*8d8c8007Stracker-user        $archiveDir = $host . '-backup-' . $stamp;             // dir inside the tar
370*8d8c8007Stracker-user        $downloadName = $archiveDir . '.tar.gz';               // browser filename
371*8d8c8007Stracker-user        $tmpFile = $tmpDir . '/' . self::TMP_PREFIX . bin2hex(random_bytes(8)) . '.tar.gz';
372*8d8c8007Stracker-user
373*8d8c8007Stracker-user        // Guarantee the temp file is deleted even on connection abort, fatal
374*8d8c8007Stracker-user        // error, or `exit` from within the streaming loop.
375*8d8c8007Stracker-user        register_shutdown_function(function () use ($tmpFile) {
376*8d8c8007Stracker-user            if (is_file($tmpFile)) @unlink($tmpFile);
377*8d8c8007Stracker-user        });
378*8d8c8007Stracker-user
379*8d8c8007Stracker-user        $oldUmask = @umask(0077);
38029ed7b46Stracker-user
38129ed7b46Stracker-user        try {
38229ed7b46Stracker-user            $tar = new Tar();
38329ed7b46Stracker-user            $tar->setCompression(6, Archive::COMPRESS_GZIP);
38429ed7b46Stracker-user            $tar->create($tmpFile);
38529ed7b46Stracker-user
386*8d8c8007Stracker-user            // Belt-and-suspenders: explicitly chmod once created, in case the
387*8d8c8007Stracker-user            // umask wasn't honored (some filesystems / wrappers ignore it).
388*8d8c8007Stracker-user            @chmod($tmpFile, 0600);
389*8d8c8007Stracker-user
39029ed7b46Stracker-user            foreach ($this->fileList as [$abs, $rel, $size]) {
39129ed7b46Stracker-user                try {
392*8d8c8007Stracker-user                    $tar->addFile($abs, $archiveDir . '/' . $rel);
39329ed7b46Stracker-user                } catch (Exception $e) {
394*8d8c8007Stracker-user                    // Skip individual broken files rather than failing the whole backup.
39529ed7b46Stracker-user                    continue;
39629ed7b46Stracker-user                }
39729ed7b46Stracker-user            }
39829ed7b46Stracker-user            $tar->close();
39929ed7b46Stracker-user        } catch (ArchiveIOException $e) {
400*8d8c8007Stracker-user            @umask($oldUmask);
40129ed7b46Stracker-user            @unlink($tmpFile);
40229ed7b46Stracker-user            msg('Site Backup: could not create archive: ' . hsc($e->getMessage()), -1);
40329ed7b46Stracker-user            return;
40429ed7b46Stracker-user        }
40529ed7b46Stracker-user
406*8d8c8007Stracker-user        @umask($oldUmask);
407*8d8c8007Stracker-user
40829ed7b46Stracker-user        if (!is_file($tmpFile) || filesize($tmpFile) === 0) {
40929ed7b46Stracker-user            @unlink($tmpFile);
41029ed7b46Stracker-user            msg('Site Backup: archive was empty or could not be written.', -1);
41129ed7b46Stracker-user            return;
41229ed7b46Stracker-user        }
41329ed7b46Stracker-user
41429ed7b46Stracker-user        $size = filesize($tmpFile);
41529ed7b46Stracker-user
416*8d8c8007Stracker-user        // Clear any output buffering DokuWiki / extensions may have started so
417*8d8c8007Stracker-user        // headers + binary body go out cleanly.
41829ed7b46Stracker-user        while (ob_get_level() > 0) {
41929ed7b46Stracker-user            @ob_end_clean();
42029ed7b46Stracker-user        }
42129ed7b46Stracker-user
42229ed7b46Stracker-user        header('Content-Type: application/gzip');
423*8d8c8007Stracker-user        header('Content-Disposition: attachment; filename="' . $downloadName . '"');
42429ed7b46Stracker-user        header('Content-Length: ' . $size);
425*8d8c8007Stracker-user        header('Cache-Control: no-store, no-cache, must-revalidate, private');
42629ed7b46Stracker-user        header('Pragma: no-cache');
427*8d8c8007Stracker-user        header('X-Content-Type-Options: nosniff');
42829ed7b46Stracker-user
42929ed7b46Stracker-user        $fp = fopen($tmpFile, 'rb');
43029ed7b46Stracker-user        if ($fp) {
43129ed7b46Stracker-user            while (!feof($fp)) {
43229ed7b46Stracker-user                $chunk = fread($fp, 1024 * 256);
43329ed7b46Stracker-user                if ($chunk === false) break;
43429ed7b46Stracker-user                echo $chunk;
43529ed7b46Stracker-user                @flush();
43629ed7b46Stracker-user            }
43729ed7b46Stracker-user            fclose($fp);
43829ed7b46Stracker-user        }
43929ed7b46Stracker-user        @unlink($tmpFile);
44029ed7b46Stracker-user        exit;
44129ed7b46Stracker-user    }
442*8d8c8007Stracker-user
443*8d8c8007Stracker-user    /**
444*8d8c8007Stracker-user     * Remove leftover temp archives from prior runs that died before unlink.
445*8d8c8007Stracker-user     * Anything matching our prefix older than TMP_STALE_AGE is fair game.
446*8d8c8007Stracker-user     */
447*8d8c8007Stracker-user    protected function sweepStaleTempFiles()
448*8d8c8007Stracker-user    {
449*8d8c8007Stracker-user        global $conf;
450*8d8c8007Stracker-user        $tmpDir = $conf['tmpdir'] ?? null;
451*8d8c8007Stracker-user        if (!$tmpDir || !is_dir($tmpDir)) return;
452*8d8c8007Stracker-user
453*8d8c8007Stracker-user        $cutoff = time() - self::TMP_STALE_AGE;
454*8d8c8007Stracker-user        $pattern = $tmpDir . '/' . self::TMP_PREFIX . '*';
455*8d8c8007Stracker-user        foreach ((array) @glob($pattern) as $stale) {
456*8d8c8007Stracker-user            if (!is_file($stale)) continue;
457*8d8c8007Stracker-user            $mtime = @filemtime($stale);
458*8d8c8007Stracker-user            if ($mtime !== false && $mtime < $cutoff) {
459*8d8c8007Stracker-user                @unlink($stale);
460*8d8c8007Stracker-user            }
461*8d8c8007Stracker-user        }
462*8d8c8007Stracker-user    }
46329ed7b46Stracker-user}
464