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