Failed Conditions
Push — pr-3115 ( f9aa34 )
by Andreas
02:44
created

lib/plugins/extension/helper/extension.php (2 issues)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

1
<?php
2
/**
3
 * DokuWiki Plugin extension (Helper Component)
4
 *
5
 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
6
 * @author  Michael Hamann <[email protected]>
7
 */
8
9
use dokuwiki\HTTP\DokuHTTPClient;
10
use dokuwiki\Extension\PluginController;
11
12
/**
13
 * Class helper_plugin_extension_extension represents a single extension (plugin or template)
14
 */
15
class helper_plugin_extension_extension extends DokuWiki_Plugin
16
{
17
    private $id;
18
    private $base;
19
    private $is_template = false;
20
    private $localInfo;
21
    private $remoteInfo;
22
    private $managerData;
23
    /** @var helper_plugin_extension_repository $repository */
24
    private $repository = null;
25
26
    /** @var array list of temporary directories */
27
    private $temporary = array();
28
29
    /** @var string where templates are installed to */
30
    private $tpllib = '';
31
32
    /**
33
     * helper_plugin_extension_extension constructor.
34
     */
35
    public function __construct()
36
    {
37
        $this->tpllib = dirname(tpl_incdir()).'/';
38
    }
39
40
    /**
41
     * Destructor
42
     *
43
     * deletes any dangling temporary directories
44
     */
45
    public function __destruct()
46
    {
47
        foreach ($this->temporary as $dir) {
48
            io_rmdir($dir, true);
49
        }
50
    }
51
52
    /**
53
     * @return bool false, this component is not a singleton
54
     */
55
    public function isSingleton()
56
    {
57
        return false;
58
    }
59
60
    /**
61
     * Set the name of the extension this instance shall represents, triggers loading the local and remote data
62
     *
63
     * @param string $id  The id of the extension (prefixed with template: for templates)
64
     * @return bool If some (local or remote) data was found
65
     */
66
    public function setExtension($id)
67
    {
68
        $id = cleanID($id);
69
        $this->id   = $id;
70
        $this->base = $id;
71
72
        if (substr($id, 0, 9) == 'template:') {
73
            $this->base = substr($id, 9);
74
            $this->is_template = true;
75
        } else {
76
            $this->is_template = false;
77
        }
78
79
        $this->localInfo = array();
80
        $this->managerData = array();
81
        $this->remoteInfo = array();
82
83
        if ($this->isInstalled()) {
84
            $this->readLocalData();
85
            $this->readManagerData();
86
        }
87
88
        if ($this->repository == null) {
89
            $this->repository = $this->loadHelper('extension_repository');
90
        }
91
92
        $this->remoteInfo = $this->repository->getData($this->getID());
93
94
        return ($this->localInfo || $this->remoteInfo);
95
    }
96
97
    /**
98
     * If the extension is installed locally
99
     *
100
     * @return bool If the extension is installed locally
101
     */
102
    public function isInstalled()
103
    {
104
        return is_dir($this->getInstallDir());
105
    }
106
107
    /**
108
     * If the extension is under git control
109
     *
110
     * @return bool
111
     */
112
    public function isGitControlled()
113
    {
114
        if (!$this->isInstalled()) return false;
115
        return is_dir($this->getInstallDir().'/.git');
116
    }
117
118
    /**
119
     * If the extension is bundled
120
     *
121
     * @return bool If the extension is bundled
122
     */
123
    public function isBundled()
124
    {
125
        if (!empty($this->remoteInfo['bundled'])) return $this->remoteInfo['bundled'];
126
        return in_array(
127
            $this->id,
128
            array(
129
                'authad', 'authldap', 'authpdo', 'authplain',
130
                'acl', 'config', 'extension', 'info', 'popularity', 'revert',
131
                'safefnrecode', 'styling', 'testing', 'usermanager',
132
                'template:dokuwiki',
133
            )
134
        );
135
    }
136
137
    /**
138
     * If the extension is protected against any modification (disable/uninstall)
139
     *
140
     * @return bool if the extension is protected
141
     */
142
    public function isProtected()
143
    {
144
        // never allow deinstalling the current auth plugin:
145
        global $conf;
146
        if ($this->id == $conf['authtype']) return true;
147
148
        /** @var PluginController $plugin_controller */
149
        global $plugin_controller;
150
        $cascade = $plugin_controller->getCascade();
151
        return (isset($cascade['protected'][$this->id]) && $cascade['protected'][$this->id]);
152
    }
153
154
    /**
155
     * If the extension is installed in the correct directory
156
     *
157
     * @return bool If the extension is installed in the correct directory
158
     */
159
    public function isInWrongFolder()
160
    {
161
        return $this->base != $this->getBase();
162
    }
163
164
    /**
165
     * If the extension is enabled
166
     *
167
     * @return bool If the extension is enabled
168
     */
169
    public function isEnabled()
170
    {
171
        global $conf;
172
        if ($this->isTemplate()) {
173
            return ($conf['template'] == $this->getBase());
174
        }
175
176
        /* @var PluginController $plugin_controller */
177
        global $plugin_controller;
178
        return !$plugin_controller->isdisabled($this->base);
0 ignored issues
show
Deprecated Code introduced by
The method dokuwiki\Extension\PluginController::isDisabled() has been deprecated with message: in favor of the more sensible isEnabled where the return value matches the enabled state

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
179
    }
180
181
    /**
182
     * If the extension should be updated, i.e. if an updated version is available
183
     *
184
     * @return bool If an update is available
185
     */
186
    public function updateAvailable()
187
    {
188
        if (!$this->isInstalled()) return false;
189
        if ($this->isBundled()) return false;
190
        $lastupdate = $this->getLastUpdate();
191
        if ($lastupdate === false) return false;
192
        $installed  = $this->getInstalledVersion();
193
        if ($installed === false || $installed === $this->getLang('unknownversion')) return true;
194
        return $this->getInstalledVersion() < $this->getLastUpdate();
195
    }
196
197
    /**
198
     * If the extension is a template
199
     *
200
     * @return bool If this extension is a template
201
     */
202
    public function isTemplate()
203
    {
204
        return $this->is_template;
205
    }
206
207
    /**
208
     * Get the ID of the extension
209
     *
210
     * This is the same as getName() for plugins, for templates it's getName() prefixed with 'template:'
211
     *
212
     * @return string
213
     */
214
    public function getID()
215
    {
216
        return $this->id;
217
    }
218
219
    /**
220
     * Get the name of the installation directory
221
     *
222
     * @return string The name of the installation directory
223
     */
224
    public function getInstallName()
225
    {
226
        return $this->base;
227
    }
228
229
    // Data from plugin.info.txt/template.info.txt or the repo when not available locally
230
    /**
231
     * Get the basename of the extension
232
     *
233
     * @return string The basename
234
     */
235
    public function getBase()
236
    {
237
        if (!empty($this->localInfo['base'])) return $this->localInfo['base'];
238
        return $this->base;
239
    }
240
241
    /**
242
     * Get the display name of the extension
243
     *
244
     * @return string The display name
245
     */
246
    public function getDisplayName()
247
    {
248
        if (!empty($this->localInfo['name'])) return $this->localInfo['name'];
249
        if (!empty($this->remoteInfo['name'])) return $this->remoteInfo['name'];
250
        return $this->base;
251
    }
252
253
    /**
254
     * Get the author name of the extension
255
     *
256
     * @return string|bool The name of the author or false if there is none
257
     */
258
    public function getAuthor()
259
    {
260
        if (!empty($this->localInfo['author'])) return $this->localInfo['author'];
261
        if (!empty($this->remoteInfo['author'])) return $this->remoteInfo['author'];
262
        return false;
263
    }
264
265
    /**
266
     * Get the email of the author of the extension if there is any
267
     *
268
     * @return string|bool The email address or false if there is none
269
     */
270
    public function getEmail()
271
    {
272
        // email is only in the local data
273
        if (!empty($this->localInfo['email'])) return $this->localInfo['email'];
274
        return false;
275
    }
276
277
    /**
278
     * Get the email id, i.e. the md5sum of the email
279
     *
280
     * @return string|bool The md5sum of the email if there is any, false otherwise
281
     */
282
    public function getEmailID()
283
    {
284
        if (!empty($this->remoteInfo['emailid'])) return $this->remoteInfo['emailid'];
285
        if (!empty($this->localInfo['email'])) return md5($this->localInfo['email']);
286
        return false;
287
    }
288
289
    /**
290
     * Get the description of the extension
291
     *
292
     * @return string The description
293
     */
294
    public function getDescription()
295
    {
296
        if (!empty($this->localInfo['desc'])) return $this->localInfo['desc'];
297
        if (!empty($this->remoteInfo['description'])) return $this->remoteInfo['description'];
298
        return '';
299
    }
300
301
    /**
302
     * Get the URL of the extension, usually a page on dokuwiki.org
303
     *
304
     * @return string The URL
305
     */
306
    public function getURL()
307
    {
308
        if (!empty($this->localInfo['url'])) return $this->localInfo['url'];
309
        return 'https://www.dokuwiki.org/'.
310
            ($this->isTemplate() ? 'template' : 'plugin').':'.$this->getBase();
311
    }
312
313
    /**
314
     * Get the installed version of the extension
315
     *
316
     * @return string|bool The version, usually in the form yyyy-mm-dd if there is any
317
     */
318
    public function getInstalledVersion()
319
    {
320
        if (!empty($this->localInfo['date'])) return $this->localInfo['date'];
321
        if ($this->isInstalled()) return $this->getLang('unknownversion');
322
        return false;
323
    }
324
325
    /**
326
     * Get the install date of the current version
327
     *
328
     * @return string|bool The date of the last update or false if not available
329
     */
330
    public function getUpdateDate()
331
    {
332
        if (!empty($this->managerData['updated'])) return $this->managerData['updated'];
333
        return $this->getInstallDate();
334
    }
335
336
    /**
337
     * Get the date of the installation of the plugin
338
     *
339
     * @return string|bool The date of the installation or false if not available
340
     */
341
    public function getInstallDate()
342
    {
343
        if (!empty($this->managerData['installed'])) return $this->managerData['installed'];
344
        return false;
345
    }
346
347
    /**
348
     * Get the names of the dependencies of this extension
349
     *
350
     * @return array The base names of the dependencies
351
     */
352
    public function getDependencies()
353
    {
354
        if (!empty($this->remoteInfo['dependencies'])) return $this->remoteInfo['dependencies'];
355
        return array();
356
    }
357
358
    /**
359
     * Get the names of the missing dependencies
360
     *
361
     * @return array The base names of the missing dependencies
362
     */
363
    public function getMissingDependencies()
364
    {
365
        /* @var PluginController $plugin_controller */
366
        global $plugin_controller;
367
        $dependencies = $this->getDependencies();
368
        $missing_dependencies = array();
369
        foreach ($dependencies as $dependency) {
370
            if ($plugin_controller->isdisabled($dependency)) {
0 ignored issues
show
Deprecated Code introduced by
The method dokuwiki\Extension\PluginController::isDisabled() has been deprecated with message: in favor of the more sensible isEnabled where the return value matches the enabled state

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
371
                $missing_dependencies[] = $dependency;
372
            }
373
        }
374
        return $missing_dependencies;
375
    }
376
377
    /**
378
     * Get the names of all conflicting extensions
379
     *
380
     * @return array The names of the conflicting extensions
381
     */
382
    public function getConflicts()
383
    {
384
        if (!empty($this->remoteInfo['conflicts'])) return $this->remoteInfo['conflicts'];
385
        return array();
386
    }
387
388
    /**
389
     * Get the names of similar extensions
390
     *
391
     * @return array The names of similar extensions
392
     */
393
    public function getSimilarExtensions()
394
    {
395
        if (!empty($this->remoteInfo['similar'])) return $this->remoteInfo['similar'];
396
        return array();
397
    }
398
399
    /**
400
     * Get the names of the tags of the extension
401
     *
402
     * @return array The names of the tags of the extension
403
     */
404
    public function getTags()
405
    {
406
        if (!empty($this->remoteInfo['tags'])) return $this->remoteInfo['tags'];
407
        return array();
408
    }
409
410
    /**
411
     * Get the popularity information as floating point number [0,1]
412
     *
413
     * @return float|bool The popularity information or false if it isn't available
414
     */
415
    public function getPopularity()
416
    {
417
        if (!empty($this->remoteInfo['popularity'])) return $this->remoteInfo['popularity'];
418
        return false;
419
    }
420
421
422
    /**
423
     * Get the text of the security warning if there is any
424
     *
425
     * @return string|bool The security warning if there is any, false otherwise
426
     */
427
    public function getSecurityWarning()
428
    {
429
        if (!empty($this->remoteInfo['securitywarning'])) return $this->remoteInfo['securitywarning'];
430
        return false;
431
    }
432
433
    /**
434
     * Get the text of the security issue if there is any
435
     *
436
     * @return string|bool The security issue if there is any, false otherwise
437
     */
438
    public function getSecurityIssue()
439
    {
440
        if (!empty($this->remoteInfo['securityissue'])) return $this->remoteInfo['securityissue'];
441
        return false;
442
    }
443
444
    /**
445
     * Get the URL of the screenshot of the extension if there is any
446
     *
447
     * @return string|bool The screenshot URL if there is any, false otherwise
448
     */
449
    public function getScreenshotURL()
450
    {
451
        if (!empty($this->remoteInfo['screenshoturl'])) return $this->remoteInfo['screenshoturl'];
452
        return false;
453
    }
454
455
    /**
456
     * Get the URL of the thumbnail of the extension if there is any
457
     *
458
     * @return string|bool The thumbnail URL if there is any, false otherwise
459
     */
460
    public function getThumbnailURL()
461
    {
462
        if (!empty($this->remoteInfo['thumbnailurl'])) return $this->remoteInfo['thumbnailurl'];
463
        return false;
464
    }
465
    /**
466
     * Get the last used download URL of the extension if there is any
467
     *
468
     * @return string|bool The previously used download URL, false if the extension has been installed manually
469
     */
470
    public function getLastDownloadURL()
471
    {
472
        if (!empty($this->managerData['downloadurl'])) return $this->managerData['downloadurl'];
473
        return false;
474
    }
475
476
    /**
477
     * Get the download URL of the extension if there is any
478
     *
479
     * @return string|bool The download URL if there is any, false otherwise
480
     */
481
    public function getDownloadURL()
482
    {
483
        if (!empty($this->remoteInfo['downloadurl'])) return $this->remoteInfo['downloadurl'];
484
        return false;
485
    }
486
487
    /**
488
     * If the download URL has changed since the last download
489
     *
490
     * @return bool If the download URL has changed
491
     */
492
    public function hasDownloadURLChanged()
493
    {
494
        $lasturl = $this->getLastDownloadURL();
495
        $currenturl = $this->getDownloadURL();
496
        return ($lasturl && $currenturl && $lasturl != $currenturl);
497
    }
498
499
    /**
500
     * Get the bug tracker URL of the extension if there is any
501
     *
502
     * @return string|bool The bug tracker URL if there is any, false otherwise
503
     */
504
    public function getBugtrackerURL()
505
    {
506
        if (!empty($this->remoteInfo['bugtracker'])) return $this->remoteInfo['bugtracker'];
507
        return false;
508
    }
509
510
    /**
511
     * Get the URL of the source repository if there is any
512
     *
513
     * @return string|bool The URL of the source repository if there is any, false otherwise
514
     */
515
    public function getSourcerepoURL()
516
    {
517
        if (!empty($this->remoteInfo['sourcerepo'])) return $this->remoteInfo['sourcerepo'];
518
        return false;
519
    }
520
521
    /**
522
     * Get the donation URL of the extension if there is any
523
     *
524
     * @return string|bool The donation URL if there is any, false otherwise
525
     */
526
    public function getDonationURL()
527
    {
528
        if (!empty($this->remoteInfo['donationurl'])) return $this->remoteInfo['donationurl'];
529
        return false;
530
    }
531
532
    /**
533
     * Get the extension type(s)
534
     *
535
     * @return array The type(s) as array of strings
536
     */
537
    public function getTypes()
538
    {
539
        if (!empty($this->remoteInfo['types'])) return $this->remoteInfo['types'];
540
        if ($this->isTemplate()) return array(32 => 'template');
541
        return array();
542
    }
543
544
    /**
545
     * Get a list of all DokuWiki versions this extension is compatible with
546
     *
547
     * @return array The versions in the form yyyy-mm-dd => ('label' => label, 'implicit' => implicit)
548
     */
549
    public function getCompatibleVersions()
550
    {
551
        if (!empty($this->remoteInfo['compatible'])) return $this->remoteInfo['compatible'];
552
        return array();
553
    }
554
555
    /**
556
     * Get the date of the last available update
557
     *
558
     * @return string|bool The last available update in the form yyyy-mm-dd if there is any, false otherwise
559
     */
560
    public function getLastUpdate()
561
    {
562
        if (!empty($this->remoteInfo['lastupdate'])) return $this->remoteInfo['lastupdate'];
563
        return false;
564
    }
565
566
    /**
567
     * Get the base path of the extension
568
     *
569
     * @return string The base path of the extension
570
     */
571
    public function getInstallDir()
572
    {
573
        if ($this->isTemplate()) {
574
            return $this->tpllib.$this->base;
575
        } else {
576
            return DOKU_PLUGIN.$this->base;
577
        }
578
    }
579
580
    /**
581
     * The type of extension installation
582
     *
583
     * @return string One of "none", "manual", "git" or "automatic"
584
     */
585
    public function getInstallType()
586
    {
587
        if (!$this->isInstalled()) return 'none';
588
        if (!empty($this->managerData)) return 'automatic';
589
        if (is_dir($this->getInstallDir().'/.git')) return 'git';
590
        return 'manual';
591
    }
592
593
    /**
594
     * If the extension can probably be installed/updated or uninstalled
595
     *
596
     * @return bool|string True or error string
597
     */
598
    public function canModify()
599
    {
600
        if ($this->isInstalled()) {
601
            if (!is_writable($this->getInstallDir())) {
602
                return 'noperms';
603
            }
604
        }
605
606
        if ($this->isTemplate() && !is_writable($this->tpllib)) {
607
            return 'notplperms';
608
        } elseif (!is_writable(DOKU_PLUGIN)) {
609
            return 'nopluginperms';
610
        }
611
        return true;
612
    }
613
614
    /**
615
     * Install an extension from a user upload
616
     *
617
     * @param string $field name of the upload file
618
     * @throws Exception when something goes wrong
619
     * @return array The list of installed extensions
620
     */
621
    public function installFromUpload($field)
622
    {
623
        if ($_FILES[$field]['error']) {
624
            throw new Exception($this->getLang('msg_upload_failed').' ('.$_FILES[$field]['error'].')');
625
        }
626
627
        $tmp = $this->mkTmpDir();
628
        if (!$tmp) throw new Exception($this->getLang('error_dircreate'));
629
630
        // filename may contain the plugin name for old style plugins...
631
        $basename = basename($_FILES[$field]['name']);
632
        $basename = preg_replace('/\.(tar\.gz|tar\.bz|tar\.bz2|tar|tgz|tbz|zip)$/', '', $basename);
633
        $basename = preg_replace('/[\W]+/', '', $basename);
634
635
        if (!move_uploaded_file($_FILES[$field]['tmp_name'], "$tmp/upload.archive")) {
636
            throw new Exception($this->getLang('msg_upload_failed'));
637
        }
638
639
        try {
640
            $installed = $this->installArchive("$tmp/upload.archive", true, $basename);
641
            $this->updateManagerData('', $installed);
642
            $this->removeDeletedfiles($installed);
643
            // purge cache
644
            $this->purgeCache();
645
        } catch (Exception $e) {
646
            throw $e;
647
        }
648
        return $installed;
649
    }
650
651
    /**
652
     * Install an extension from a remote URL
653
     *
654
     * @param string $url
655
     * @throws Exception when something goes wrong
656
     * @return array The list of installed extensions
657
     */
658
    public function installFromURL($url)
659
    {
660
        try {
661
            $path      = $this->download($url);
662
            $installed = $this->installArchive($path, true);
663
            $this->updateManagerData($url, $installed);
664
            $this->removeDeletedfiles($installed);
665
666
            // purge cache
667
            $this->purgeCache();
668
        } catch (Exception $e) {
669
            throw $e;
670
        }
671
        return $installed;
672
    }
673
674
    /**
675
     * Install or update the extension
676
     *
677
     * @throws \Exception when something goes wrong
678
     * @return array The list of installed extensions
679
     */
680
    public function installOrUpdate()
681
    {
682
        $url       = $this->getDownloadURL();
683
        $path      = $this->download($url);
684
        $installed = $this->installArchive($path, $this->isInstalled(), $this->getBase());
685
        $this->updateManagerData($url, $installed);
686
687
        // refresh extension information
688
        if (!isset($installed[$this->getID()])) {
689
            throw new Exception('Error, the requested extension hasn\'t been installed or updated');
690
        }
691
        $this->removeDeletedfiles($installed);
692
        $this->setExtension($this->getID());
693
        $this->purgeCache();
694
        return $installed;
695
    }
696
697
    /**
698
     * Uninstall the extension
699
     *
700
     * @return bool If the plugin was sucessfully uninstalled
701
     */
702
    public function uninstall()
703
    {
704
        $this->purgeCache();
705
        return io_rmdir($this->getInstallDir(), true);
706
    }
707
708
    /**
709
     * Enable the extension
710
     *
711
     * @return bool|string True or an error message
712
     */
713
    public function enable()
714
    {
715
        if ($this->isTemplate()) return $this->getLang('notimplemented');
716
        if (!$this->isInstalled()) return $this->getLang('notinstalled');
717
        if ($this->isEnabled()) return $this->getLang('alreadyenabled');
718
719
        /* @var PluginController $plugin_controller */
720
        global $plugin_controller;
721
        if ($plugin_controller->enable($this->base)) {
722
            $this->purgeCache();
723
            return true;
724
        } else {
725
            return $this->getLang('pluginlistsaveerror');
726
        }
727
    }
728
729
    /**
730
     * Disable the extension
731
     *
732
     * @return bool|string True or an error message
733
     */
734
    public function disable()
735
    {
736
        if ($this->isTemplate()) return $this->getLang('notimplemented');
737
738
        /* @var PluginController $plugin_controller */
739
        global $plugin_controller;
740
        if (!$this->isInstalled()) return $this->getLang('notinstalled');
741
        if (!$this->isEnabled()) return $this->getLang('alreadydisabled');
742
        if ($plugin_controller->disable($this->base)) {
743
            $this->purgeCache();
744
            return true;
745
        } else {
746
            return $this->getLang('pluginlistsaveerror');
747
        }
748
    }
749
750
    /**
751
     * Purge the cache by touching the main configuration file
752
     */
753
    protected function purgeCache()
754
    {
755
        global $config_cascade;
756
757
        // expire dokuwiki caches
758
        // touching local.php expires wiki page, JS and CSS caches
759
        @touch(reset($config_cascade['main']['local']));
760
    }
761
762
    /**
763
     * Read local extension data either from info.txt or getInfo()
764
     */
765
    protected function readLocalData()
766
    {
767
        if ($this->isTemplate()) {
768
            $infopath = $this->getInstallDir().'/template.info.txt';
769
        } else {
770
            $infopath = $this->getInstallDir().'/plugin.info.txt';
771
        }
772
773
        if (is_readable($infopath)) {
774
            $this->localInfo = confToHash($infopath);
775
        } elseif (!$this->isTemplate() && $this->isEnabled()) {
776
            $path   = $this->getInstallDir().'/';
777
            $plugin = null;
778
779
            foreach (PluginController::PLUGIN_TYPES as $type) {
780
                if (file_exists($path.$type.'.php')) {
781
                    $plugin = plugin_load($type, $this->base);
782
                    if ($plugin) break;
783
                }
784
785
                if ($dh = @opendir($path.$type.'/')) {
786
                    while (false !== ($cp = readdir($dh))) {
787
                        if ($cp == '.' || $cp == '..' || strtolower(substr($cp, -4)) != '.php') continue;
788
789
                        $plugin = plugin_load($type, $this->base.'_'.substr($cp, 0, -4));
790
                        if ($plugin) break;
791
                    }
792
                    if ($plugin) break;
793
                    closedir($dh);
794
                }
795
            }
796
797
            if ($plugin) {
798
                /* @var DokuWiki_Plugin $plugin */
799
                $this->localInfo = $plugin->getInfo();
800
            }
801
        }
802
    }
803
804
    /**
805
     * Save the given URL and current datetime in the manager.dat file of all installed extensions
806
     *
807
     * @param string $url       Where the extension was downloaded from. (empty for manual installs via upload)
808
     * @param array  $installed Optional list of installed plugins
809
     */
810
    protected function updateManagerData($url = '', $installed = null)
811
    {
812
        $origID = $this->getID();
813
814
        if (is_null($installed)) {
815
            $installed = array($origID);
816
        }
817
818
        foreach ($installed as $ext => $info) {
819
            if ($this->getID() != $ext) $this->setExtension($ext);
820
            if ($url) {
821
                $this->managerData['downloadurl'] = $url;
822
            } elseif (isset($this->managerData['downloadurl'])) {
823
                unset($this->managerData['downloadurl']);
824
            }
825
            if (isset($this->managerData['installed'])) {
826
                $this->managerData['updated'] = date('r');
827
            } else {
828
                $this->managerData['installed'] = date('r');
829
            }
830
            $this->writeManagerData();
831
        }
832
833
        if ($this->getID() != $origID) $this->setExtension($origID);
834
    }
835
836
    /**
837
     * Read the manager.dat file
838
     */
839
    protected function readManagerData()
840
    {
841
        $managerpath = $this->getInstallDir().'/manager.dat';
842
        if (is_readable($managerpath)) {
843
            $file = @file($managerpath);
844
            if (!empty($file)) {
845
                foreach ($file as $line) {
846
                    list($key, $value) = explode('=', trim($line, DOKU_LF), 2);
847
                    $key = trim($key);
848
                    $value = trim($value);
849
                    // backwards compatible with old plugin manager
850
                    if ($key == 'url') $key = 'downloadurl';
851
                    $this->managerData[$key] = $value;
852
                }
853
            }
854
        }
855
    }
856
857
    /**
858
     * Write the manager.data file
859
     */
860
    protected function writeManagerData()
861
    {
862
        $managerpath = $this->getInstallDir().'/manager.dat';
863
        $data = '';
864
        foreach ($this->managerData as $k => $v) {
865
            $data .= $k.'='.$v.DOKU_LF;
866
        }
867
        io_saveFile($managerpath, $data);
868
    }
869
870
    /**
871
     * Returns a temporary directory
872
     *
873
     * The directory is registered for cleanup when the class is destroyed
874
     *
875
     * @return false|string
876
     */
877
    protected function mkTmpDir()
878
    {
879
        $dir = io_mktmpdir();
880
        if (!$dir) return false;
881
        $this->temporary[] = $dir;
882
        return $dir;
883
    }
884
885
    /**
886
     * downloads a file from the net and saves it
887
     *
888
     * - $file is the directory where the file should be saved
889
     * - if successful will return the name used for the saved file, false otherwise
890
     *
891
     * @author Andreas Gohr <[email protected]>
892
     * @author Chris Smith <[email protected]>
893
     *
894
     * @param string $url           url to download
895
     * @param string $file          path to file or directory where to save
896
     * @param string $defaultName   fallback for name of download
897
     * @return bool|string          if failed false, otherwise true or the name of the file in the given dir
898
     */
899
    protected function downloadToFile($url, $file, $defaultName = '')
900
    {
901
        global $conf;
902
        $http = new DokuHTTPClient();
903
        $http->max_bodysize = 0;
904
        $http->timeout = 25; //max. 25 sec
905
        $http->keep_alive = false; // we do single ops here, no need for keep-alive
906
        $http->agent = 'DokuWiki HTTP Client (Extension Manager)';
907
908
        $data = $http->get($url);
909
        if ($data === false) return false;
910
911
        $name = '';
912
        if (isset($http->resp_headers['content-disposition'])) {
913
            $content_disposition = $http->resp_headers['content-disposition'];
914
            $match = array();
915
            if (is_string($content_disposition) &&
916
                preg_match('/attachment;\s*filename\s*=\s*"([^"]*)"/i', $content_disposition, $match)
917
            ) {
918
                $name = \dokuwiki\Utf8\PhpString::basename($match[1]);
919
            }
920
921
        }
922
923
        if (!$name) {
924
            if (!$defaultName) return false;
925
            $name = $defaultName;
926
        }
927
928
        $file = $file.$name;
929
930
        $fileexists = file_exists($file);
931
        $fp = @fopen($file,"w");
932
        if (!$fp) return false;
933
        fwrite($fp, $data);
934
        fclose($fp);
935
        if (!$fileexists and $conf['fperm']) chmod($file, $conf['fperm']);
936
        return $name;
937
    }
938
939
    /**
940
     * Download an archive to a protected path
941
     *
942
     * @param string $url  The url to get the archive from
943
     * @throws Exception   when something goes wrong
944
     * @return string The path where the archive was saved
945
     */
946
    public function download($url)
947
    {
948
        // check the url
949
        if (!preg_match('/https?:\/\//i', $url)) {
950
            throw new Exception($this->getLang('error_badurl'));
951
        }
952
953
        // try to get the file from the path (used as plugin name fallback)
954
        $file = parse_url($url, PHP_URL_PATH);
955
        if (is_null($file)) {
956
            $file = md5($url);
957
        } else {
958
            $file = \dokuwiki\Utf8\PhpString::basename($file);
959
        }
960
961
        // create tmp directory for download
962
        if (!($tmp = $this->mkTmpDir())) {
963
            throw new Exception($this->getLang('error_dircreate'));
964
        }
965
966
        // download
967
        if (!$file = $this->downloadToFile($url, $tmp.'/', $file)) {
968
            io_rmdir($tmp, true);
969
            throw new Exception(sprintf($this->getLang('error_download'),
970
                '<bdi>'.hsc($url).'</bdi>')
971
            );
972
        }
973
974
        return $tmp.'/'.$file;
975
    }
976
977
    /**
978
     * @param string $file      The path to the archive that shall be installed
979
     * @param bool   $overwrite If an already installed plugin should be overwritten
980
     * @param string $base      The basename of the plugin if it's known
981
     * @throws Exception        when something went wrong
982
     * @return array            list of installed extensions
983
     */
984
    public function installArchive($file, $overwrite = false, $base = '')
985
    {
986
        $installed_extensions = array();
987
988
        // create tmp directory for decompression
989
        if (!($tmp = $this->mkTmpDir())) {
990
            throw new Exception($this->getLang('error_dircreate'));
991
        }
992
993
        // add default base folder if specified to handle case where zip doesn't contain this
994
        if ($base && !@mkdir($tmp.'/'.$base)) {
995
            throw new Exception($this->getLang('error_dircreate'));
996
        }
997
998
        // decompress
999
        $this->decompress($file, "$tmp/".$base);
1000
1001
        // search $tmp/$base for the folder(s) that has been created
1002
        // move the folder(s) to lib/..
1003
        $result = array('old'=>array(), 'new'=>array());
1004
        $default = ($this->isTemplate() ? 'template' : 'plugin');
1005
        if (!$this->findFolders($result, $tmp.'/'.$base, $default)) {
1006
            throw new Exception($this->getLang('error_findfolder'));
1007
        }
1008
1009
        // choose correct result array
1010
        if (count($result['new'])) {
1011
            $install = $result['new'];
1012
        } else {
1013
            $install = $result['old'];
1014
        }
1015
1016
        if (!count($install)) {
1017
            throw new Exception($this->getLang('error_findfolder'));
1018
        }
1019
1020
        // now install all found items
1021
        foreach ($install as $item) {
1022
            // where to install?
1023
            if ($item['type'] == 'template') {
1024
                $target_base_dir = $this->tpllib;
1025
            } else {
1026
                $target_base_dir = DOKU_PLUGIN;
1027
            }
1028
1029
            if (!empty($item['base'])) {
1030
                // use base set in info.txt
1031
            } elseif ($base && count($install) == 1) {
1032
                $item['base'] = $base;
1033
            } else {
1034
                // default - use directory as found in zip
1035
                // plugins from github/master without *.info.txt will install in wrong folder
1036
                // but using $info->id will make 'code3' fail (which should install in lib/code/..)
1037
                $item['base'] = basename($item['tmp']);
1038
            }
1039
1040
            // check to make sure we aren't overwriting anything
1041
            $target = $target_base_dir.$item['base'];
1042
            if (!$overwrite && file_exists($target)) {
1043
                // TODO remember our settings, ask the user to confirm overwrite
1044
                continue;
1045
            }
1046
1047
            $action = file_exists($target) ? 'update' : 'install';
1048
1049
            // copy action
1050
            if ($this->dircopy($item['tmp'], $target)) {
1051
                // return info
1052
                $id = $item['base'];
1053
                if ($item['type'] == 'template') {
1054
                    $id = 'template:'.$id;
1055
                }
1056
                $installed_extensions[$id] = array(
1057
                    'base' => $item['base'],
1058
                    'type' => $item['type'],
1059
                    'action' => $action
1060
                );
1061
            } else {
1062
                throw new Exception(sprintf($this->getLang('error_copy').DOKU_LF,
1063
                    '<bdi>'.$item['base'].'</bdi>')
1064
                );
1065
            }
1066
        }
1067
1068
        // cleanup
1069
        if ($tmp) io_rmdir($tmp, true);
1070
1071
        return $installed_extensions;
1072
    }
1073
1074
    /**
1075
     * Find out what was in the extracted directory
1076
     *
1077
     * Correct folders are searched recursively using the "*.info.txt" configs
1078
     * as indicator for a root folder. When such a file is found, it's base
1079
     * setting is used (when set). All folders found by this method are stored
1080
     * in the 'new' key of the $result array.
1081
     *
1082
     * For backwards compatibility all found top level folders are stored as
1083
     * in the 'old' key of the $result array.
1084
     *
1085
     * When no items are found in 'new' the copy mechanism should fall back
1086
     * the 'old' list.
1087
     *
1088
     * @author Andreas Gohr <[email protected]>
1089
     * @param array $result - results are stored here
1090
     * @param string $directory - the temp directory where the package was unpacked to
1091
     * @param string $default_type - type used if no info.txt available
1092
     * @param string $subdir - a subdirectory. do not set. used by recursion
1093
     * @return bool - false on error
1094
     */
1095
    protected function findFolders(&$result, $directory, $default_type = 'plugin', $subdir = '')
1096
    {
1097
        $this_dir = "$directory$subdir";
1098
        $dh       = @opendir($this_dir);
1099
        if (!$dh) return false;
1100
1101
        $found_dirs           = array();
1102
        $found_files          = 0;
1103
        $found_template_parts = 0;
1104
        while (false !== ($f = readdir($dh))) {
1105
            if ($f == '.' || $f == '..') continue;
1106
1107
            if (is_dir("$this_dir/$f")) {
1108
                $found_dirs[] = "$subdir/$f";
1109
            } else {
1110
                // it's a file -> check for config
1111
                $found_files++;
1112
                switch ($f) {
1113
                    case 'plugin.info.txt':
1114
                    case 'template.info.txt':
1115
                        // we have  found a clear marker, save and return
1116
                        $info = array();
1117
                        $type = explode('.', $f, 2);
1118
                        $info['type'] = $type[0];
1119
                        $info['tmp']  = $this_dir;
1120
                        $conf = confToHash("$this_dir/$f");
1121
                        $info['base'] = basename($conf['base']);
1122
                        $result['new'][] = $info;
1123
                        return true;
1124
1125
                    case 'main.php':
1126
                    case 'details.php':
1127
                    case 'mediamanager.php':
1128
                    case 'style.ini':
1129
                        $found_template_parts++;
1130
                        break;
1131
                }
1132
            }
1133
        }
1134
        closedir($dh);
1135
1136
        // files where found but no info.txt - use old method
1137
        if ($found_files) {
1138
            $info        = array();
1139
            $info['tmp'] = $this_dir;
1140
            // does this look like a template or should we use the default type?
1141
            if ($found_template_parts >= 2) {
1142
                $info['type'] = 'template';
1143
            } else {
1144
                $info['type'] = $default_type;
1145
            }
1146
1147
            $result['old'][] = $info;
1148
            return true;
1149
        }
1150
1151
        // we have no files yet -> recurse
1152
        foreach ($found_dirs as $found_dir) {
1153
            $this->findFolders($result, $directory, $default_type, "$found_dir");
1154
        }
1155
        return true;
1156
    }
1157
1158
    /**
1159
     * Decompress a given file to the given target directory
1160
     *
1161
     * Determines the compression type from the file extension
1162
     *
1163
     * @param string $file   archive to extract
1164
     * @param string $target directory to extract to
1165
     * @throws Exception
1166
     * @return bool
1167
     */
1168
    private function decompress($file, $target)
1169
    {
1170
        // decompression library doesn't like target folders ending in "/"
1171
        if (substr($target, -1) == "/") $target = substr($target, 0, -1);
1172
1173
        $ext = $this->guessArchiveType($file);
1174
        if (in_array($ext, array('tar', 'bz', 'gz'))) {
1175
            try {
1176
                $tar = new \splitbrain\PHPArchive\Tar();
1177
                $tar->open($file);
1178
                $tar->extract($target);
1179
            } catch (\splitbrain\PHPArchive\ArchiveIOException $e) {
1180
                throw new Exception($this->getLang('error_decompress').' '.$e->getMessage());
1181
            }
1182
1183
            return true;
1184
        } elseif ($ext == 'zip') {
1185
            try {
1186
                $zip = new \splitbrain\PHPArchive\Zip();
1187
                $zip->open($file);
1188
                $zip->extract($target);
1189
            } catch (\splitbrain\PHPArchive\ArchiveIOException $e) {
1190
                throw new Exception($this->getLang('error_decompress').' '.$e->getMessage());
1191
            }
1192
1193
            return true;
1194
        }
1195
1196
        // the only case when we don't get one of the recognized archive types is
1197
        // when the archive file can't be read
1198
        throw new Exception($this->getLang('error_decompress').' Couldn\'t read archive file');
1199
    }
1200
1201
    /**
1202
     * Determine the archive type of the given file
1203
     *
1204
     * Reads the first magic bytes of the given file for content type guessing,
1205
     * if neither bz, gz or zip are recognized, tar is assumed.
1206
     *
1207
     * @author Andreas Gohr <[email protected]>
1208
     * @param string $file The file to analyze
1209
     * @return string|false false if the file can't be read, otherwise an "extension"
1210
     */
1211
    private function guessArchiveType($file)
1212
    {
1213
        $fh = fopen($file, 'rb');
1214
        if (!$fh) return false;
1215
        $magic = fread($fh, 5);
1216
        fclose($fh);
1217
1218
        if (strpos($magic, "\x42\x5a") === 0) return 'bz';
1219
        if (strpos($magic, "\x1f\x8b") === 0) return 'gz';
1220
        if (strpos($magic, "\x50\x4b\x03\x04") === 0) return 'zip';
1221
        return 'tar';
1222
    }
1223
1224
    /**
1225
     * Copy with recursive sub-directory support
1226
     *
1227
     * @param string $src filename path to file
1228
     * @param string $dst filename path to file
1229
     * @return bool|int|string
1230
     */
1231
    private function dircopy($src, $dst)
1232
    {
1233
        global $conf;
1234
1235
        if (is_dir($src)) {
1236
            if (!$dh = @opendir($src)) return false;
1237
1238
            if ($ok = io_mkdir_p($dst)) {
1239
                while ($ok && (false !== ($f = readdir($dh)))) {
1240
                    if ($f == '..' || $f == '.') continue;
1241
                    $ok = $this->dircopy("$src/$f", "$dst/$f");
1242
                }
1243
            }
1244
1245
            closedir($dh);
1246
            return $ok;
1247
        } else {
1248
            $existed = file_exists($dst);
1249
1250
            if (!@copy($src, $dst)) return false;
1251
            if (!$existed && $conf['fperm']) chmod($dst, $conf['fperm']);
1252
            @touch($dst, filemtime($src));
1253
        }
1254
1255
        return true;
1256
    }
1257
1258
    /**
1259
     * Delete outdated files from updated plugins
1260
     *
1261
     * @param array $installed
1262
     */
1263
    private function removeDeletedfiles($installed)
1264
    {
1265
        foreach ($installed as $id => $extension) {
1266
            // only on update
1267
            if ($extension['action'] == 'install') continue;
1268
1269
            // get definition file
1270
            if ($extension['type'] == 'template') {
1271
                $extensiondir = $this->tpllib;
1272
            } else {
1273
                $extensiondir = DOKU_PLUGIN;
1274
            }
1275
            $extensiondir = $extensiondir . $extension['base'] .'/';
1276
            $definitionfile = $extensiondir . 'deleted.files';
1277
            if (!file_exists($definitionfile)) continue;
1278
1279
            // delete the old files
1280
            $list = file($definitionfile);
1281
1282
            foreach ($list as $line) {
1283
                $line = trim(preg_replace('/#.*$/', '', $line));
1284
                if (!$line) continue;
1285
                $file = $extensiondir . $line;
1286
                if (!file_exists($file)) continue;
1287
1288
                io_rmdir($file, true);
1289
            }
1290
        }
1291
    }
1292
}
1293
1294
// vim:ts=4:sw=4:et:
1295