1<?php
2/**
3 * DokuWiki Plugin elwikiupgrade (Admin Component)
4 *
5 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
6 * @author  Andreas Gohr <andi@splitbrain.org>
7 */
8
9// must be run within Dokuwiki
10if(!defined('DOKU_INC')) die();
11if(!defined('DOKU_PLUGIN')) define('DOKU_PLUGIN', DOKU_INC.'lib/plugins/');
12require_once DOKU_PLUGIN.'admin.php';
13require_once DOKU_PLUGIN.'elwikiupgrade/VerboseTarLib.class.php';
14require_once DOKU_PLUGIN.'elwikiupgrade/HTTPClient.php';
15
16use dokuwiki\plugin\elwikiupgrade\DokuHTTPClient;
17
18class admin_plugin_elwikiupgrade extends DokuWiki_Admin_Plugin {
19    private $tgzurl;
20    private $tgzfile;
21    private $tgzdir;
22    private $tgzversion;
23    private $pluginversion;
24
25    protected $haderrors = false;
26
27    public function __construct() {
28        global $conf;
29
30        $branch = 'stable';
31
32        $this->tgzurl        = "https://download.einsatzleiterwiki.de/elwikiupgrade/einsatzleiterwiki-upgrade.tar.gz";
33        $this->tgzfile       = $conf['tmpdir'].'/dokuwiki-elwikiupgrade.tgz';
34        $this->tgzdir        = $conf['tmpdir'].'/dokuwiki-elwikiupgrade/';
35        $this->tgzversion    = "https://download.einsatzleiterwiki.de/elwikiupgrade/VERSION";
36        $this->pluginversion = "https://download.einsatzleiterwiki.de/elwikiupgrade/plugin.info.txt";
37    }
38
39    public function getMenuSort() {
40        return 555;
41    }
42
43    public function handle() {
44        if($_REQUEST['step'] && !checkSecurityToken()) {
45            unset($_REQUEST['step']);
46        }
47    }
48
49    public function html() {
50        $abrt = false;
51        $next = false;
52
53        echo '<h1>'.$this->getLang('menu').'</h1>';
54
55        self::_say('<div id="plugin__elwikiupgrade">');
56        // enable auto scroll
57        ?>
58        <script language="javascript" type="text/javascript">
59            var plugin_elwikiupgrade = window.setInterval(function () {
60                var obj = document.getElementById('plugin__elwikiupgrade');
61                if (obj) obj.scrollTop = obj.scrollHeight;
62            }, 25);
63        </script>
64        <?php
65
66        // handle current step
67        $this->_stepit($abrt, $next);
68
69        // disable auto scroll
70        ?>
71        <script language="javascript" type="text/javascript">
72            window.setTimeout(function () {
73                window.clearInterval(plugin_elwikiupgrade);
74            }, 50);
75        </script>
76        <?php
77        self::_say('</div>');
78
79        $careful = '';
80        if($this->haderrors) {
81            echo '<div id="plugin__elwikiupgrade_careful">'.$this->getLang('careful').'</div>';
82            $careful = 'careful';
83        }
84
85        $action = script();
86        echo '<form action="' . $action . '" method="post" id="plugin__elwikiupgrade_form">';
87        echo '<input type="hidden" name="do" value="admin" />';
88        echo '<input type="hidden" name="page" value="elwikiupgrade" />';
89        echo '<input type="hidden" name="sectok" value="' . getSecurityToken() . '" />';
90        if($next) echo '<button type="submit" name="step[' . $next . ']" value="1" class="button continue '.$careful.'">' . $this->getLang('btn_continue') . ' ➡</button>';
91        if($abrt) echo '<button type="submit" name="step[cancel]" value="1" class="button abort">✖ ' . $this->getLang('btn_abort') . '</button>';
92        echo '</form>';
93
94        $this->_progress($next);
95    }
96
97    /**
98     * Display a progress bar of all steps
99     *
100     * @param string $next the next step
101     */
102    private function _progress($next) {
103        $steps  = array('version', 'download', 'unpack', 'check', 'elwikiupgrade');
104        $active = true;
105        $count = 0;
106
107        echo '<div id="plugin__elwikiupgrade_meter"><ol>';
108        foreach($steps as $step) {
109            $count++;
110            if($step == $next) $active = false;
111            if($active) {
112                echo '<li class="active">';
113                echo '<span class="step">✔</span>';
114            } else {
115                echo '<li>';
116                echo '<span class="step">'.$count.'</span>';
117            }
118
119            echo '<span class="stage">'.$this->getLang('step_'.$step).'</span>';
120            echo '</li>';
121        }
122        echo '</ol></div>';
123    }
124
125    /**
126     * Decides the current step and executes it
127     *
128     * @param bool $abrt
129     * @param bool $next
130     */
131    private function _stepit(&$abrt, &$next) {
132
133        if(isset($_REQUEST['step']) && is_array($_REQUEST['step'])) {
134            $step = array_shift(array_keys($_REQUEST['step']));
135        } else {
136            $step = '';
137        }
138
139        if($step == 'cancel' || $step == 'done') {
140            # cleanup
141            @unlink($this->tgzfile);
142            $this->_rdel($this->tgzdir);
143            if($step == 'cancel') $step = '';
144        }
145
146        if($step) {
147            $abrt = true;
148            $next = false;
149            if($step == 'version') {
150                $this->_step_version();
151                $next = 'download';
152            } elseif ($step == 'done') {
153                $this->_step_done();
154                $next = '';
155                $abrt = '';
156            } elseif(!file_exists($this->tgzfile)) {
157                if($this->_step_download()) $next = 'unpack';
158            } elseif(!is_dir($this->tgzdir)) {
159                if($this->_step_unpack()) $next = 'check';
160            } elseif($step != 'elwikiupgrade') {
161                if($this->_step_check()) $next = 'elwikiupgrade';
162            } elseif($step == 'elwikiupgrade') {
163                if($this->_step_copy()) {
164                    $next = 'done';
165                    $abrt = '';
166                }
167            } else {
168                echo 'uhm. what happened? where am I? This should not happen';
169            }
170        } else {
171            # first time run, show intro
172            echo $this->locale_xhtml('step0');
173            $abrt = false;
174            $next = 'version';
175        }
176    }
177
178    /**
179     * Output the given arguments using vsprintf and flush buffers
180     */
181    public static function _say() {
182        $args = func_get_args();
183        echo '<img src="'.DOKU_BASE.'lib/images/blank.gif" width="16" height="16" alt="" /> ';
184        echo vsprintf(array_shift($args)."<br />\n", $args);
185        flush();
186        ob_flush();
187    }
188
189    /**
190     * Print a warning using the given arguments with vsprintf and flush buffers
191     */
192    public function _warn() {
193        $this->haderrors = true;
194
195        $args = func_get_args();
196        echo '<img src="'.DOKU_BASE.'lib/images/error.png" width="16" height="16" alt="!" /> ';
197        echo vsprintf(array_shift($args)."<br />\n", $args);
198        flush();
199        ob_flush();
200    }
201
202    /**
203     * Recursive delete
204     *
205     * @author Jon Hassall
206     * @link   http://de.php.net/manual/en/function.unlink.php#87045
207     */
208    private function _rdel($dir) {
209        if(!$dh = @opendir($dir)) {
210            return false;
211        }
212        while(false !== ($obj = readdir($dh))) {
213            if($obj == '.' || $obj == '..') continue;
214
215            if(!@unlink($dir.'/'.$obj)) {
216                $this->_rdel($dir.'/'.$obj);
217            }
218        }
219        closedir($dh);
220        return @rmdir($dir);
221    }
222
223    /**
224     * Check various versions
225     *
226     * @return bool
227     */
228    private function _step_version() {
229        $ok = true;
230
231        // we need SSL - only newer HTTPClients check that themselves
232        if(!in_array('ssl', stream_get_transports())) {
233            $this->_warn($this->getLang('vs_ssl'));
234            $ok = false;
235        }
236
237        // get the available version
238        $http       = new DokuHTTPClient();
239        $tgzversion = $http->get($this->tgzversion);
240        if(!$tgzversion) {
241            $this->_warn($this->getLang('vs_tgzno').' '.hsc($http->error));
242            $ok = false;
243        }
244        $tgzversionnum = $this->dateFromVersion($tgzversion);
245        if($tgzversionnum === 0) {
246            $this->_warn($this->getLang('vs_tgzno'));
247            $ok            = false;
248        } else {
249            self::_say($this->getLang('vs_tgz'), $tgzversion);
250        }
251
252        // get the current version
253        $version = getVersion();
254        $versionnum = $this->dateFromVersion($version);
255        self::_say($this->getLang('vs_local'), $version);
256
257        // compare versions
258        if(!$versionnum) {
259            $this->_warn($this->getLang('vs_localno'));
260            $ok = false;
261        } else if($tgzversionnum) {
262            if($tgzversionnum < $versionnum) {
263                $this->_warn($this->getLang('vs_newer'));
264                $ok = false;
265            } elseif($tgzversionnum == $versionnum && $tgzversion == $version) {
266                $this->_warn($this->getLang('vs_same'));
267                $ok = false;
268            }
269        }
270
271        // check plugin version
272        $pluginversion = $http->get($this->pluginversion);
273        if($pluginversion) {
274            $plugininfo = linesToHash(explode("\n", $pluginversion));
275            $myinfo     = $this->getInfo();
276            if($plugininfo['date'] > $myinfo['date']) {
277                $this->_warn($this->getLang('vs_plugin'), $plugininfo['date']);
278                $ok = false;
279            }
280        }
281
282        // check if PHP is up to date
283        $minphp = '5.6';
284        if(version_compare(phpversion(), $minphp, '<')) {
285            $this->_warn($this->getLang('vs_php'), $minphp, phpversion());
286            $ok = false;
287        }
288
289        return $ok;
290    }
291
292    /**
293     * Redirect to the start page
294     */
295    private function _step_done() {
296        if (function_exists('opcache_reset')) {
297            opcache_reset();
298        }
299        echo $this->getLang('finish');
300        echo "<script type='text/javascript'>location.href='".DOKU_URL."';</script>";
301    }
302
303    /**
304     * Download the tarball
305     *
306     * @return bool
307     */
308    private function _step_download() {
309        self::_say($this->getLang('dl_from'), $this->tgzurl);
310
311        @set_time_limit(300);
312        @ignore_user_abort();
313
314        $http          = new DokuHTTPClient();
315        $http->timeout = 300;
316        $data          = $http->get($this->tgzurl);
317
318        if(!$data) {
319            $this->_warn($http->error);
320            $this->_warn($this->getLang('dl_fail'));
321            return false;
322        }
323
324        if(!io_saveFile($this->tgzfile, $data)) {
325            $this->_warn($this->getLang('dl_fail'));
326            return false;
327        }
328
329        self::_say($this->getLang('dl_done'), filesize_h(strlen($data)));
330
331        return true;
332    }
333
334    /**
335     * Unpack the tarball
336     *
337     * @return bool
338     */
339    private function _step_unpack() {
340        self::_say('<b>'.$this->getLang('pk_extract').'</b>');
341
342        @set_time_limit(300);
343        @ignore_user_abort();
344
345        try {
346            $tar = new VerboseTar();
347            $tar->open($this->tgzfile);
348            $tar->extract($this->tgzdir, 1);
349            $tar->close();
350        } catch (Exception $e) {
351            $this->_warn($e->getMessage());
352            $this->_warn($this->getLang('pk_fail'));
353            return false;
354        }
355
356        self::_say($this->getLang('pk_done'));
357
358        self::_say(
359            $this->getLang('pk_version'),
360            hsc(file_get_contents($this->tgzdir.'/VERSION')),
361            getVersion()
362        );
363        return true;
364    }
365
366    /**
367     * Check permissions of files to change
368     *
369     * @return bool
370     */
371    private function _step_check() {
372        self::_say($this->getLang('ck_start'));
373        $ok = $this->_traverse('', true);
374        if($ok) {
375            self::_say('<b>'.$this->getLang('ck_done').'</b>');
376        } else {
377            $this->_warn('<b>'.$this->getLang('ck_fail').'</b>');
378        }
379        return $ok;
380    }
381
382    /**
383     * Copy over new files
384     *
385     * @return bool
386     */
387    private function _step_copy() {
388        self::_say($this->getLang('cp_start'));
389        $ok = $this->_traverse('', false);
390        if($ok) {
391            self::_say('<b>'.$this->getLang('cp_done').'</b>');
392            $this->_rmold();
393            self::_say('<b>'.$this->getLang('finish').'</b>');
394        } else {
395            $this->_warn('<b>'.$this->getLang('cp_fail').'</b>');
396        }
397        return $ok;
398    }
399
400    /**
401     * Delete outdated files
402     */
403    private function _rmold() {
404        global $conf;
405
406        $list = file($this->tgzdir.'data/deleted.files');
407        foreach($list as $line) {
408            $line = trim(preg_replace('/#.*$/', '', $line));
409            if(!$line) continue;
410            $file = DOKU_INC.$line;
411            if(!file_exists($file)) continue;
412
413            // check that the given file is an case sensitive match
414            if(basename(realpath($file)) != basename($file)) {
415                self::_say($this->getLang('rm_mismatch'), hsc($line));
416                continue;
417            }
418
419            if((is_dir($file) && $this->_rdel($file)) ||
420                @unlink($file)
421            ) {
422                self::_say($this->getLang('rm_done'), hsc($line));
423            } else {
424                $this->_warn($this->getLang('rm_fail'), hsc($line));
425            }
426        }
427        // delete install
428        @unlink(DOKU_INC.'install.php');
429
430        // make sure update message will be gone
431        @touch(DOKU_INC.'doku.php');
432        @unlink($conf['cachedir'].'/messages.txt');
433    }
434
435    /**
436     * Traverse over the given dir and compare it to the DokuWiki dir
437     *
438     * Checks what files need an update, tests for writability and copies
439     *
440     * @param string $dir
441     * @param bool   $dryrun do not copy but only check permissions
442     * @return bool
443     */
444    private function _traverse($dir, $dryrun) {
445        $base = $this->tgzdir;
446        $ok   = true;
447
448        $dh = @opendir($base.'/'.$dir);
449        if(!$dh) return false;
450        while(($file = readdir($dh)) !== false) {
451            if($file == '.' || $file == '..') continue;
452            $from = "$base/$dir/$file";
453            $to   = DOKU_INC."$dir/$file";
454
455            if(is_dir($from)) {
456                if($dryrun) {
457                    // just check for writability
458                    if(!is_dir($to)) {
459                        if(is_dir(dirname($to)) && !is_writable(dirname($to))) {
460                            $this->_warn('<b>'.$this->getLang('tv_noperm').'</b>', hsc("$dir/$file"));
461                            $ok = false;
462                        }
463                    }
464                }
465
466                // recursion
467                if(!$this->_traverse("$dir/$file", $dryrun)) {
468                    $ok = false;
469                }
470            } else {
471                $fmd5 = md5(@file_get_contents($from));
472                $tmd5 = md5(@file_get_contents($to));
473                if($fmd5 != $tmd5 || !file_exists($to)) {
474                    if($dryrun) {
475                        // just check for writability
476                        if((file_exists($to) && !is_writable($to)) ||
477                            (!file_exists($to) && is_dir(dirname($to)) && !is_writable(dirname($to)))
478                        ) {
479
480                            $this->_warn('<b>'.$this->getLang('tv_noperm').'</b>', hsc("$dir/$file"));
481                            $ok = false;
482                        } else {
483                            self::_say($this->getLang('tv_upd'), hsc("$dir/$file"));
484                        }
485                    } else {
486                        // check dir
487                        if(io_mkdir_p(dirname($to))) {
488                            // remove existing (avoid case sensitivity problems)
489                            if(file_exists($to) && !@unlink($to)) {
490                                $this->_warn('<b>'.$this->getLang('tv_nodel').'</b>', hsc("$dir/$file"));
491                                $ok = false;
492                            }
493                            // copy
494                            if(!copy($from, $to)) {
495                                $this->_warn('<b>'.$this->getLang('tv_nocopy').'</b>', hsc("$dir/$file"));
496                                $ok = false;
497                            } else {
498                                self::_say($this->getLang('tv_done'), hsc("$dir/$file"));
499                            }
500                        } else {
501                            $this->_warn('<b>'.$this->getLang('tv_nodir').'</b>', hsc("$dir"));
502                            $ok = false;
503                        }
504                    }
505                }
506            }
507        }
508        closedir($dh);
509        return $ok;
510    }
511
512    /**
513     * Figure out the release date from the version string
514     *
515     * @param $version
516     * @return int|string returns 0 if the version can't be read
517     */
518    public function dateFromVersion($version) {
519        if(preg_match('/(^|[^\d])(\d\d\d\d-\d\d-\d\d)([^\d]|$)/i', $version, $m)) {
520            return $m[2];
521        }
522        return 0;
523    }
524}
525
526// vim:ts=4:sw=4:et:enc=utf-8:
527