1<?php
2
3require_once(INCLUDE_DIR.'/class.config.php');
4class PluginConfig extends Config {
5    var $table = CONFIG_TABLE;
6    var $form;
7
8    function __construct($name) {
9        // Use parent constructor to place configurable information into the
10        // central config table in a namespace of "plugin.<id>"
11        parent::__construct("plugin.$name");
12        foreach ($this->getOptions() as $name => $field) {
13            if ($this->exists($name))
14                $this->config[$name]->value = $field->to_php($this->get($name));
15            elseif ($default = $field->get('default'))
16                $this->defaults[$name] = $default;
17        }
18    }
19
20    /* abstract */
21    function getOptions() {
22        return array();
23    }
24
25    function hasCustomConfig() {
26        return $this instanceof PluginCustomConfig;
27    }
28
29    /**
30     * Retreive a Form instance for the configurable options offered in
31     * ::getOptions
32     */
33    function getForm() {
34        if (!isset($this->form)) {
35            $this->form = new SimpleForm($this->getOptions());
36            if ($_SERVER['REQUEST_METHOD'] != 'POST')
37                $this->form->data($this->getInfo());
38        }
39        return $this->form;
40    }
41
42    /**
43     * commit
44     *
45     * Used in the POST request of the configuration process. The
46     * ::getForm() method should be used to retrieve a configuration form
47     * for this plugin. That form should be submitted via a POST request,
48     * and this method should be called in that request. The data from the
49     * POST request will be interpreted and will adjust the configuration of
50     * this field
51     *
52     * Parameters:
53     * errors - (OUT array) receives validation errors of the parsed
54     *      configuration form
55     *
56     * Returns:
57     * (bool) true if the configuration was updated, false if there were
58     * errors. If false, the errors were written into the received errors
59     * array.
60     */
61    function commit(&$errors=array()) {
62        global $msg;
63
64        if ($this->hasCustomConfig())
65            return $this->saveCustomConfig($errors);
66
67        return $this->commitForm($errors);
68    }
69
70    function commitForm(&$errors=array()) {
71        global $msg;
72
73        $f = $this->getForm();
74        $commit = false;
75        if ($f->isValid()) {
76            $config = $f->getClean();
77            $commit = $this->pre_save($config, $errors);
78        }
79        $errors += $f->errors();
80        if ($commit && count($errors) === 0) {
81            $dbready = array();
82            foreach ($config as $name => $val) {
83                $field = $f->getField($name);
84                try {
85                    $dbready[$name] = $field->to_database($val);
86                }
87                catch (FieldUnchanged $e) {
88                    // Don't save the field value
89                    continue;
90                }
91            }
92            if ($this->updateAll($dbready)) {
93                if (!$msg)
94                    $msg = 'Successfully updated configuration';
95                return true;
96            }
97        }
98        return false;
99    }
100
101    /**
102     * Pre-save hook to check configuration for errors (other than obvious
103     * validation errors) prior to saving. Add an error to the errors list
104     * or return boolean FALSE if the config commit should be aborted.
105     */
106    function pre_save($config, &$errors) {
107        return true;
108    }
109
110    /**
111     * Remove all configuration for this plugin -- used when the plugin is
112     * uninstalled
113     */
114    function purge() {
115        $sql = 'DELETE FROM '.$this->table
116            .' WHERE `namespace`='.db_input($this->getNamespace());
117        return (db_query($sql) && db_affected_rows());
118    }
119}
120
121/**
122 * Interface: PluginCustomConfig
123 *
124 * Allows a plugin to specify custom configuration pages. If the
125 * configuration cannot be suited by a single page, single form, then
126 * the plugin can use the ::renderCustomConfig() method to trigger
127 * rendering the page, and use ::saveCustomConfig() to trigger
128 * validating and saving the custom configuration.
129 */
130interface PluginCustomConfig {
131    function renderCustomConfig();
132    function saveCustomConfig();
133}
134
135class PluginManager {
136    static private $plugin_info = array();
137    static private $plugin_list = array();
138
139    /**
140     * boostrap
141     *
142     * Used to bootstrap the plugin subsystem and initialize all the plugins
143     * currently enabled.
144     */
145    function bootstrap() {
146        foreach ($this->allActive() as $p)
147            $p->bootstrap();
148    }
149
150    /**
151     * allActive
152     *
153     * Scans the plugin registry to find all installed and active plugins.
154     * Those plugins are included, instanciated, and cached in a list.
155     *
156     * Returns:
157     * Array<Plugin> a cached list of instanciated plugins for all installed
158     * and active plugins
159     */
160    static function allInstalled() {
161        if (static::$plugin_list)
162            return static::$plugin_list;
163
164        $sql = 'SELECT * FROM '.PLUGIN_TABLE.' ORDER BY name';
165        if (!($res = db_query($sql)))
166            return static::$plugin_list;
167
168        while ($ht = db_fetch_array($res)) {
169            // XXX: Only read active plugins here. allInfos() will
170            //      read all plugins
171            $info = static::getInfoForPath(
172                INCLUDE_DIR . $ht['install_path'], $ht['isphar']);
173
174            list($path, $class) = explode(':', $info['plugin']);
175            if (!$class)
176                $class = $path;
177            elseif ($ht['isphar'])
178                @include_once('phar://' . INCLUDE_DIR . $ht['install_path']
179                    . '/' . $path);
180            else
181                @include_once(INCLUDE_DIR . $ht['install_path']
182                    . '/' . $path);
183
184            if (!class_exists($class)) {
185                $class = 'DefunctPlugin';
186                $ht['isactive'] = false;
187                $info = array('name' => $ht['name'] . ' '. __('(defunct — missing)'));
188            }
189
190            if ($ht['isactive']) {
191                static::$plugin_list[$ht['install_path']]
192                    = new $class($ht['id']);
193            }
194            else {
195                // Get instance without calling the constructor. Thanks
196                // http://stackoverflow.com/a/2556089
197                $a = unserialize(
198                    sprintf(
199                        'O:%d:"%s":0:{}',
200                        strlen($class), $class
201                    )
202                );
203                // Simulate __construct() and load()
204                $a->id = $ht['id'];
205                $a->ht = $ht;
206                $a->info = $info;
207                static::$plugin_list[$ht['install_path']] = &$a;
208                unset($a);
209            }
210        }
211        return static::$plugin_list;
212    }
213
214    static function getPluginByName($name, $active=false) {
215        $sql = sprintf('SELECT * FROM %s WHERE name="%s"', PLUGIN_TABLE, $name);
216        if ($active)
217            $sql = sprintf('%s AND isactive = true', $sql);
218        if (!($res = db_query($sql)))
219            return false;
220        $ht = db_fetch_array($res);
221        return $ht['name'];
222    }
223
224    static function auditPlugin() {
225        return self::getPluginByName('Help Desk Audit', true);
226    }
227
228    static function allActive() {
229        $plugins = array();
230        foreach (static::allInstalled() as $p)
231            if ($p instanceof Plugin && $p->isActive())
232                $plugins[] = $p;
233        return $plugins;
234    }
235
236    function throwException($errno, $errstr) {
237        throw new RuntimeException($errstr);
238    }
239
240    /**
241     * allInfos
242     *
243     * Scans the plugin folders for installed plugins. For each one, the
244     * plugin.php file is included and the info array returned in added to
245     * the list returned.
246     *
247     * Returns:
248     * Information about all available plugins. The registry will have to be
249     * queried to determine if the plugin is installed
250     */
251    static function allInfos() {
252        foreach (glob(INCLUDE_DIR . 'plugins/*',
253                GLOB_NOSORT|GLOB_BRACE) as $p) {
254            $is_phar = false;
255            if (substr($p, strlen($p) - 5) == '.phar'
256                    && class_exists('Phar')
257                    && Phar::isValidPharFilename($p)) {
258                try {
259                // When public key is invalid, openssl throws a
260                // 'supplied key param cannot be coerced into a public key' warning
261                // and phar ignores sig verification.
262                // We need to protect from that by catching the warning
263                // Thanks, https://github.com/koto/phar-util
264                set_error_handler(array('self', 'throwException'));
265                $ph = new Phar($p);
266                restore_error_handler();
267                // Verify the signature
268                $ph->getSignature();
269                $p = 'phar://' . $p;
270                $is_phar = true;
271                } catch (UnexpectedValueException $e) {
272                    // Cannot find signature file
273                } catch (RuntimeException $e) {
274                    // Invalid signature file
275                }
276
277            }
278
279            if (!is_file($p . '/plugin.php'))
280                // Invalid plugin -- must define "/plugin.php"
281                continue;
282
283            // Cache the info into static::$plugin_info
284            static::getInfoForPath($p, $is_phar);
285        }
286        return static::$plugin_info;
287    }
288
289    static function getInfoForPath($path, $is_phar=false) {
290        static $defaults = array(
291            'include' => 'include/',
292            'stream' => false,
293        );
294
295        $install_path = str_replace(INCLUDE_DIR, '', $path);
296        $install_path = str_replace('phar://', '', $install_path);
297        if ($is_phar && substr($path, 0, 7) != 'phar://')
298            $path = 'phar://' . $path;
299        if (!isset(static::$plugin_info[$install_path])) {
300            // plugin.php is require to return an array of informaiton about
301            // the plugin.
302            if (!file_exists($path . '/plugin.php'))
303                return false;
304            $info = array_merge($defaults, (@include $path . '/plugin.php'));
305            $info['install_path'] = $install_path;
306
307            // XXX: Ensure 'id' key isset
308            static::$plugin_info[$install_path] = $info;
309        }
310        return static::$plugin_info[$install_path];
311    }
312
313    function getInstance($path) {
314        static $instances = array();
315        if (!isset($instances[$path])
316                && ($ps = static::allInstalled())
317                && ($ht = $ps[$path])) {
318
319            $info = static::getInfoForPath($path);
320
321            // $ht may be the plugin instance
322            if ($ht instanceof Plugin)
323                return $ht;
324
325            // Usually this happens when the plugin is being enabled
326            list($path, $class) = explode(':', $info['plugin']);
327            if (!$class)
328                $class = $path;
329            else
330                require_once(INCLUDE_DIR . $info['install_path'] . '/' . $path);
331            $instances[$path] = new $class($ht['id']);
332        }
333        return $instances[$path];
334    }
335
336    /**
337     * install
338     *
339     * Used to install a plugin that is in-place on the filesystem, but not
340     * registered in the plugin registry -- the %plugin table.
341     */
342    function install($path) {
343        $is_phar = substr($path, strlen($path) - 5) == '.phar';
344        if (!($info = $this->getInfoForPath(INCLUDE_DIR . $path, $is_phar)))
345            return false;
346
347        $sql='INSERT INTO '.PLUGIN_TABLE.' SET installed=NOW() '
348            .', install_path='.db_input($path)
349            .', name='.db_input($info['name'])
350            .', isphar='.db_input($is_phar);
351        if ($info['version'])
352            $sql.=', version='.db_input($info['version']);
353        if (!db_query($sql) || !db_affected_rows())
354            return false;
355        static::clearCache();
356        return true;
357    }
358
359    static function clearCache() {
360        static::$plugin_list = array();
361    }
362}
363
364/**
365 * Class: Plugin (abstract)
366 *
367 * Base class for plugins. Plugins should inherit from this class and define
368 * the useful pieces of the
369 */
370abstract class Plugin {
371    /**
372     * Configuration manager for the plugin. Should be the name of a class
373     * that inherits from PluginConfig. This is abstract and must be defined
374     * by the plugin subclass.
375     */
376    var $config_class = null;
377    var $id;
378    var $info;
379
380    const VERIFIED = 1;             // Thumbs up
381    const VERIFY_EXT_MISSING = 2;   // PHP extension missing
382    const VERIFY_FAILED = 3;        // Bad signature data
383    const VERIFY_ERROR = 4;         // Unable to verify (unexpected error)
384    const VERIFY_NO_KEY = 5;        // Public key missing
385    const VERIFY_DNS_PASS = 6;      // DNS check passes, cannot verify sig
386
387    static $verify_domain = 'updates.osticket.com';
388
389    function __construct($id) {
390        $this->id = $id;
391        $this->load();
392    }
393
394    function load() {
395        $sql = 'SELECT * FROM '.PLUGIN_TABLE.' WHERE
396            `id`='.db_input($this->id);
397        if (($res = db_query($sql)) && ($ht=db_fetch_array($res)))
398            $this->ht = $ht;
399        $this->info = PluginManager::getInfoForPath($this->ht['install_path'],
400            $this->isPhar());
401    }
402
403    function getId() { return $this->id; }
404    function getName() { return $this->__($this->info['name']); }
405    function isActive() { return $this->ht['isactive']; }
406    function isPhar() { return $this->ht['isphar']; }
407    function getVersion() { return $this->ht['version'] ?: $this->info['version']; }
408    function getInstallDate() { return $this->ht['installed']; }
409    function getInstallPath() { return $this->ht['install_path']; }
410
411    function getIncludePath() {
412        return realpath(INCLUDE_DIR . $this->info['install_path'] . '/'
413            . $this->info['include_path']) . '/';
414    }
415
416    /**
417     * Main interface for plugins. Called at the beginning of every request
418     * for each installed plugin. Plugins should register functionality and
419     * connect to signals, etc.
420     */
421    abstract function bootstrap();
422
423    /**
424     * uninstall
425     *
426     * Removes the plugin from the plugin registry. The files remain on the
427     * filesystem which would allow the plugin to be reinstalled. The
428     * configuration for the plugin is also removed. If the plugin is
429     * reinstalled, it will have to be reconfigured.
430     */
431    function uninstall(&$errors) {
432        if ($this->pre_uninstall($errors) === false)
433            return false;
434
435        $sql = 'DELETE FROM '.PLUGIN_TABLE
436            .' WHERE id='.db_input($this->getId());
437        PluginManager::clearCache();
438        if (!db_query($sql) || !db_affected_rows())
439            return false;
440
441        if ($config = $this->getConfig())
442            $config->purge();
443
444        return true;
445    }
446
447    /**
448     * pre_uninstall
449     *
450     * Hook function to veto the uninstallation request. Return boolean
451     * FALSE if the uninstall operation should be aborted.
452     */
453    function pre_uninstall(&$errors) {
454        return true;
455    }
456
457    function enable() {
458        $sql = 'UPDATE '.PLUGIN_TABLE
459            .' SET isactive=1 WHERE id='.db_input($this->getId());
460        PluginManager::clearCache();
461        return (db_query($sql) && db_affected_rows());
462    }
463
464    function disable() {
465        $sql = 'UPDATE '.PLUGIN_TABLE
466            .' SET isactive=0 WHERE id='.db_input($this->getId());
467        PluginManager::clearCache();
468        return (db_query($sql) && db_affected_rows());
469    }
470
471    /**
472     * upgrade
473     *
474     * Upgrade the plugin. This is used to migrate the database pieces of
475     * the plugin using the database migration stream packaged with the
476     * plugin.
477     */
478    function upgrade() {
479    }
480
481    function getConfig() {
482        static $config = null;
483        if ($config === null && $this->config_class)
484            $config = new $this->config_class($this->getId());
485
486        return $config;
487    }
488
489    function source($what) {
490        $what = str_replace('\\', '/', $what);
491        if ($what && $what[0] != '/')
492            $what = $this->getIncludePath() . $what;
493        include_once $what;
494    }
495
496    static function lookup($id) { //Assuming local ID is the only lookup used!
497        $path = false;
498        if ($id && is_numeric($id)) {
499            $sql = 'SELECT install_path FROM '.PLUGIN_TABLE
500                .' WHERE id='.db_input($id);
501            $path = db_result(db_query($sql));
502        }
503        if ($path)
504           return PluginManager::getInstance($path);
505    }
506
507    /**
508     * Function: isVerified
509     *
510     * This will help verify the content, integrity, oversight, and origin
511     * of plugins, language packs and other modules distributed for
512     * osTicket.
513     *
514     * This idea is that the signature of the PHAR file will be registered
515     * in DNS, for instance,
516     * `7afc8bf80b0555bed88823306744258d6030f0d9.updates.osticket.com`, for
517     * a PHAR file with a SHA1 signature of
518     * `7afc8bf80b0555bed88823306744258d6030f0d9 `, which will resolve to a
519     * string like the following:
520     * ```
521     * "v=1; i=storage:s3; s=MEUCIFw6A489eX4Oq17BflxCZ8+MH6miNjtcpScUoKDjmb
522     * lsAiEAjiBo9FzYtV3WQtW6sbhPlJXcoPpDfYyQB+BFVBMps4c=; V=0.1;"
523     * ```
524     * Which is a simple semicolon separated key-value pair string with the
525     * following keys
526     *
527     *   Key | Description
528     *  :----|:---------------------------------------------------
529     *   v   | Algorithm version
530     *   i   | Plugin 'id' registered in plugin.php['id']
531     *   V   | Plugin 'version' registered in plugin.php['version']
532     *   s   | OpenSSL signature of the PHAR SHA1 signature using a
533     *       | private key (specified on the command line)
534     *
535     * The public key, which will be distributed with osTicket, can be used
536     * to verify the signature of the PHAR file from the data received from
537     * DNS.
538     *
539     * Parameters:
540     * $phar - (string) filename of phar file to verify
541     *
542     * Returns:
543     * (int) -
544     *      Plugin::VERIFIED upon success
545     *      Plugin::VERIFY_DNS_PASS if found in DNS but cannot verify sig
546     *      Plugin::VERIFY_NO_KEY if public key not found in include/plugins
547     *      Plugin::VERIFY_FAILED if the plugin fails validation
548     *      Plugin::VERIFY_EXT_MISSING if a PHP extension is required
549     *      Plugin::VERIFY_ERROR if an unexpected error occurred
550     */
551    static function isVerified($phar) {
552        static $pubkey = null;
553
554        if (!class_exists('Phar') || !extension_loaded('openssl'))
555            return self::VERIFY_EXT_MISSING;
556        elseif (!file_exists(INCLUDE_DIR . '/plugins/updates.pem'))
557            return self::VERIFY_NO_KEY;
558
559        if (!isset($pubkey)) {
560            $pubkey = openssl_pkey_get_public(
561                    file_get_contents(INCLUDE_DIR . 'plugins/updates.pem'));
562        }
563        if (!$pubkey) {
564            return self::VERIFY_ERROR;
565        }
566
567        $P = new Phar($phar);
568        $sig = $P->getSignature();
569        $info = array();
570        $ignored = null;
571        if ($r = dns_get_record($sig['hash'].'.'.self::$verify_domain.'.', DNS_TXT)) {
572            foreach ($r as $rec) {
573                foreach (explode(';', $rec['txt']) as $kv) {
574                    list($k, $v) = explode('=', trim($kv));
575                    $info[$k] = trim($v);
576                }
577                if ($info['v'] && $info['s'])
578                    break;
579            }
580        }
581
582        if (is_array($info) && isset($info['v'])) {
583            switch ($info['v']) {
584            case '1':
585                if (!($signature = base64_decode($info['s'])))
586                    return self::VERIFY_FAILED;
587                elseif (!function_exists('openssl_verify'))
588                    return self::VERIFY_DNS_PASS;
589
590                $codes = array(
591                    -1 => self::VERIFY_ERROR,
592                    0 => self::VERIFY_FAILED,
593                    1 => self::VERIFIED,
594                );
595                $result = openssl_verify($sig['hash'], $signature, $pubkey,
596                    OPENSSL_ALGO_SHA1);
597                return $codes[$result];
598            }
599        }
600        return self::VERIFY_FAILED;
601    }
602
603    static function showVerificationBadge($phar) {
604        switch (self::isVerified($phar)) {
605        case self::VERIFIED:
606            $show_lock = true;
607        case self::VERIFY_DNS_PASS: ?>
608        &nbsp;
609        <span class="label label-verified" title="<?php
610            if ($show_lock) echo sprintf(__('Verified by %s'), self::$verify_domain);
611            ?>"> <?php
612            if ($show_lock) echo '<i class="icon icon-lock"></i>'; ?>
613            <?php echo $show_lock ? __('Verified') : __('Registered'); ?></span>
614<?php       break;
615        case self::VERIFY_FAILED: ?>
616        &nbsp;
617        <span class="label label-danger" title="<?php
618            echo __('The originator of this extension cannot be verified');
619            ?>"><i class="icon icon-warning-sign"></i></span>
620<?php       break;
621        }
622    }
623
624    /**
625     * Function: __
626     *
627     * Translate a single string (without plural alternatives) from the
628     * langauge pack installed in this plugin. The domain is auto-configured
629     * and detected from the plugin install path.
630     */
631    function __($msgid) {
632        if (!isset($this->translation)) {
633            // Detect the domain from the plugin install-path
634            $groups = array();
635            preg_match('`plugins/(\w+)(?:.phar)?`', $this->getInstallPath(), $groups);
636
637            $domain = $groups[1];
638            if (!$domain)
639                return $msgid;
640
641            $this->translation = self::translate($domain);
642        }
643        list($__, $_N) = $this->translation;
644        return $__($msgid);
645    }
646
647    // Domain-specific translations (plugins)
648    /**
649     * Function: translate
650     *
651     * Convenience function to setup translation functions for other
652     * domains. This is of greatest benefit for plugins. This will return
653     * two functions to perform the translations. The first will translate a
654     * single string, the second will translate a plural string.
655     *
656     * Parameters:
657     * $domain - (string) text domain. The location of the MO.php file
658     *      will be (path)/LC_MESSAGES/(locale)/(domain).mo.php. The (path)
659     *      can be set via the $options parameter
660     * $options - (array<string:mixed>) Extra options for the setup
661     *      "path" - (string) path to the folder containing the LC_MESSAGES
662     *          folder. The (locale) setting is set externally respective to
663     *          the user. If this is not set, the directory of the caller is
664     *          assumed, plus '/i18n'.  This is geared for plugins to be
665     *          built with i18n content inside the '/i18n/' folder.
666     *
667     * Returns:
668     * Translation utility functions which mimic the __() and _N()
669     * functions. Note that two functions are returned. Capture them with a
670     * PHP list() construct.
671     *
672     * Caveats:
673     * When desiging plugins which might be installed in versions of
674     * osTicket which don't provide this function, use this compatibility
675     * interface:
676     *
677     * // Provide compatibility function for versions of osTicket prior to
678     * // translation support (v1.9.4)
679     * function translate($domain) {
680     *     if (!method_exists('Plugin', 'translate')) {
681     *         return array(
682     *             function($x) { return $x; },
683     *             function($x, $y, $n) { return $n != 1 ? $y : $x; },
684     *         );
685     *     }
686     *     return Plugin::translate($domain);
687     * }
688     */
689    static function translate($domain, $options=array()) {
690
691        // Configure the path for the domain. If no
692        $path = @$options['path'];
693        if (!$path) {
694            # Fetch the working path of the caller
695            $bt = debug_backtrace(false);
696            $path = dirname($bt[0]["file"]) . '/i18n';
697        }
698        $path = rtrim($path, '/') . '/';
699
700        $D = TextDomain::lookup($domain);
701        $D->setPath($path);
702        $trans = $D->getTranslation();
703
704        return array(
705            // __()
706            function($msgid) use ($trans) {
707                return $trans->translate($msgid);
708            },
709            // _N()
710            function($singular, $plural, $n) use ($trans) {
711                return $trans->ngettext($singular, $plural, $n);
712            },
713        );
714    }
715}
716
717class DefunctPlugin extends Plugin {
718    function bootstrap() {}
719
720    function enable() {
721        return false;
722    }
723}
724?>
725