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