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