1<?php
2
3use splitbrain\PHPArchive\Zip;
4
5/**
6 * DokuWiki Plugin archivegenerator (Admin Component)
7 *
8 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
9 * @author  Michael Große <dokuwiki@cosmocode.de>
10 */
11class admin_plugin_archivegenerator extends DokuWiki_Admin_Plugin
12{
13
14    protected $generateArchive = false;
15    protected $type = 'full';
16    protected $base = 'dokuwiki/';
17
18    /** @inheritdoc */
19    public function getMenuSort()
20    {
21        return 123;
22    }
23
24    /** @inheritdoc */
25    public function forAdminOnly()
26    {
27        return true;
28    }
29
30    /** @inheritdoc */
31    public function handle()
32    {
33        global $INPUT;
34
35        if ($INPUT->bool('isupdate')) $this->type = 'update';
36
37        if ($INPUT->bool('downloadArchive') && checkSecurityToken()) {
38            $this->sendArchiveAndExit();
39        }
40
41        if ($INPUT->server->str('REQUEST_METHOD') !== 'POST') {
42            return;
43        }
44
45
46        $sectok = $INPUT->post->str('sectok');
47        if (!checkSecurityToken($sectok)) {
48            return;
49        }
50
51        // check for email and pass on full archives only
52        if ($this->type == 'full') {
53            $email = $INPUT->post->str('adminMail');
54            $pass = $INPUT->post->str('adminPass');
55
56            if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
57                msg(sprintf($this->getLang('message: email invalid'), hsc($email)), -1);
58                return;
59            }
60
61            if (empty($pass)) {
62                msg($this->getLang('message: password empty'), -1);
63                return;
64            }
65        }
66
67        $this->generateArchive = true;
68    }
69
70    /** @inheritdoc */
71    public function html()
72    {
73        if (!$this->generateArchive) {
74            $this->downloadView();
75
76            ptln('<h1>' . $this->getLang('menu') . '</h1>');
77            echo $this->locale_xhtml('intro');
78        } else {
79            ptln('<h1>' . $this->getLang('menu') . '</h1>');
80            try {
81                if ($this->type == 'full') {
82                    $this->generateArchive();
83                } else {
84                    $this->generateUpdateArchive();
85                }
86                return;
87            } catch (\splitbrain\PHPArchive\ArchiveIOException $e) {
88                msg(hsc($e->getMessage()), -1);
89            }
90        }
91        $this->showFullForm();
92        $this->showUpdateForm();
93    }
94
95    /**
96     * Send the existing wiki archive file and exit
97     */
98    protected function sendArchiveAndExit()
99    {
100        global $conf;
101        if ($this->type == 'full') {
102            $persistentArchiveFN = $conf['tmpdir'] . '/archivegenerator/archive.zip';
103        } else {
104            $persistentArchiveFN = $conf['tmpdir'] . '/archivegenerator/archive-update.zip';
105        }
106        header('Content-Type: application/zip');
107        header('Content-Disposition: attachment; filename="archive.zip"');
108        http_sendfile($persistentArchiveFN);
109        readfile($persistentArchiveFN);
110        exit();
111    }
112
113    /**
114     * Build the archive based on the existing wiki
115     *
116     * @throws \splitbrain\PHPArchive\ArchiveIOException
117     */
118    protected function generateArchive()
119    {
120        global $conf;
121        $this->log('info', $this->getLang('message: starting'));
122        $tmpArchiveFN = $conf['tmpdir'] . '/archivegenerator/archive_new.zip';
123        $archive = $this->createZipArchive($tmpArchiveFN);
124        set_time_limit(0);
125        $this->addDirToArchive($archive, '.', false);
126        $this->addDirToArchive($archive, 'inc');
127        $this->addDirToArchive($archive, 'bin');
128        $this->addDirToArchive($archive, 'vendor');
129        $this->addDirToArchive($archive, 'conf', true, '^' . $this->base . 'conf/(users\.auth\.php|acl\.auth\.php)$');
130        $this->addUsersAuthToArchive($archive);
131        $this->addACLToArchive($archive);
132        $this->addDirToArchive($archive, 'lib', true, '^' . $this->base . 'lib/plugins$');
133        $this->addDirToArchive($archive, 'lib/plugins', true, $this->buildSkipPluginRegex());
134        $this->addDirToArchive($archive, 'data/pages');
135        $this->addDirToArchive($archive, 'data/meta', true, '\.changes(\.trimmed)?$');
136        $this->addDirToArchive($archive, 'data/media');
137        $this->addDirToArchive($archive, 'data/media_meta', true, '\.changes$');
138        $this->addDirToArchive($archive, 'data/index');
139
140        $this->addEmptyDirToArchive($archive, 'data/attic');
141        $this->addEmptyDirToArchive($archive, 'data/cache');
142        $this->addEmptyDirToArchive($archive, 'data/locks');
143        $this->addEmptyDirToArchive($archive, 'data/tmp');
144        $this->addEmptyDirToArchive($archive, 'data/media_attic');
145
146        $archive->close();
147        $this->log('info', $this->getLang('message: adding data done'));
148
149        $persistentArchiveFN = $conf['tmpdir'] . '/archivegenerator/archive.zip';
150        io_rename($tmpArchiveFN, $persistentArchiveFN);
151
152        $href = $this->getDownloadLinkHref();
153        $link = "<a href=\"$href\">" . $this->getLang('link: download now') . '</a>';
154        $this->log('success', $this->getLang('message: done') . ' ' . $link);
155
156        // try a redirect to self
157        ptln('<script type="text/javascript">window.location.href=\'' . $this->getSelfRedirect() . '\';</script>');
158    }
159
160    /**
161     * Build an update archive based on the existing wiki
162     *
163     * @throws \splitbrain\PHPArchive\ArchiveIOException
164     */
165    protected function generateUpdateArchive()
166    {
167        global $conf;
168        $this->log('info', $this->getLang('message: starting'));
169        $tmpArchiveFN = $conf['tmpdir'] . '/archivegenerator/archive-update_new.zip';
170        $archive = $this->createZipArchive($tmpArchiveFN);
171        set_time_limit(0);
172        $this->addDirToArchive($archive, '.', false);
173        $this->addDirToArchive($archive, 'inc');
174        $this->addDirToArchive($archive, 'bin');
175        $this->addDirToArchive($archive, 'vendor');
176        $this->addDirToArchive($archive, 'conf', true, '^' . $this->base . 'conf/(users\.auth\.php|acl\.auth\.php|.*local\.(php|conf))$');
177        $this->addDirToArchive($archive, 'lib', true, '^' . $this->base . 'lib/plugins$');
178        $this->addDirToArchive($archive, 'lib/plugins', true, $this->buildSkipPluginRegex());
179
180        $this->addEmptyDirToArchive($archive, 'data/pages');
181        $this->addEmptyDirToArchive($archive, 'data/media');
182        $this->addEmptyDirToArchive($archive, 'data/index');
183        $this->addEmptyDirToArchive($archive, 'data/media_meta');
184        $this->addEmptyDirToArchive($archive, 'data/meta');
185        $this->addEmptyDirToArchive($archive, 'data/attic');
186        $this->addEmptyDirToArchive($archive, 'data/cache');
187        $this->addEmptyDirToArchive($archive, 'data/locks');
188        $this->addEmptyDirToArchive($archive, 'data/tmp');
189        $this->addEmptyDirToArchive($archive, 'data/media_attic');
190
191        $archive->close();
192        $this->log('info', $this->getLang('message: adding data done'));
193
194        $persistentArchiveFN = $conf['tmpdir'] . '/archivegenerator/archive-update.zip';
195        io_rename($tmpArchiveFN, $persistentArchiveFN);
196
197        $href = $this->getDownloadLinkHref('update');
198        $link = "<a href=\"$href\">" . $this->getLang('link: download now') . '</a>';
199        $this->log('success', $this->getLang('message: done') . ' ' . $link);
200
201        // try a redirect to self
202        ptln('<script type="text/javascript">window.location.href=\'' . $this->getSelfRedirect() . '\';</script>');
203    }
204
205    /**
206     * Build a regex for the plugins to skip, relative to the DokuWiki root
207     *
208     * @return string
209     */
210    protected function buildSkipPluginRegex()
211    {
212        $list = array_map('trim', explode(',', $this->getConf('pluginsToIgnore')));
213        return '^' . $this->base . 'lib/plugins/(' . implode('|', $list) . ')$';
214    }
215
216    /**
217     * Generate a href for a link to download the archive
218     *
219     * @param string $type
220     * @return string
221     */
222    protected function getDownloadLinkHref($type = 'full')
223    {
224        global $ID;
225        return wl($ID, [
226            'do' => 'admin',
227            'page' => 'archivegenerator',
228            'downloadArchive' => 1,
229            'sectok' => getSecurityToken(),
230            'isupdate' => (int)($type == 'update'),
231        ]);
232    }
233
234    /**
235     * Generate the link to the admin page itself
236     *
237     * @return string
238     */
239    protected function getSelfRedirect()
240    {
241        global $ID;
242        return wl($ID, [
243            'do' => 'admin',
244            'page' => 'archivegenerator',
245        ], false, '&');
246    }
247
248    /**
249     * Add an empty directory to the archive.
250     *
251     * The directory will contain a dummy .keep file.
252     *
253     * @param Zip $archive
254     * @param string $directory path of the directory to add relative to the dokuwiki root
255     *
256     * @throws \splitbrain\PHPArchive\ArchiveIOException
257     */
258    protected function addEmptyDirToArchive(Zip $archive, $directory)
259    {
260        $this->log('info', sprintf($this->getLang('message: create empty dir'), $directory));
261        $dirPath = $this->base . $directory . '/.keep';
262        $archive->addData($dirPath, '');
263    }
264
265    /**
266     * Create a users.auth.php file with a single admin user
267     *
268     * @param Zip $archive
269     *
270     * @throws \splitbrain\PHPArchive\ArchiveIOException
271     */
272    protected function addUsersAuthToArchive(Zip $archive)
273    {
274        global $INPUT;
275
276        $email = $INPUT->post->str('adminMail');
277        $pass = $INPUT->post->str('adminPass');
278
279        $this->log('info', $this->getLang('message: create users'));
280        $authFile = '
281# users.auth.php
282# <?php exit()?>
283# Don\'t modify the lines above
284#
285# Userfile
286#
287# Format:
288#
289# login:passwordhash:Real Name:email:groups,comma,separated
290
291        ';
292
293        $pwHash = auth_cryptPassword($pass);
294        $adminLine = "admin:$pwHash:Administrator:$email:user,admin\n";
295        $archive->addData($this->base . 'conf/users.auth.php', $authFile . $adminLine);
296    }
297
298    /**
299     * Create an acl.auth.php file that allows reading only for logged-in users
300     *
301     * @param Zip $archive
302     *
303     * @throws \splitbrain\PHPArchive\ArchiveIOException
304     */
305    protected function addACLToArchive(Zip $archive)
306    {
307        $this->log('info', $this->getLang('message: create acl'));
308        $aclFileContents = '# acl.auth.php
309# <?php exit()?>
310*  @ALL   0
311*  @users 1
312';
313        $archive->addData($this->base . 'conf/acl.auth.php', $aclFileContents);
314    }
315
316    /**
317     * Create the archive file
318     *
319     * @return Zip
320     * @throws \splitbrain\PHPArchive\ArchiveIOException
321     */
322    protected function createZipArchive($archiveFN)
323    {
324        $this->log('info', sprintf($this->getLang('message: create zip archive'), hsc($archiveFN)));
325        io_makeFileDir($archiveFN);
326        $zip = new Zip();
327        $zip->create($archiveFN);
328
329        return $zip;
330    }
331
332    /**
333     * Add the contents of an directory to the archive
334     *
335     * @param Zip $archive
336     * @param string $srcDir the directory relative to the dokuwiki root
337     * @param bool $recursive whether to add subdirectories as well
338     * @param null|string $skipRegex files and directories matching this regex will be ignored. no delimiters
339     *
340     * @throws \splitbrain\PHPArchive\ArchiveIOException
341     */
342    protected function addDirToArchive(Zip $archive, $srcDir, $recursive = true, $skipRegex = null)
343    {
344        $message = [];
345        $message[] = sprintf($this->getLang('message: add files in dir'), hsc($srcDir . '/'));
346        if ($recursive) {
347            $message[] = $this->getLang('message: recursive');
348        }
349        if ($skipRegex) {
350            $message[] = sprintf($this->getLang('message: skipping files'), hsc($skipRegex));
351        }
352        $message[] .= '...';
353        $this->log('info', implode(' ', $message));
354        $this->addFilesToArchive(DOKU_INC . $srcDir, $archive, !$recursive, $skipRegex);
355    }
356
357    /**
358     * Recursive method to add files and directories to a archive
359     *
360     * It will report large files that might cause the process to fail.
361     *
362     * @param string $source
363     * @param Zip $archive
364     * @param bool $filesOnly
365     * @param null $skipRegex
366     *
367     * @return bool
368     * @throws \splitbrain\PHPArchive\ArchiveIOException
369     */
370    protected function addFilesToArchive($source, Zip $archive, $filesOnly = false, $skipRegex = null)
371    {
372        // Simple copy for a file
373        if (is_file($source)) {
374            if (filesize($source) > 50 * 1024 * 1024) {
375                $this->log('warning', sprintf($this->getLang('message: file is large'),
376                        hsc($source)) . ' ' . filesize_h(filesize($source)));
377            }
378
379            try {
380                $archive->addFile($source, $this->getDWPathName($source));
381            } catch (\splitbrain\PHPArchive\ArchiveIOException $e) {
382                $this->log('error', hsc($e->getMessage()));
383                throw $e;
384            }
385            return true;
386        }
387
388        // Loop through the folder
389        $dir = dir($source);
390        while (false !== $entry = $dir->read()) {
391            if (in_array($entry, ['.', '..', '.git', 'node_modules'])) {
392                continue;
393            }
394            $srcFN = "$source/$entry";
395
396            if ($skipRegex && preg_match("#$skipRegex#", $this->getDWPathName($srcFN))) {
397                continue;
398            }
399
400            if (is_dir($srcFN) && $filesOnly) {
401                continue;
402            }
403
404            $copyOK = $this->addFilesToArchive($srcFN, $archive, $filesOnly, $skipRegex);
405            if ($copyOK === false) {
406                return false;
407            }
408        }
409
410        // Clean up
411        $dir->close();
412        return true;
413    }
414
415    /**
416     * Get the filepath relative to the dokuwiki root
417     *
418     * @param $filepath
419     *
420     * @return string
421     */
422    protected function getDWPathName($filepath)
423    {
424        return $this->base . substr($filepath, strlen(DOKU_INC));
425    }
426
427    /**
428     * Display the download info
429     */
430    protected function downloadView()
431    {
432        global $conf;
433
434        $fullArchiveFN = $conf['tmpdir'] . '/archivegenerator/archive.zip';
435        $updateArchiveFN = $conf['tmpdir'] . '/archivegenerator/archive-update.zip';
436        if (!file_exists($fullArchiveFN) && !file_exists($updateArchiveFN)) return;
437
438        ptln('<h1>' . $this->getLang('label: download') . '</h1>');
439
440        if (file_exists($fullArchiveFN)) {
441            $mtime = dformat(filemtime($fullArchiveFN));
442            $href = $this->getDownloadLinkHref();
443            ptln('<p>');
444            ptln('<b>' . $this->getLang('label: full archive') . '</b><br>');
445            ptln(sprintf($this->getLang('message: archive exists'), $mtime));
446            ptln("<a href=\"$href\">" . $this->getLang('link: download now') . '</a>');
447            ptln('</p>');
448        }
449
450        if (file_exists($updateArchiveFN)) {
451            $mtime = dformat(filemtime($updateArchiveFN));
452            $href = $this->getDownloadLinkHref('update');
453            ptln('<p>');
454            ptln('<b>' . $this->getLang('label: update archive') . '</b><br>');
455            ptln(sprintf($this->getLang('message: archive exists'), $mtime));
456            ptln("<a href=\"$href\">" . $this->getLang('link: download now') . '</a>');
457            ptln('</p>');
458        }
459    }
460
461    /**
462     * Show the form to create a full archive
463     */
464    protected function showFullForm()
465    {
466        $form = new \dokuwiki\Form\Form();
467        $form->addFieldsetOpen($this->getLang('label: full archive'));
468
469        $adminMailInput = $form->addTextInput('adminMail', $this->getLang('label: admin mail'));
470        $adminMailInput->addClass('block');
471        $adminMailInput->attrs(['type' => 'email', 'required' => '1']);
472
473        $adminPassInput = $form->addPasswordInput('adminPass', $this->getLang('label: admin pass'));
474        $adminPassInput->addClass('block');
475        $adminPassInput->attr('required', 1);
476
477        $form->addButton('submit', $this->getLang('button: generate archive'));
478
479        $form->addFieldsetClose();
480        echo $form->toHTML();
481    }
482
483    /**
484     * Show the form to create a full archive
485     */
486    protected function showUpdateForm()
487    {
488        $form = new \dokuwiki\Form\Form();
489        $form->addFieldsetOpen($this->getLang('label: update archive'));
490
491        $form->setHiddenField('isupdate', '1');
492
493        $form->addButton('submit', $this->getLang('button: generate archive'));
494
495        $form->addFieldsetClose();
496        echo $form->toHTML();
497    }
498
499    /**
500     * Print a message to the user, prefixes the time since the first message
501     *
502     * This adds whitespace padding to force the message being printed immediately.
503     *
504     * @param string $level can be 'error', 'warning' or 'info'
505     * @param string $message
506     */
507    protected function log($level, $message)
508    {
509        static $startTime;
510        if (!$startTime) {
511            $startTime = microtime(true);
512        }
513
514        $time = round(microtime(true) - $startTime, 3);
515        $timedMessage = sprintf($this->getLang('seconds'), $time) . ': ' . $message;
516
517        switch ($level) {
518            case 'error':
519                $msgLVL = -1;
520                break;
521            case 'warning':
522                $msgLVL = 2;
523                break;
524            case 'success':
525                $msgLVL = 1;
526                break;
527            default:
528                $msgLVL = 0;
529        }
530
531        msg($timedMessage, $msgLVL);
532        echo str_repeat(' ', 16 * 1024);
533
534        /** @noinspection MissingOrEmptyGroupStatementInspection */
535        /** @noinspection LoopWhichDoesNotLoopInspection */
536        /** @noinspection PhpStatementHasEmptyBodyInspection */
537        while (@ob_end_flush()) {
538        };
539        flush();
540    }
541}
542
543