1<?php
2
3use splitbrain\PHPArchive\Tar;
4
5/**
6 * Backup Tool for DokuWiki
7 *
8 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
9 * @author     Terence J. Grant<tjgrant@tatewake.com>
10 * @author     Andreas Wagner <andreas.wagner@em.uni-frankfurt.de>
11 * @author     Andreas Gohr <gohr@cosmocode.de>
12 */
13class admin_plugin_backup extends DokuWiki_Admin_Plugin
14{
15    protected $prefFile = DOKU_CONF . 'backup.json';
16    protected $filters = null;
17
18    protected function isRunningWindows()
19    {
20        return (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') ? true : false;
21    }
22
23    public function btRemoveFiles($dir, $startString)
24    {
25        if (is_dir($dir)) {
26            $objects = scandir($dir);
27
28            foreach ($objects as $object) {
29                if ($object != "." && $object != ".." && substr($object, 0, strlen($startString)) === $startString) {
30                    if (!is_dir($dir. DIRECTORY_SEPARATOR .$object) || is_link($dir."/".$object)) {
31                        unlink($dir. DIRECTORY_SEPARATOR .$object);
32                    }
33                }
34            }
35        }
36    }
37
38    /** @inheritdoc */
39    public function handle()
40    {
41        global $INPUT;
42        if ($INPUT->post->has('pref') && checkSecurityToken()) {
43            $this->savePreferences($INPUT->post->arr('pref'));
44        }
45    }
46
47    /**
48     * output appropriate html
49     */
50    public function html()
51    {
52        global $INPUT;
53
54        echo '<div class="plugin_backup">';
55
56        if ($INPUT->post->bool('backup')) {
57            $this->removeMediaAtticBackups();
58            $this->runBackup();
59        } else {
60            echo '<h1>' . $this->getLang('menu') . '</h1>';
61
62            if ($this->isRunningWindows()) {
63                msg($this->getLang('windows-msg'), 2);
64            }
65
66            if ($this->isRunningWindows()) {
67                echo '<div class="bt-warning" style="display: block;">';
68                echo $this->locale_xhtml('windows');
69                echo '<button type="button" class="collapsible">I understand</button>';
70                echo '</div>';
71
72                echo '<div class="bt-content" style="display: none;">';
73            } else {
74                echo '<div>';
75            }
76
77            echo $this->locale_xhtml('intro');
78
79            echo $this->getForm();
80
81            $this->listBackups();
82
83            echo $this->locale_xhtml('donate');
84            echo '</div>';
85        }
86
87        echo '</div>';
88    }
89
90    /**
91     * Lists the 5 most recent backups if any.
92     */
93    protected function listBackups()
94    {
95        global $ID;
96        $ns = $this->getConf('backupnamespace');
97        $link = wl($ID, ['do' => 'media', 'ns' => $ns]);
98
99        echo '<div class="recent">';
100
101        $backups = glob(dirname(mediaFN("$ns:foo")) . '/*.tar*');
102        rsort($backups);
103        $backups = array_slice($backups, 0, 5);
104        if ($backups) {
105            echo '<h2>' . $this->getLang('recent') . '</h2>';
106            echo '<ul>';
107            foreach ($backups as $full) {
108                $backup = basename($full);
109                $url = ml("$ns:$backup");
110                echo '<li><div class="li">';
111                echo '<a href="' . $url . '">' . $backup . '</a> ';
112                echo filesize_h(filesize($full));
113                echo ' ';
114                echo dformat(filemtime($full), '%f');
115                echo '</div></li>';
116            }
117            echo '</ul>';
118        }
119
120        echo '<p>' . sprintf($this->getLang('medians'), $ns, $link) . '</p>';
121        echo '</div>';
122    }
123
124    protected function removeMediaAtticBackups()
125    {
126        try {
127            global $conf;
128
129            $self = fullpath(dirname(mediaFN($this->getConf('backupnamespace') . ':foo')));
130            $targetdir = $conf['mediaolddir'] . '/' . $this->stripPrefix($self, fullpath(dirname(mediaFN($conf['savedir']))));
131
132            $this->btRemoveFiles($targetdir, 'dw-backup-');
133        } catch (Exception $e) {
134        }
135    }
136
137    /**
138     * Runs the backup process with XHTML output
139     */
140    protected function runBackup()
141    {
142        echo '<h1>' . $this->getLang('menu') . '</h1>';
143        echo '<p class="bt-running">';
144        echo hsc($this->getLang('running'));
145        echo '&nbsp;';
146        echo '<img src="' . DOKU_BASE . 'lib/plugins/backup/spinner.gif" alt="…" />';
147        echo '</p>';
148
149        $id = $this->createBackupID();
150        $fn = mediaFN($id);
151        try {
152            echo '<div class="log">';
153            tpl_flush();
154            $this->createBackup($fn, $this->loadPreferences(), [$this, 'logXHTML']);
155            echo '</div>';
156            msg(sprintf($this->getLang('success'), ml($id), $id), 1);
157        } catch (\splitbrain\PHPArchive\ArchiveIOException $e) {
158            echo '</div>'; // close the log wrapping
159            msg('Backup failed. ' . $e->getMessage(), -1);
160            @unlink($fn);
161        }
162
163        echo '<script>document.getElementsByClassName(\'bt-running\')[0].style.display=\'none\';</script>';
164    }
165
166    /**
167     * The logger to output the progress of the backup
168     *
169     * We want the filenames a little bit less prominent, so we handle those differently
170     *
171     * @param string $msg
172     * @param int $level
173     */
174    protected function logXHTML($msg, $level = 0)
175    {
176        if ($level === -1 || $level === 1) {
177            msg(hsc($msg), $level);
178        } else {
179            echo '<div>' . hsc($msg) . '</div>';
180        }
181        ob_flush();
182        flush();
183    }
184
185    /**
186     * Create the preference form
187     *
188     * @return string
189     */
190    protected function getForm()
191    {
192        global $ID;
193        $form = new \dokuwiki\Form\Form([
194            'method' => 'POST',
195            'action' => wl($ID, ['do' => 'admin', 'page' => 'backup'], false, '&')
196        ]);
197        $form->addFieldsetOpen($this->getLang('components'));
198
199        $prefs = $this->loadPreferences();
200        foreach ($prefs as $pref => $val) {
201            $label = $this->getLang('bt_' . $pref);
202            if (!$label) {
203                continue;
204            } // unknown pref, skip it
205
206            $form->setHiddenField("pref[$pref]", '0');
207            $cb = $form->addCheckbox("pref[$pref]", $label)->useInput(false)->addClass('block');
208            if ($val) {
209                $cb->attr('checked', 'checked');
210            }
211        }
212
213        $form->addButton('backup', $this->getLang('bt_create_backup'));
214        return $form->toHTML();
215    }
216
217    /**
218     * Get the currently saved preferences
219     *
220     * @return array
221     */
222    protected function loadPreferences()
223    {
224        $prefs = [
225            'config' => 1,
226            'pages' => 1,
227            'revisions' => 1,
228            'meta' => 1,
229            'media' => 1,
230            'mediarevs' => 0,
231            'mediameta' => 1,
232            'templates' => 0,
233            'plugins' => 0
234        ];
235        // load and merge saved preferences
236        if (file_exists($this->prefFile)) {
237            $more = json_decode(io_readFile($this->prefFile, false), true);
238            $prefs = array_merge($prefs, $more);
239        }
240
241        return $prefs;
242    }
243
244    /**
245     * Store the backup preferences
246     *
247     * @param array $prefs
248     */
249    protected function savePreferences($prefs)
250    {
251        $prefs = array_map('intval', $prefs);
252        io_saveFile($this->prefFile, json_encode($prefs, JSON_PRETTY_PRINT));
253    }
254
255    /**
256     * Generate a new unique backup name
257     *
258     * @return string
259     */
260    protected function createBackupID()
261    {
262        $tarfilename = 'dw-backup-' . date('Ymd-His') . '.tar';
263        if (extension_loaded('bz2')) {
264            $tarfilename .= '.bz2';
265        } elseif (extension_loaded('gz')) {
266            $tarfilename .= '.gz';
267        }
268        return cleanID($this->getConf('backupnamespace') . ':' . $tarfilename);
269    }
270
271    /**
272     * Create the backup
273     *
274     * @param string $fn Filename of the backup archive
275     * @param array $prefs
276     * @param Callable $logger A method compatible to DokuWiki's msg()
277     * @throws \splitbrain\PHPArchive\ArchiveIOException
278     */
279    protected function createBackup($fn, $prefs, $logger)
280    {
281        @set_time_limit(0);
282        io_mkdir_p(dirname($fn));
283        $tar = new Tar();
284        $tar->create($fn);
285
286        foreach ($prefs as $pref => $val) {
287            if (!$val) {
288                continue;
289            }
290
291            $cmd = [$this, 'backup' . ucfirst($pref)];
292            if (is_callable($cmd)) {
293                $cmd($tar, $logger);
294            } else {
295                $logger('Can\'t call ' . $cmd[1], -1);
296            }
297        }
298
299        $tar->close();
300    }
301
302    /**
303     * Adds the given directory recursively to the tar archive
304     *
305     * @param Tar $tar
306     * @param string $dir The original directory
307     * @param string $as The directory name to use in the archive
308     * @param Callable|null $logger msg() compatible logger
309     * @param Callable|null $filter a filter method, returns true for all files to add
310     * @throws \splitbrain\PHPArchive\ArchiveCorruptedException
311     * @throws \splitbrain\PHPArchive\ArchiveIOException
312     */
313    protected function addDirectoryToTar(Tar $tar, $dir, $as, $logger = null, $filter = null)
314    {
315        $dir = fullpath($dir);
316        $ri = new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS);
317        $rii = new RecursiveIteratorIterator($ri, RecursiveIteratorIterator::SELF_FIRST);
318
319        foreach ($rii as $path => $info) {
320            $file = $this->stripPrefix($path, $dir);
321            $file = $as . '/' . $file;
322
323            // custom filter:
324            if ($filter !== null && !$filter($file)) {
325                continue;
326            }
327            if (!$this->defaultFilter($file)) {
328                continue;
329            }
330
331            if ($logger !== null) {
332                $logger($file);
333            }
334            $tar->addFile($path, $file);
335        }
336    }
337
338    /**
339     * Checks the default filters against the given backup path
340     *
341     * We also filter .git directories
342     *
343     * @param string $path the backup path
344     * @return bool true if the file should be backed up, false if not
345     */
346    protected function defaultFilter($path)
347    {
348        if ($this->filters === null) {
349            $this->filters = explode("\n", $this->getConf('filterdirs'));
350            $this->filters = array_map('trim', $this->filters);
351            $this->filters = array_filter($this->filters);
352        }
353
354        if (strpos($path, '/.git') !== false) {
355            return false;
356        }
357
358        foreach ($this->filters as $filter) {
359            if (strpos($path, $filter) === 0) {
360                return false;
361            }
362        }
363
364        return true;
365    }
366
367    /**
368     * Strip the given prefix from the directory
369     *
370     * @param string $dir
371     * @param string $prefix
372     * @return string
373     */
374    protected function stripPrefix($dir, $prefix)
375    {
376        if (strpos($dir, $prefix) === 0) {
377            $dir = substr($dir, strlen($prefix));
378        }
379        return ltrim($dir, '/');
380    }
381
382    // region backup components
383
384    /**
385     * Backup the config files
386     *
387     * @param Tar $tar
388     * @param Callable $logger
389     * @throws \splitbrain\PHPArchive\ArchiveCorruptedException
390     * @throws \splitbrain\PHPArchive\ArchiveIOException
391     */
392    protected function backupConfig(Tar $tar, $logger)
393    {
394        $this->addDirectoryToTar($tar, DOKU_CONF, 'conf', $logger, function ($path) {
395            return !preg_match('/\.(dist|example|bak)/', $path);
396        });
397        // we consider the preload a config file
398        if (file_exists(DOKU_INC . 'inc/preload.php')) {
399            $tar->addFile(DOKU_INC . 'inc/preload.php', 'inc/preload.php');
400        }
401    }
402
403    /**
404     * Backup the pages
405     *
406     * @param Tar $tar
407     * @param Callable $logger
408     * @throws \splitbrain\PHPArchive\ArchiveCorruptedException
409     * @throws \splitbrain\PHPArchive\ArchiveIOException
410     */
411    protected function backupPages(Tar $tar, $logger)
412    {
413        global $conf;
414        $this->addDirectoryToTar($tar, $conf['datadir'], 'data/pages', $logger);
415    }
416
417    /**
418     * Backup the page revisions
419     *
420     * @param Tar $tar
421     * @param Callable $logger
422     * @throws \splitbrain\PHPArchive\ArchiveCorruptedException
423     * @throws \splitbrain\PHPArchive\ArchiveIOException
424     */
425    protected function backupRevisions(Tar $tar, $logger)
426    {
427        global $conf;
428        $this->addDirectoryToTar($tar, $conf['olddir'], 'data/attic', $logger);
429    }
430
431    /**
432     * Backup the meta files
433     *
434     * @param Tar $tar
435     * @param Callable $logger
436     * @throws \splitbrain\PHPArchive\ArchiveCorruptedException
437     * @throws \splitbrain\PHPArchive\ArchiveIOException
438     */
439    protected function backupMeta(Tar $tar, $logger)
440    {
441        global $conf;
442        $this->addDirectoryToTar($tar, $conf['metadir'], 'data/meta', $logger);
443    }
444
445    /**
446     * Backup the media files
447     *
448     * @param Tar $tar
449     * @param Callable $logger
450     * @throws \splitbrain\PHPArchive\ArchiveCorruptedException
451     * @throws \splitbrain\PHPArchive\ArchiveIOException
452     */
453    protected function backupMedia(Tar $tar, $logger)
454    {
455        global $conf;
456
457        // figure out what our backup folder would be called within the backup
458        $media = fullpath(dirname(mediaFN('foo')));
459        $self = fullpath(dirname(mediaFN($this->getConf('backupnamespace') . ':foo')));
460        $relself = 'data/media/' . $this->stripPrefix($self, $media);
461
462        $this->addDirectoryToTar($tar, $conf['mediadir'], 'data/media', $logger, function ($path) use ($relself) {
463            // skip our own backups
464            return (strpos($path, $relself) !== 0);
465        });
466    }
467
468    /**
469     * Backup the media revisions
470     *
471     * @param Tar $tar
472     * @param Callable $logger
473     * @throws \splitbrain\PHPArchive\ArchiveCorruptedException
474     * @throws \splitbrain\PHPArchive\ArchiveIOException
475     */
476    protected function backupMediarevs(Tar $tar, $logger)
477    {
478        global $conf;
479
480        // figure out what our backup folder would be called within the backup
481        $media = fullpath(dirname(mediaFN('foo')));
482        $self = fullpath(dirname(mediaFN($this->getConf('backupnamespace') . ':foo')));
483        $relself = 'data/media_attic/' . $this->stripPrefix($self, $media);
484
485        $this->addDirectoryToTar($tar, $conf['mediaolddir'], 'data/media_attic', $logger, function ($path) use ($relself) {
486            // skip our own backups
487            return (strpos($path, $relself) !== 0);
488        });
489    }
490
491    /**
492     * Backup the media meta info
493     *
494     * @param Tar $tar
495     * @param Callable $logger
496     * @throws \splitbrain\PHPArchive\ArchiveCorruptedException
497     * @throws \splitbrain\PHPArchive\ArchiveIOException
498     */
499    protected function backupMediameta(Tar $tar, $logger)
500    {
501        global $conf;
502
503        // figure out what our backup folder would be called within the backup
504        $media = fullpath(dirname(mediaFN('foo')));
505        $self = fullpath(dirname(mediaFN($this->getConf('backupnamespace') . ':foo')));
506        $relself = 'data/media_meta/' . $this->stripPrefix($self, $media);
507
508        $this->addDirectoryToTar($tar, $conf['mediametadir'], 'data/media_meta', $logger, function ($path) use ($relself) {
509            // skip our own backups
510            return (strpos($path, $relself) !== 0);
511        });
512    }
513
514    /**
515     * Backup the templates
516     *
517     * @param Tar $tar
518     * @param Callable $logger
519     * @throws \splitbrain\PHPArchive\ArchiveCorruptedException
520     * @throws \splitbrain\PHPArchive\ArchiveIOException
521     */
522    protected function backupTemplates(Tar $tar, $logger)
523    {
524        // FIXME skip builtin ones
525        $this->addDirectoryToTar($tar, DOKU_INC . 'lib/tpl', 'lib/tpl', $logger);
526    }
527
528    /**
529     * Backup the plugins
530     *
531     * @param Tar $tar
532     * @param Callable $logger
533     * @throws \splitbrain\PHPArchive\ArchiveCorruptedException
534     * @throws \splitbrain\PHPArchive\ArchiveIOException
535     */
536    protected function backupPlugins(Tar $tar, $logger)
537    {
538        // FIXME skip builtin ones
539        $this->addDirectoryToTar($tar, DOKU_INC . 'lib/plugins', 'lib/plugins', $logger);
540    }
541
542    // endregion
543}
544