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 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 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