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/log');
143        $this->addEmptyDirToArchive($archive, 'data/locks');
144        $this->addEmptyDirToArchive($archive, 'data/tmp');
145        $this->addEmptyDirToArchive($archive, 'data/media_attic');
146
147        $archive->close();
148        $this->log('info', $this->getLang('message: adding data done'));
149
150        $persistentArchiveFN = $conf['tmpdir'] . '/archivegenerator/archive.zip';
151        io_rename($tmpArchiveFN, $persistentArchiveFN);
152
153        $href = $this->getDownloadLinkHref();
154        $link = "<a href=\"$href\">" . $this->getLang('link: download now') . '</a>';
155        $this->log('success', $this->getLang('message: done') . ' ' . $link);
156
157        // try a redirect to self
158        ptln('<script type="text/javascript">window.location.href=\'' . $this->getSelfRedirect() . '\';</script>');
159    }
160
161    /**
162     * Build an update archive based on the existing wiki
163     *
164     * @throws \splitbrain\PHPArchive\ArchiveIOException
165     */
166    protected function generateUpdateArchive()
167    {
168        global $conf;
169        $this->log('info', $this->getLang('message: starting'));
170        $tmpArchiveFN = $conf['tmpdir'] . '/archivegenerator/archive-update_new.zip';
171        $archive = $this->createZipArchive($tmpArchiveFN);
172        set_time_limit(0);
173        $this->addDirToArchive($archive, '.', false);
174        $this->addDirToArchive($archive, 'inc');
175        $this->addDirToArchive($archive, 'bin');
176        $this->addDirToArchive($archive, 'vendor');
177        $this->addDirToArchive($archive, 'conf', true, '^' . $this->base . 'conf/(users\.auth\.php|acl\.auth\.php|.*local\.(php|conf))$');
178        $this->addDirToArchive($archive, 'lib', true, '^' . $this->base . 'lib/plugins$');
179        $this->addDirToArchive($archive, 'lib/plugins', true, $this->buildSkipPluginRegex());
180
181        $this->addEmptyDirToArchive($archive, 'data/pages');
182        $this->addEmptyDirToArchive($archive, 'data/media');
183        $this->addEmptyDirToArchive($archive, 'data/index');
184        $this->addEmptyDirToArchive($archive, 'data/media_meta');
185        $this->addEmptyDirToArchive($archive, 'data/meta');
186        $this->addEmptyDirToArchive($archive, 'data/attic');
187        $this->addEmptyDirToArchive($archive, 'data/cache');
188        $this->addEmptyDirToArchive($archive, 'data/log');
189        $this->addEmptyDirToArchive($archive, 'data/locks');
190        $this->addEmptyDirToArchive($archive, 'data/tmp');
191        $this->addEmptyDirToArchive($archive, 'data/media_attic');
192
193        $archive->close();
194        $this->log('info', $this->getLang('message: adding data done'));
195
196        $persistentArchiveFN = $conf['tmpdir'] . '/archivegenerator/archive-update.zip';
197        io_rename($tmpArchiveFN, $persistentArchiveFN);
198
199        $href = $this->getDownloadLinkHref('update');
200        $link = "<a href=\"$href\">" . $this->getLang('link: download now') . '</a>';
201        $this->log('success', $this->getLang('message: done') . ' ' . $link);
202
203        // try a redirect to self
204        ptln('<script type="text/javascript">window.location.href=\'' . $this->getSelfRedirect() . '\';</script>');
205    }
206
207    /**
208     * Build a regex for the plugins to skip, relative to the DokuWiki root
209     *
210     * @return string
211     */
212    protected function buildSkipPluginRegex()
213    {
214        $list = array_map('trim', explode(',', $this->getConf('pluginsToIgnore')));
215        return '^' . $this->base . 'lib/plugins/(' . implode('|', $list) . ')$';
216    }
217
218    /**
219     * Generate a href for a link to download the archive
220     *
221     * @param string $type
222     * @return string
223     */
224    protected function getDownloadLinkHref($type = 'full')
225    {
226        global $ID;
227        return wl($ID, [
228            'do' => 'admin',
229            'page' => 'archivegenerator',
230            'downloadArchive' => 1,
231            'sectok' => getSecurityToken(),
232            'isupdate' => (int)($type == 'update'),
233        ]);
234    }
235
236    /**
237     * Generate the link to the admin page itself
238     *
239     * @return string
240     */
241    protected function getSelfRedirect()
242    {
243        global $ID;
244        return wl($ID, [
245            'do' => 'admin',
246            'page' => 'archivegenerator',
247        ], false, '&');
248    }
249
250    /**
251     * Add an empty directory to the archive.
252     *
253     * The directory will contain a dummy .keep file.
254     *
255     * @param Zip $archive
256     * @param string $directory path of the directory to add relative to the dokuwiki root
257     *
258     * @throws \splitbrain\PHPArchive\ArchiveIOException
259     */
260    protected function addEmptyDirToArchive(Zip $archive, $directory)
261    {
262        $this->log('info', sprintf($this->getLang('message: create empty dir'), $directory));
263        $dirPath = $this->base . $directory . '/.keep';
264        $archive->addData($dirPath, '');
265    }
266
267    /**
268     * Create a users.auth.php file with a single admin user
269     *
270     * @param Zip $archive
271     *
272     * @throws \splitbrain\PHPArchive\ArchiveIOException
273     */
274    protected function addUsersAuthToArchive(Zip $archive)
275    {
276        global $INPUT;
277
278        $email = $INPUT->post->str('adminMail');
279        $pass = $INPUT->post->str('adminPass');
280
281        $this->log('info', $this->getLang('message: create users'));
282        $authFile = '
283# users.auth.php
284# <?php exit()?>
285# Don\'t modify the lines above
286#
287# Userfile
288#
289# Format:
290#
291# login:passwordhash:Real Name:email:groups,comma,separated
292
293        ';
294
295        $pwHash = auth_cryptPassword($pass);
296        $adminLine = "admin:$pwHash:Administrator:$email:user,admin\n";
297        $archive->addData($this->base . 'conf/users.auth.php', $authFile . $adminLine);
298    }
299
300    /**
301     * Create an acl.auth.php file that allows reading only for logged-in users
302     *
303     * @param Zip $archive
304     *
305     * @throws \splitbrain\PHPArchive\ArchiveIOException
306     */
307    protected function addACLToArchive(Zip $archive)
308    {
309        $this->log('info', $this->getLang('message: create acl'));
310        $aclFileContents = '# acl.auth.php
311# <?php exit()?>
312*  @ALL   0
313*  @users 1
314';
315        $archive->addData($this->base . 'conf/acl.auth.php', $aclFileContents);
316    }
317
318    /**
319     * Create the archive file
320     *
321     * @return Zip
322     * @throws \splitbrain\PHPArchive\ArchiveIOException
323     */
324    protected function createZipArchive($archiveFN)
325    {
326        $this->log('info', sprintf($this->getLang('message: create zip archive'), hsc($archiveFN)));
327        io_makeFileDir($archiveFN);
328        $zip = new Zip();
329        $zip->create($archiveFN);
330
331        return $zip;
332    }
333
334    /**
335     * Add the contents of an directory to the archive
336     *
337     * @param Zip $archive
338     * @param string $srcDir the directory relative to the dokuwiki root
339     * @param bool $recursive whether to add subdirectories as well
340     * @param null|string $skipRegex files and directories matching this regex will be ignored. no delimiters
341     *
342     * @throws \splitbrain\PHPArchive\ArchiveIOException
343     */
344    protected function addDirToArchive(Zip $archive, $srcDir, $recursive = true, $skipRegex = null)
345    {
346        $message = [];
347        $message[] = sprintf($this->getLang('message: add files in dir'), hsc($srcDir . '/'));
348        if ($recursive) {
349            $message[] = $this->getLang('message: recursive');
350        }
351        if ($skipRegex) {
352            $message[] = sprintf($this->getLang('message: skipping files'), hsc($skipRegex));
353        }
354        $message[] .= '...';
355        $this->log('info', implode(' ', $message));
356        $this->addFilesToArchive(DOKU_INC . $srcDir, $archive, !$recursive, $skipRegex);
357    }
358
359    /**
360     * Recursive method to add files and directories to a archive
361     *
362     * It will report large files that might cause the process to fail.
363     *
364     * @param string $source
365     * @param Zip $archive
366     * @param bool $filesOnly
367     * @param null $skipRegex
368     *
369     * @return bool
370     * @throws \splitbrain\PHPArchive\ArchiveIOException
371     */
372    protected function addFilesToArchive($source, Zip $archive, $filesOnly = false, $skipRegex = null)
373    {
374        // Simple copy for a file
375        if (is_file($source)) {
376            if (filesize($source) > 50 * 1024 * 1024) {
377                $this->log('warning', sprintf($this->getLang('message: file is large'),
378                        hsc($source)) . ' ' . filesize_h(filesize($source)));
379            }
380
381            try {
382                $archive->addFile($source, $this->getDWPathName($source));
383            } catch (\splitbrain\PHPArchive\ArchiveIOException $e) {
384                $this->log('error', hsc($e->getMessage()));
385                throw $e;
386            }
387            return true;
388        }
389
390        // Loop through the folder
391        $dir = dir($source);
392        while (false !== $entry = $dir->read()) {
393            if (in_array($entry, ['.', '..', '.git', 'node_modules'])) {
394                continue;
395            }
396            $srcFN = "$source/$entry";
397
398            if ($skipRegex && preg_match("#$skipRegex#", $this->getDWPathName($srcFN))) {
399                continue;
400            }
401
402            if (is_dir($srcFN) && $filesOnly) {
403                continue;
404            }
405
406            $copyOK = $this->addFilesToArchive($srcFN, $archive, $filesOnly, $skipRegex);
407            if ($copyOK === false) {
408                return false;
409            }
410        }
411
412        // Clean up
413        $dir->close();
414        return true;
415    }
416
417    /**
418     * Get the filepath relative to the dokuwiki root
419     *
420     * @param $filepath
421     *
422     * @return string
423     */
424    protected function getDWPathName($filepath)
425    {
426        return $this->base . substr($filepath, strlen(DOKU_INC));
427    }
428
429    /**
430     * Display the download info
431     */
432    protected function downloadView()
433    {
434        global $conf;
435
436        $fullArchiveFN = $conf['tmpdir'] . '/archivegenerator/archive.zip';
437        $updateArchiveFN = $conf['tmpdir'] . '/archivegenerator/archive-update.zip';
438        if (!file_exists($fullArchiveFN) && !file_exists($updateArchiveFN)) return;
439
440        ptln('<h1>' . $this->getLang('label: download') . '</h1>');
441
442        if (file_exists($fullArchiveFN)) {
443            $mtime = dformat(filemtime($fullArchiveFN));
444            $href = $this->getDownloadLinkHref();
445            ptln('<p>');
446            ptln('<b>' . $this->getLang('label: full archive') . '</b><br>');
447            ptln(sprintf($this->getLang('message: archive exists'), $mtime));
448            ptln("<a href=\"$href\">" . $this->getLang('link: download now') . '</a>');
449            ptln('</p>');
450        }
451
452        if (file_exists($updateArchiveFN)) {
453            $mtime = dformat(filemtime($updateArchiveFN));
454            $href = $this->getDownloadLinkHref('update');
455            ptln('<p>');
456            ptln('<b>' . $this->getLang('label: update archive') . '</b><br>');
457            ptln(sprintf($this->getLang('message: archive exists'), $mtime));
458            ptln("<a href=\"$href\">" . $this->getLang('link: download now') . '</a>');
459            ptln('</p>');
460        }
461    }
462
463    /**
464     * Show the form to create a full archive
465     */
466    protected function showFullForm()
467    {
468        $form = new \dokuwiki\Form\Form();
469        $form->addFieldsetOpen($this->getLang('label: full archive'));
470
471        $adminMailInput = $form->addTextInput('adminMail', $this->getLang('label: admin mail'));
472        $adminMailInput->addClass('block');
473        $adminMailInput->attrs(['type' => 'email', 'required' => '1']);
474
475        $adminPassInput = $form->addPasswordInput('adminPass', $this->getLang('label: admin pass'));
476        $adminPassInput->addClass('block');
477        $adminPassInput->attr('required', 1);
478
479        $form->addButton('submit', $this->getLang('button: generate archive'));
480
481        $form->addFieldsetClose();
482        echo $form->toHTML();
483    }
484
485    /**
486     * Show the form to create a full archive
487     */
488    protected function showUpdateForm()
489    {
490        $form = new \dokuwiki\Form\Form();
491        $form->addFieldsetOpen($this->getLang('label: update archive'));
492
493        $form->setHiddenField('isupdate', '1');
494
495        $form->addButton('submit', $this->getLang('button: generate archive'));
496
497        $form->addFieldsetClose();
498        echo $form->toHTML();
499    }
500
501    /**
502     * Print a message to the user, prefixes the time since the first message
503     *
504     * This adds whitespace padding to force the message being printed immediately.
505     *
506     * @param string $level can be 'error', 'warning' or 'info'
507     * @param string $message
508     */
509    protected function log($level, $message)
510    {
511        static $startTime;
512        if (!$startTime) {
513            $startTime = microtime(true);
514        }
515
516        $time = round(microtime(true) - $startTime, 3);
517        $timedMessage = sprintf($this->getLang('seconds'), $time) . ': ' . $message;
518
519        switch ($level) {
520            case 'error':
521                $msgLVL = -1;
522                break;
523            case 'warning':
524                $msgLVL = 2;
525                break;
526            case 'success':
527                $msgLVL = 1;
528                break;
529            default:
530                $msgLVL = 0;
531        }
532
533        msg($timedMessage, $msgLVL);
534        echo str_repeat(' ', 16 * 1024);
535
536        /** @noinspection MissingOrEmptyGroupStatementInspection */
537        /** @noinspection LoopWhichDoesNotLoopInspection */
538        /** @noinspection PhpStatementHasEmptyBodyInspection */
539        while (@ob_end_flush()) {
540        };
541        flush();
542    }
543}
544
545