xref: /plugin/upgrade/helper.php (revision fc9fd1dacdabb9cf1d802003da12b51682e02ca8)
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        io_mkdir_p(dirname($this->tgzfile));
147        if (!file_put_contents($this->tgzfile, $data)) {
148            $this->log('error', $this->getLang('dl_fail'));
149            return false;
150        }
151
152        $this->log('success', $this->getLang('dl_done'), filesize_h(strlen($data)));
153        return true;
154    }
155
156    /**
157     * Unpack the tarball
158     *
159     * @return bool
160     */
161    public function extractTarball()
162    {
163        $this->log('notice', '<b>' . $this->getLang('pk_extract') . '</b>');
164
165        @set_time_limit(300);
166        @ignore_user_abort();
167
168        try {
169            $tar = new Tar();
170            $tar->setCallback(function ($file) {
171                /** @var FileInfo $file */
172                $this->log('info', $file->getPath());
173            });
174            $tar->open($this->tgzfile);
175            $tar->extract($this->tgzdir, 1);
176            $tar->close();
177        } catch (Exception $e) {
178            $this->log('error', $e->getMessage());
179            $this->log('error', $this->getLang('pk_fail'));
180            return false;
181        }
182
183        $this->log('success', $this->getLang('pk_done'));
184
185        $this->log(
186            'notice',
187            $this->getLang('pk_version'),
188            hsc(file_get_contents($this->tgzdir . '/VERSION')),
189            getVersion()
190        );
191        return true;
192    }
193
194    /**
195     * Check permissions of files to change
196     *
197     * @return bool
198     */
199    public function checkPermissions()
200    {
201        $this->log('notice', $this->getLang('ck_start'));
202        $ok = $this->traverseCheckAndCopy('', true);
203        if ($ok) {
204            $this->log('success', '<b>' . $this->getLang('ck_done') . '</b>');
205        } else {
206            $this->log('error', '<b>' . $this->getLang('ck_fail') . '</b>');
207        }
208        return $ok;
209    }
210
211    /**
212     * Copy over new files
213     *
214     * @return bool
215     */
216    public function copyFiles()
217    {
218        $this->log('notice', $this->getLang('cp_start'));
219        $ok = $this->traverseCheckAndCopy('', false);
220        if ($ok) {
221            $this->log('success', '<b>' . $this->getLang('cp_done') . '</b>');
222        } else {
223            $this->log('error', '<b>' . $this->getLang('cp_fail') . '</b>');
224        }
225        return $ok;
226    }
227
228    /**
229     * Delete outdated files
230     */
231    public function deleteObsoleteFiles()
232    {
233        global $conf;
234
235        $list = file($this->tgzdir . 'data/deleted.files');
236        foreach ($list as $line) {
237            $line = trim(preg_replace('/#.*$/', '', $line));
238            if (!$line) continue;
239            $file = DOKU_INC . $line;
240            if (!file_exists($file)) continue;
241
242            // check that the given file is a case sensitive match
243            if (basename(realpath($file)) != basename($file)) {
244                $this->log('info', $this->getLang('rm_mismatch'), hsc($line));
245                continue;
246            }
247
248            if ((is_dir($file) && $this->recursiveDelete($file)) ||
249                @unlink($file)
250            ) {
251                $this->log('info', $this->getLang('rm_done'), hsc($line));
252            } else {
253                $this->log('error', $this->getLang('rm_fail'), hsc($line));
254            }
255        }
256        // delete install
257        @unlink(DOKU_INC . 'install.php');
258
259        // make sure update message will be gone
260        @touch(DOKU_INC . 'doku.php');
261        @unlink($conf['cachedir'] . '/messages.txt');
262
263        // clear opcache
264        if (function_exists('opcache_reset')) {
265            opcache_reset();
266        }
267
268        $this->log('success', '<b>' . $this->getLang('finish') . '</b>');
269        return true;
270    }
271
272    /**
273     * Remove the downloaded and extracted files
274     *
275     * @return bool
276     */
277    public function cleanUp()
278    {
279        @unlink($this->tgzfile);
280        $this->recursiveDelete($this->tgzdir);
281        return true;
282    }
283
284    // endregion
285
286    /**
287     * Traverse over the given dir and compare it to the DokuWiki dir
288     *
289     * Checks what files need an update, tests for writability and copies
290     *
291     * @param string $dir
292     * @param bool $dryrun do not copy but only check permissions
293     * @return bool
294     */
295    private function traverseCheckAndCopy($dir, $dryrun)
296    {
297        $base = $this->tgzdir;
298        $ok = true;
299
300        $dh = @opendir($base . '/' . $dir);
301        if (!$dh) return false;
302        while (($file = readdir($dh)) !== false) {
303            if ($file == '.' || $file == '..') continue;
304            $from = "$base/$dir/$file";
305            $to = DOKU_INC . "$dir/$file";
306
307            if (is_dir($from)) {
308                if ($dryrun) {
309                    // just check for writability
310                    if (!is_dir($to)) {
311                        if (is_dir(dirname($to)) && !is_writable(dirname($to))) {
312                            $this->log('error', '<b>' . $this->getLang('tv_noperm') . '</b>', hsc("$dir/$file"));
313                            $ok = false;
314                        }
315                    }
316                }
317
318                // recursion
319                if (!$this->traverseCheckAndCopy("$dir/$file", $dryrun)) {
320                    $ok = false;
321                }
322            } else {
323                $fmd5 = md5(@file_get_contents($from));
324                $tmd5 = md5(@file_get_contents($to));
325                if ($fmd5 != $tmd5 || !file_exists($to)) {
326                    if ($dryrun) {
327                        // just check for writability
328                        if ((file_exists($to) && !is_writable($to)) ||
329                            (!file_exists($to) && is_dir(dirname($to)) && !is_writable(dirname($to)))
330                        ) {
331
332                            $this->log('error', '<b>' . $this->getLang('tv_noperm') . '</b>', hsc("$dir/$file"));
333                            $ok = false;
334                        } else {
335                            $this->log('info', $this->getLang('tv_upd'), hsc("$dir/$file"));
336                        }
337                    } else {
338                        // check dir
339                        if (io_mkdir_p(dirname($to))) {
340                            // remove existing (avoid case sensitivity problems)
341                            if (file_exists($to) && !@unlink($to)) {
342                                $this->log('error', '<b>' . $this->getLang('tv_nodel') . '</b>', hsc("$dir/$file"));
343                                $ok = false;
344                            }
345                            // copy
346                            if (!copy($from, $to)) {
347                                $this->log('error', '<b>' . $this->getLang('tv_nocopy') . '</b>', hsc("$dir/$file"));
348                                $ok = false;
349                            } else {
350                                $this->log('info', $this->getLang('tv_done'), hsc("$dir/$file"));
351                            }
352                        } else {
353                            $this->log('error', '<b>' . $this->getLang('tv_nodir') . '</b>', hsc("$dir"));
354                            $ok = false;
355                        }
356                    }
357                }
358            }
359        }
360        closedir($dh);
361        return $ok;
362    }
363
364    // region utilities
365
366    /**
367     * Figure out the release date from the version string
368     *
369     * @param $version
370     * @return int|string returns 0 if the version can't be read
371     */
372    protected function dateFromVersion($version)
373    {
374        if (preg_match('/(^|\D)(\d\d\d\d-\d\d-\d\d)(\D|$)/i', $version, $m)) {
375            return $m[2];
376        }
377        return 0;
378    }
379
380    /**
381     * Recursive delete
382     *
383     * @author Jon Hassall
384     * @link   http://de.php.net/manual/en/function.unlink.php#87045
385     */
386    protected function recursiveDelete($dir)
387    {
388        if (!$dh = @opendir($dir)) {
389            return false;
390        }
391        while (false !== ($obj = readdir($dh))) {
392            if ($obj == '.' || $obj == '..') continue;
393
394            if (!@unlink($dir . '/' . $obj)) {
395                $this->recursiveDelete($dir . '/' . $obj);
396            }
397        }
398        closedir($dh);
399        return @rmdir($dir);
400    }
401
402    /**
403     * Log a message
404     *
405     * @param string ...$level, $msg
406     */
407    protected function log()
408    {
409        $args = func_get_args();
410        $level = array_shift($args);
411        $msg = array_shift($args);
412        $msg = vsprintf($msg, $args);
413        if ($this->logger) $this->logger->log($level, $msg);
414    }
415
416    // endregion
417}
418