xref: /plugin/upgrade/helper.php (revision 6741b5851b8c87b8bde87e4630915b305440506e)
1<?php
2/**
3 * DokuWiki Plugin upgrade (Helper Component)
4 *
5 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
6 * @author  Andreas Gohr <andi@splitbrain.org>
7 */
8
9use dokuwiki\plugin\upgrade\HTTP\DokuHTTPClient;
10use splitbrain\PHPArchive\FileInfo;
11use splitbrain\PHPArchive\Tar;
12
13class helper_plugin_upgrade extends DokuWiki_Plugin
14{
15    /** @var string download URL for the new DokuWiki release */
16    public $tgzurl;
17    /** @var string full path to where the file will be downloaded to */
18    public $tgzfile;
19    /** @var string full path to where the file will be extracted to */
20    public $tgzdir;
21    /** @var string URL to the VERSION file of the new DokuWiki release */
22    public $tgzversion;
23    /** @var string URL to the composer.json file of the new DokuWiki release */
24    protected string $composer;
25    /** @var string URL to the plugin.info.txt file of the upgrade plugin */
26    public $pluginversion;
27
28    /** @var admin_plugin_upgrade|cli_plugin_upgrade */
29    protected $logger;
30
31    public function __construct()
32    {
33        global $conf;
34
35        $branch = 'stable';
36
37        $this->tgzurl = "https://github.com/splitbrain/dokuwiki/archive/$branch.tar.gz";
38        $this->tgzfile = $conf['tmpdir'] . '/dokuwiki-upgrade.tgz';
39        $this->tgzdir = $conf['tmpdir'] . '/dokuwiki-upgrade/';
40        $this->tgzversion = "https://raw.githubusercontent.com/splitbrain/dokuwiki/$branch/VERSION";
41        $this->composer = "https://raw.githubusercontent.com/splitbrain/dokuwiki/$branch/composer.json";
42        $this->pluginversion = "https://raw.githubusercontent.com/splitbrain/dokuwiki-plugin-upgrade/master/plugin.info.txt";
43    }
44
45    /**
46     * @param admin_plugin_upgrade|cli_plugin_upgrade $logger Logger object
47     * @return void
48     */
49    public function setLogger($logger)
50    {
51        $this->logger = $logger;
52    }
53
54    // region Steps
55
56    /**
57     * Check various versions
58     *
59     * @return bool
60     */
61    public function checkVersions()
62    {
63        $ok = true;
64
65        // we need SSL - only newer HTTPClients check that themselves
66        if (!in_array('ssl', stream_get_transports())) {
67            $this->log('error', $this->getLang('vs_ssl'));
68            $ok = false;
69        }
70
71        // get the available version
72        $http = new DokuHTTPClient();
73        $tgzversion = trim($http->get($this->tgzversion));
74        if (!$tgzversion) {
75            $this->log('error', $this->getLang('vs_tgzno') . ' ' . hsc($http->error));
76            $ok = false;
77        }
78        $tgzversionnum = $this->dateFromVersion($tgzversion);
79        if ($tgzversionnum === 0) {
80            $this->log('error', $this->getLang('vs_tgzno'));
81            $ok = false;
82        } else {
83            $this->log('notice', $this->getLang('vs_tgz'), $tgzversion);
84        }
85
86        // get the current version
87        $versiondata = getVersionData();
88        $version = trim($versiondata['date']);
89        $versionnum = $this->dateFromVersion($version);
90        $this->log('notice', $this->getLang('vs_local'), $version);
91
92        // compare versions
93        if (!$versionnum) {
94            $this->log('warning', $this->getLang('vs_localno'));
95            $ok = false;
96        } else if ($tgzversionnum) {
97            if ($tgzversionnum < $versionnum) {
98                $this->log('warning', $this->getLang('vs_newer'));
99                $ok = false;
100            } elseif ($tgzversionnum == $versionnum && $tgzversion == $version) {
101                $this->log('warning', $this->getLang('vs_same'));
102                $ok = false;
103            }
104        }
105
106        // check plugin version
107        $pluginversion = $http->get($this->pluginversion);
108        if ($pluginversion) {
109            $plugininfo = linesToHash(explode("\n", $pluginversion));
110            $myinfo = $this->getInfo();
111            if ($plugininfo['date'] > $myinfo['date']) {
112                $this->log('warning', $this->getLang('vs_plugin'), $plugininfo['date']);
113                $ok = false;
114            }
115        }
116
117        // check if PHP is up to date
118        $json = $http->get($this->composer);
119        $data = json_decode($json, true);
120        $minphp = $data['config']['platform']['php'];
121        if (version_compare(phpversion(), $minphp, '<')) {
122            $this->log('error', $this->getLang('vs_php'), $minphp, phpversion());
123            $ok = false;
124        }
125
126        return $ok;
127    }
128
129    /**
130     * Download the tarball
131     *
132     * @return bool
133     */
134    public function downloadTarball()
135    {
136        $this->log('notice', $this->getLang('dl_from'), $this->tgzurl);
137
138        @set_time_limit(300);
139        @ignore_user_abort();
140
141        $http = new DokuHTTPClient();
142        $http->timeout = 300;
143        $data = $http->get($this->tgzurl);
144
145        if (!$data) {
146            $this->log('error', $http->error);
147            $this->log('error', $this->getLang('dl_fail'));
148            return false;
149        }
150
151        io_mkdir_p(dirname($this->tgzfile));
152        if (!file_put_contents($this->tgzfile, $data)) {
153            $this->log('error', $this->getLang('dl_fail'));
154            return false;
155        }
156
157        $this->log('success', $this->getLang('dl_done'), filesize_h(strlen($data)));
158        return true;
159    }
160
161    /**
162     * Unpack the tarball
163     *
164     * @return bool
165     */
166    public function extractTarball()
167    {
168        $this->log('notice', '<b>' . $this->getLang('pk_extract') . '</b>');
169
170        @set_time_limit(300);
171        @ignore_user_abort();
172
173        try {
174            $tar = new Tar();
175            $tar->setCallback(function ($file) {
176                /** @var FileInfo $file */
177                $this->log('info', $file->getPath());
178            });
179            $tar->open($this->tgzfile);
180            $tar->extract($this->tgzdir, 1);
181            $tar->close();
182        } catch (Exception $e) {
183            $this->log('error', $e->getMessage());
184            $this->log('error', $this->getLang('pk_fail'));
185            return false;
186        }
187
188        $this->log('success', $this->getLang('pk_done'));
189
190        $this->log(
191            'notice',
192            $this->getLang('pk_version'),
193            hsc(file_get_contents($this->tgzdir . '/VERSION')),
194            getVersion()
195        );
196        return true;
197    }
198
199    /**
200     * Check permissions of files to change
201     *
202     * @return bool
203     */
204    public function checkPermissions()
205    {
206        $this->log('notice', $this->getLang('ck_start'));
207        $ok = $this->traverseCheckAndCopy('', true);
208        if ($ok) {
209            $this->log('success', '<b>' . $this->getLang('ck_done') . '</b>');
210        } else {
211            $this->log('error', '<b>' . $this->getLang('ck_fail') . '</b>');
212        }
213        return $ok;
214    }
215
216    /**
217     * Copy over new files
218     *
219     * @return bool
220     */
221    public function copyFiles()
222    {
223        $this->log('notice', $this->getLang('cp_start'));
224        $ok = $this->traverseCheckAndCopy('', false);
225        if ($ok) {
226            $this->log('success', '<b>' . $this->getLang('cp_done') . '</b>');
227        } else {
228            $this->log('error', '<b>' . $this->getLang('cp_fail') . '</b>');
229        }
230        return $ok;
231    }
232
233    /**
234     * Delete outdated files
235     */
236    public function deleteObsoleteFiles()
237    {
238        global $conf;
239
240        $list = file($this->tgzdir . 'data/deleted.files');
241        foreach ($list as $line) {
242            $line = trim(preg_replace('/#.*$/', '', $line));
243            if (!$line) continue;
244            $file = DOKU_INC . $line;
245            if (!file_exists($file)) continue;
246
247            // check that the given file is a case sensitive match
248            if (basename(realpath($file)) != basename($file)) {
249                $this->log('info', $this->getLang('rm_mismatch'), hsc($line));
250                continue;
251            }
252
253            if ((is_dir($file) && $this->recursiveDelete($file)) ||
254                @unlink($file)
255            ) {
256                $this->log('info', $this->getLang('rm_done'), hsc($line));
257            } else {
258                $this->log('error', $this->getLang('rm_fail'), hsc($line));
259            }
260        }
261        // delete install
262        @unlink(DOKU_INC . 'install.php');
263
264        // make sure update message will be gone
265        @touch(DOKU_INC . 'doku.php');
266        @unlink($conf['cachedir'] . '/messages.txt');
267
268        // clear opcache
269        if (function_exists('opcache_reset')) {
270            opcache_reset();
271        }
272
273        $this->log('success', '<b>' . $this->getLang('finish') . '</b>');
274        return true;
275    }
276
277    /**
278     * Remove the downloaded and extracted files
279     *
280     * @return bool
281     */
282    public function cleanUp()
283    {
284        @unlink($this->tgzfile);
285        $this->recursiveDelete($this->tgzdir);
286        return true;
287    }
288
289    // endregion
290
291    /**
292     * Traverse over the given dir and compare it to the DokuWiki dir
293     *
294     * Checks what files need an update, tests for writability and copies
295     *
296     * @param string $dir
297     * @param bool $dryrun do not copy but only check permissions
298     * @return bool
299     */
300    private function traverseCheckAndCopy($dir, $dryrun)
301    {
302        $base = $this->tgzdir;
303        $ok = true;
304
305        $dh = @opendir($base . '/' . $dir);
306        if (!$dh) return false;
307        while (($file = readdir($dh)) !== false) {
308            if ($file == '.' || $file == '..') continue;
309            $from = "$base/$dir/$file";
310            $to = DOKU_INC . "$dir/$file";
311
312            if (is_dir($from)) {
313                if ($dryrun) {
314                    // just check for writability
315                    if (!is_dir($to)) {
316                        if (is_dir(dirname($to)) && !is_writable(dirname($to))) {
317                            $this->log('error', '<b>' . $this->getLang('tv_noperm') . '</b>', hsc("$dir/$file"));
318                            $ok = false;
319                        }
320                    }
321                }
322
323                // recursion
324                if (!$this->traverseCheckAndCopy("$dir/$file", $dryrun)) {
325                    $ok = false;
326                }
327            } else {
328                $fmd5 = md5(@file_get_contents($from));
329                $tmd5 = md5(@file_get_contents($to));
330                if ($fmd5 != $tmd5 || !file_exists($to)) {
331                    if ($dryrun) {
332                        // just check for writability
333                        if ((file_exists($to) && !is_writable($to)) ||
334                            (!file_exists($to) && is_dir(dirname($to)) && !is_writable(dirname($to)))
335                        ) {
336
337                            $this->log('error', '<b>' . $this->getLang('tv_noperm') . '</b>', hsc("$dir/$file"));
338                            $ok = false;
339                        } else {
340                            $this->log('info', $this->getLang('tv_upd'), hsc("$dir/$file"));
341                        }
342                    } else {
343                        // check dir
344                        if (io_mkdir_p(dirname($to))) {
345                            // remove existing (avoid case sensitivity problems)
346                            if (file_exists($to) && !@unlink($to)) {
347                                $this->log('error', '<b>' . $this->getLang('tv_nodel') . '</b>', hsc("$dir/$file"));
348                                $ok = false;
349                            }
350                            // copy
351                            if (!copy($from, $to)) {
352                                $this->log('error', '<b>' . $this->getLang('tv_nocopy') . '</b>', hsc("$dir/$file"));
353                                $ok = false;
354                            } else {
355                                $this->log('info', $this->getLang('tv_done'), hsc("$dir/$file"));
356                            }
357                        } else {
358                            $this->log('error', '<b>' . $this->getLang('tv_nodir') . '</b>', hsc("$dir"));
359                            $ok = false;
360                        }
361                    }
362                }
363            }
364        }
365        closedir($dh);
366        return $ok;
367    }
368
369    // region utilities
370
371    /**
372     * Figure out the release date from the version string
373     *
374     * @param $version
375     * @return int|string returns 0 if the version can't be read
376     */
377    protected function dateFromVersion($version)
378    {
379        if (preg_match('/(^|\D)(\d\d\d\d-\d\d-\d\d)(\D|$)/i', $version, $m)) {
380            return $m[2];
381        }
382        return 0;
383    }
384
385    /**
386     * Recursive delete
387     *
388     * @author Jon Hassall
389     * @link   http://de.php.net/manual/en/function.unlink.php#87045
390     */
391    protected function recursiveDelete($dir)
392    {
393        if (!$dh = @opendir($dir)) {
394            return false;
395        }
396        while (false !== ($obj = readdir($dh))) {
397            if ($obj == '.' || $obj == '..') continue;
398
399            if (!@unlink($dir . '/' . $obj)) {
400                $this->recursiveDelete($dir . '/' . $obj);
401            }
402        }
403        closedir($dh);
404        return @rmdir($dir);
405    }
406
407    /**
408     * Log a message
409     *
410     * @param string ...$level , $msg
411     */
412    protected function log()
413    {
414        $args = func_get_args();
415        $level = array_shift($args);
416        $msg = array_shift($args);
417        $msg = vsprintf($msg, $args);
418        if ($this->logger) $this->logger->log($level, $msg);
419    }
420
421    // endregion
422}
423