1<?php 2/** 3 * Copyright 2000-2017 Horde LLC (http://www.horde.org/) 4 * 5 * See the enclosed file LICENSE for license information (ASL). If you did 6 * did not receive this file, see http://www.horde.org/licenses/apache. 7 * 8 * @category Horde 9 * @copyright 2000-2017 Horde LLC 10 * @license http://www.horde.org/licenses/apache ASL 11 * @package Turba 12 */ 13 14/** 15 * Turba Base Class. 16 * 17 * @author Chuck Hagenbuch <chuck@horde.org> 18 * @author Jon Parise <jon@horde.org> 19 * @category Horde 20 * @copyright 2000-2017 Horde LLC 21 * @license http://www.horde.org/licenses/apache ASL 22 * @package Turba 23 */ 24class Turba 25{ 26 /** 27 * The virtual path to use for VFS data. 28 */ 29 const VFS_PATH = '.horde/turba/documents'; 30 31 /** 32 * The current source. 33 * 34 * @var string 35 */ 36 static public $source; 37 38 /** 39 * Cached data. 40 * 41 * @var array 42 */ 43 static protected $_cache = array(); 44 45 /** 46 * Returns the source entries from config/backends.php that have been 47 * configured as available sources in the main Turba configuration. 48 * 49 * @return array List of available sources. 50 * @throws Horde_Exception 51 */ 52 static public function availableSources() 53 { 54 global $registry; 55 56 $s = $registry->loadConfigFile('backends.php', 'cfgSources', 'turba')->config['cfgSources']; 57 58 $sources = array(); 59 foreach ($s as $key => $source) { 60 if (empty($source['disabled'])) { 61 $sources[$key] = $source; 62 } 63 } 64 65 return $sources; 66 } 67 68 /** 69 * Get all the address books the user has the requested permissions to and 70 * return them in the user's preferred order. 71 * 72 * @param integer $permission The Horde_Perms::* constant to filter on. 73 * @param array $options Any additional options. 74 * 75 * @return array The filtered, ordered $cfgSources entries. 76 */ 77 static public function getAddressBooks($permission = Horde_Perms::READ, 78 array $options = array()) 79 { 80 return self::permissionsFilter( 81 $GLOBALS['cfgSources'], 82 $permission, 83 $options 84 ); 85 } 86 87 /** 88 * Returns the current user's default address book. 89 * 90 * @return string The default address book name. 91 */ 92 static public function getDefaultAddressbook() 93 { 94 /* In case of shares select first user owned address book as default */ 95 if (!empty($_SESSION['turba']['has_share'])) { 96 try { 97 $owned_shares = self::listShares(true); 98 if (count($owned_shares)) { 99 return key($owned_shares); 100 } 101 } catch (Exception $e) {} 102 } 103 104 reset($GLOBALS['cfgSources']); 105 return key($GLOBALS['cfgSources']); 106 } 107 108 /** 109 * Returns the sort order selected by the user. 110 * 111 * @return array TODO 112 */ 113 static public function getPreferredSortOrder() 114 { 115 return @unserialize($GLOBALS['prefs']->getValue('sortorder')); 116 } 117 118 /** 119 * Saves the sort order to the preferences backend. 120 * 121 * @param Horde_Variables $vars Variables object. 122 * @param string $source Source. 123 */ 124 static public function setPreferredSortOrder(Horde_Variables $vars, 125 $source) 126 { 127 if (!strlen($sortby = $vars->get('sortby'))) { 128 return; 129 } 130 131 $sources = self::getColumns(); 132 $columns = isset($sources[$source]) 133 ? $sources[$source] 134 : array(); 135 $column_name = self::getColumnName($sortby, $columns); 136 137 $append = true; 138 $ascending = ($vars->get('sortdir') == 0); 139 140 if ($vars->get('sortadd')) { 141 $sortorder = self::getPreferredSortOrder(); 142 foreach ($sortorder as $i => $elt) { 143 if ($elt['field'] == $column_name) { 144 $sortorder[$i]['ascending'] = $ascending; 145 $append = false; 146 } 147 } 148 } else { 149 $sortorder = array(); 150 } 151 152 if ($append) { 153 $sortorder[] = array( 154 'ascending' => $ascending, 155 'field' => $column_name 156 ); 157 } 158 159 $GLOBALS['prefs']->setValue('sortorder', serialize($sortorder)); 160 } 161 162 /** 163 * Retrieves a column's field name. 164 * 165 * @param integer $i TODO 166 * @param array $columns TODO 167 * 168 * @return string TODO 169 */ 170 static public function getColumnName($i, $columns) 171 { 172 return (($i == 0) || !isset($columns[$i - 1])) 173 ? 'name' 174 : $columns[$i - 1]; 175 } 176 177 /** 178 * TODO 179 */ 180 static public function getColumns() 181 { 182 $columns = array(); 183 $lines = explode("\n", $GLOBALS['prefs']->getValue('columns')); 184 185 foreach ($lines as $line) { 186 $line = trim($line); 187 if ($line) { 188 $cols = explode("\t", $line); 189 if (count($cols) > 1) { 190 $source = array_splice($cols, 0, 1); 191 $columns[$source[0]] = array(); 192 foreach ($cols as $col) { 193 if ($col == '__tags' || 194 isset($GLOBALS['cfgSources'][$source[0]]['map'][$col])) { 195 $columns[$source[0]][] = $col; 196 } 197 } 198 } 199 } 200 } 201 202 return $columns; 203 } 204 205 /** 206 * Builds and cleans up a composite field. 207 * 208 * @param string $format The sprintf field format. 209 * @param array $fields The fields that compose the composite field. 210 * 211 * @return string The formatted composite field. 212 */ 213 static public function formatCompositeField($format, $fields) 214 { 215 return preg_replace('/ +/', ' ', trim(vsprintf($format, $fields), " \t\n\r\0\x0B,")); 216 } 217 218 /** 219 * Returns a best guess at the lastname in a string. 220 * 221 * @param string $name String contain the full name. 222 * 223 * @return string String containing the last name. 224 */ 225 static public function guessLastname($name) 226 { 227 $name = trim(preg_replace('|\s|', ' ', $name)); 228 if (!empty($name)) { 229 /* Assume that last names are always before any commas. */ 230 if (is_int(strpos($name, ','))) { 231 $name = Horde_String::substr($name, 0, strpos($name, ',')); 232 } 233 234 /* Take out anything in parentheses. */ 235 $name = trim(preg_replace('|\(.*\)|', '', $name)); 236 237 $namelist = explode(' ', $name); 238 $name = $namelist[($nameindex = (count($namelist) - 1))]; 239 240 while (!empty($name) && 241 (($nlength = Horde_String::length($name)) < 5) && 242 strspn($name[($nlength - 1)], '.:-') && 243 !empty($namelist[($nameindex - 1)])) { 244 $name = $namelist[--$nameindex]; 245 } 246 } 247 248 return strlen($name) 249 ? $name 250 : null; 251 } 252 253 /** 254 * Formats the name according to the user's preference. 255 * 256 * If the format is 'none', the full name with all parts is returned. If 257 * the format is 'last_first' or 'first_last', only the first name and 258 * last name are returned. 259 * 260 * @param Turba_Object $ob The object to get a name from. 261 * @param string $name_format The formatting. One of 'none', 'last_first' 262 * or 'first_last'. Defaults to the user 263 * preference. 264 * 265 * @return string The formatted name, either "Firstname Lastname" 266 * or "Lastname, Firstname" depending on $name_format or 267 * the user's preference. 268 */ 269 static public function formatName(Turba_Object $ob, $name_format = null) 270 { 271 if (!$name_format) { 272 if (!isset(self::$_cache['defaultFormat'])) { 273 self::$_cache['defaultFormat'] = $GLOBALS['prefs']->getValue('name_format'); 274 } 275 $name_format = self::$_cache['defaultFormat']; 276 } 277 278 /* If no formatting, return original name. */ 279 if (!in_array($name_format, array('first_last', 'last_first'))) { 280 return $ob->getValue('name'); 281 } 282 283 /* See if we have the name fields split out explicitly. */ 284 if ($ob->hasValue('firstname') && $ob->hasValue('lastname')) { 285 return ($name_format == 'last_first') 286 ? $ob->getValue('lastname') . ', ' . $ob->getValue('firstname') 287 : $ob->getValue('firstname') . ' ' . $ob->getValue('lastname'); 288 } 289 290 /* One field, we'll have to guess. */ 291 $name = $ob->getValue('name'); 292 $lastname = self::guessLastname($name); 293 if (($name_format == 'last_first') && 294 !is_int(strpos($name, ',')) && 295 (Horde_String::length($name) > Horde_String::length($lastname))) { 296 return $lastname . ', ' . preg_replace('/\s+' . preg_quote($lastname, '/') . '/', '', $name); 297 } 298 299 if (($name_format == 'first_last') && 300 is_int(strpos($name, ',')) && 301 (Horde_String::length($name) > Horde_String::length($lastname))) { 302 return preg_replace('/' . preg_quote($lastname, '/') . ',\s*/', '', $name) . ' ' . $lastname; 303 } 304 305 return $name; 306 } 307 308 /** 309 * TODO 310 * 311 * @param mixed $data Either a single email address or an array of email 312 * addresses to format. 313 * @param string $name The personal name phrase. 314 * 315 * @return mixed Either the formatted address or an array of formatted 316 * addresses. 317 */ 318 static public function formatEmailAddresses($data, $name) 319 { 320 if (!isset(self::$_cache['useRegistry'])) { 321 self::$_cache['useRegistry'] = $GLOBALS['registry']->hasMethod('mail/batchCompose'); 322 } 323 324 $out = array(); 325 $rfc822 = $GLOBALS['injector']->getInstance('Horde_Mail_Rfc822'); 326 327 if (!is_array($data)) { 328 $data = array($data); 329 } 330 331 foreach ($data as $email_vals) { 332 foreach ($rfc822->parseAddressList($email_vals) as $ob) { 333 $addr = strval($ob); 334 $tmp = null; 335 336 if (self::$_cache['useRegistry']) { 337 try { 338 $tmp = $GLOBALS['registry']->call('mail/batchCompose', array(array($addr))); 339 } catch (Horde_Exception $e) { 340 self::$_cache['useRegistry'] = false; 341 } 342 } 343 344 $tmp = empty($tmp) 345 ? 'mailto:' . urlencode($addr) 346 : reset($tmp); 347 348 $out[] = Horde::link($tmp) . htmlspecialchars($addr) . '</a>'; 349 } 350 } 351 352 return implode(', ', $out); 353 } 354 355 /** 356 * Returns the real name, if available, of a user. 357 * 358 * @param string $uid The uid of the name to return. 359 * 360 * @return string The user's full, real name. 361 */ 362 static public function getUserName($uid) 363 { 364 if (!isset(self::$_cache['names'])) { 365 self::$_cache['names'] = array(); 366 } 367 368 if (!isset(self::$_cache['names'][$uid])) { 369 $ident = $GLOBALS['injector']->getInstance('Horde_Core_Factory_Identity')->create($uid); 370 $ident->setDefault($ident->getDefault()); 371 $name = $ident->getValue('fullname'); 372 self::$_cache['names'][$uid] = empty($name) 373 ? $uid 374 : $name; 375 } 376 377 return self::$_cache['names'][$uid]; 378 } 379 380 /** 381 * Gets extended permissions on an address book. 382 * 383 * @param Turba_Driver $addressBook The address book to get extended 384 * permissions for. 385 * @param string $permission What extended permission to get. 386 * 387 * @return mixed The requested extended permissions value, or true if it 388 * doesn't exist. 389 */ 390 static public function getExtendedPermission(Turba_Driver $addressBook, 391 $permission) 392 { 393 // We want to check the base source as extended permissions 394 // are enforced per backend, not per share. 395 $key = $addressBook->getName() . ':' . $permission; 396 397 $perms = $GLOBALS['injector']->getInstance('Horde_Perms'); 398 if (!$perms->exists('turba:sources:' . $key)) { 399 return true; 400 } 401 402 $allowed = $perms->getPermissions('turba:sources:' . $key, $GLOBALS['registry']->getAuth()); 403 if (is_array($allowed)) { 404 switch ($permission) { 405 case 'max_contacts': 406 $allowed = max($allowed); 407 break; 408 } 409 } 410 return $allowed; 411 } 412 413 /** 414 * Filters sources based on permissions. 415 * 416 * @param array $in The source list we want filtered. 417 * @param integer $permission The Horde_Perms::* constant we will filter 418 * on. 419 * @param array $options Additional options: 420 * - require_add: (boolean) Only return sources 421 * that can be added to. 422 * 423 * @return array The filtered data. 424 */ 425 static public function permissionsFilter(array $in, 426 $permission = Horde_Perms::READ, 427 array $options = array()) 428 { 429 $factory = $GLOBALS['injector']->getInstance('Turba_Factory_Driver'); 430 $out = array(); 431 432 foreach ($in as $sourceId => $source) { 433 try { 434 $driver = $factory->create($source, $sourceId); 435 if ($driver->hasPermission($permission) && 436 (empty($options['require_add']) || $driver->canAdd())) { 437 $out[$sourceId] = $source; 438 } 439 } catch (Turba_Exception $e) { 440 Horde::log($e, 'ERR'); 441 } 442 } 443 444 return $out; 445 } 446 447 /** 448 * Replaces all share-enabled sources in a source list with all shares 449 * from this source that the current user has access to. 450 * 451 * This will only sync shares that are unique to Horde (such as a SQL or 452 * Kolab sources). Any backend that supports ACLs or similar mechanism 453 * should be configured from within backends.local.php or via Horde's 454 * share_* hooks. 455 * 456 * @param array $sources The default $cfgSources array. 457 * @param boolean $owner Only return shares that the current user owns? 458 * @param array $options An array of options: 459 * - shares: Use this list of provided shares. Default is to get the 460 * list from the share system using the current user. 461 * - auth_user: Use this as the authenticated user name. 462 * 463 * @return array The $cfgSources array. 464 */ 465 public static function getConfigFromShares(array $sources, $owner = false, $options = array()) 466 { 467 global $notification, $registry, $conf, $injector, $prefs; 468 469 if (empty($options['shares'])) { 470 try { 471 $shares = self::listShares($owner); 472 } catch (Horde_Share_Exception $e) { 473 // Notify the user if we failed, but still return the $cfgSource 474 // array. 475 $notification->push($e, 'horde.error'); 476 return $sources; 477 } 478 } else { 479 $shares = $options['shares']; 480 } 481 482 /* See if any of our sources are configured to handle all otherwise 483 * unassigned Horde_Share objects. */ 484 $all_shares = null; 485 foreach ($sources as $key => $cfg) { 486 if (!empty($cfg['all_shares'])) { 487 // Indicate the source handler that catches unassigned shares. 488 $all_shares = $key; 489 } 490 } 491 492 if (empty($options['auth_user'])) { 493 $auth_user = $registry->getAuth(); 494 } else { 495 $auth_user = $options['auth_user']; 496 } 497 498 $sortedSources = $vbooks = array(); 499 $personal = false; 500 501 foreach ($shares as $name => &$share) { 502 if (isset($sources[$name])) { 503 continue; 504 } 505 506 $personal |= ($share->get('owner') == $auth_user); 507 508 $params = @unserialize($share->get('params')); 509 if (empty($params['source']) && !empty($all_shares)) { 510 $params['source'] = $all_shares; 511 } 512 513 if (isset($params['type']) && $params['type'] == 'vbook') { 514 // We load vbooks last in case they're based on other shares. 515 $params['share'] = $share; 516 $vbooks[$name] = $params; 517 } elseif (!empty($params['source']) && 518 !empty($sources[$params['source']]['use_shares'])) { 519 if (empty($params['name'])) { 520 $params['name'] = $name; 521 $share->set('params', serialize($params)); 522 try { 523 $share->save(); 524 } catch (Horde_Share_Exception $e) { 525 Horde::log($e, 'ERR'); 526 } 527 } 528 529 $info = $sources[$params['source']]; 530 $info['params']['config'] = $sources[$params['source']]; 531 $info['params']['config']['params']['share'] = $share; 532 $info['params']['config']['params']['name'] = $params['name']; 533 $info['title'] = $share->get('name'); 534 if ($share->get('owner') != $auth_user) { 535 $info['title'] .= ' [' . $registry->convertUsername($share->get('owner'), false) . ']'; 536 } 537 $info['type'] = 'share'; 538 $info['use_shares'] = false; 539 $sortedSources[$params['source']][$name] = $info; 540 } 541 } 542 543 // Check for the user's default share and built new source list. 544 $newSources = array(); 545 foreach (array_keys($sources) as $source) { 546 if (empty($sources[$source]['use_shares'])) { 547 $newSources[$source] = $sources[$source]; 548 continue; 549 } 550 551 if (isset($sortedSources[$source])) { 552 $newSources = array_merge($newSources, $sortedSources[$source]); 553 } 554 555 if (!empty($conf['share']['auto_create']) && 556 $auth_user && 557 !$personal) { 558 // User's default share is missing. 559 try { 560 $driver = $injector 561 ->getInstance('Turba_Factory_Driver') 562 ->create($source); 563 } catch (Turba_Exception $e) { 564 $notification->push($e->getMessage(), 'horde.error'); 565 continue; 566 } 567 568 $sourceKey = strval(new Horde_Support_Randomid()); 569 try { 570 $share = $driver->createShare( 571 $sourceKey, 572 array( 573 'params' => array( 574 'source' => $source, 575 'default' => true, 576 'name' => $auth_user 577 ) 578 ) 579 ); 580 581 $source_config = $sources[$source]; 582 $source_config['params']['share'] = $share; 583 $newSources[$sourceKey] = $source_config; 584 $personal = true; 585 $prefs->setValue('default_dir', $share->getName()); 586 } catch (Horde_Share_Exception $e) { 587 Horde::log($e, 'ERR'); 588 } 589 } 590 } 591 592 // Add vbooks now that all available address books are loaded. 593 foreach ($vbooks as $name => $params) { 594 if (isset($newSources[$params['source']])) { 595 $newSources[$name] = array( 596 'title' => $shares[$name]->get('name'), 597 'type' => 'vbook', 598 'params' => $params, 599 'export' => true, 600 'browse' => true, 601 'map' => $newSources[$params['source']]['map'], 602 'search' => $newSources[$params['source']]['search'], 603 'strict' => $newSources[$params['source']]['strict'], 604 'use_shares' => false, 605 ); 606 } else { 607 $notification->push(sprintf( 608 _("Removing the virtual address book \"%s\" because the parent source has disappeared."), 609 $shares[$name]->get('name')), 'horde.message' 610 ); 611 try { 612 $injector->getInstance('Turba_Shares')->removeShare($shares[$name]); 613 } catch (Horde_Share_Exception $e) { 614 Horde::log($e, 'ERR'); 615 } 616 } 617 } 618 619 return $newSources; 620 } 621 622 /** 623 * Retrieve a new source config entry based on a Turba share. 624 * 625 * @param Horde_Share_Object object The share to base config on. 626 * 627 * @return array The $cfgSource entry for this share source. 628 */ 629 public static function getSourceFromShare(Horde_Share_Object $share) 630 { 631 // Require a fresh config file. 632 $cfgSources = self::availableSources(); 633 634 $params = @unserialize($share->get('params')); 635 $newConfig = $cfgSources[$params['source']]; 636 $newConfig['params']['config'] = $cfgSources[$params['source']]; 637 $newConfig['params']['config']['params']['share'] = $share; 638 $newConfig['params']['config']['params']['name'] = $params['name']; 639 $newConfig['title'] = $share->get('name'); 640 $newConfig['type'] = 'share'; 641 $newConfig['use_shares'] = false; 642 643 return $newConfig; 644 } 645 646 /** 647 * Returns all shares the current user has specified permissions to. 648 * 649 * @param boolean $owneronly Only return address books owned by the user? 650 * Defaults to false. 651 * @param integer $permission Permissions to filter by. 652 * 653 * @return array Shares the user has the requested permissions to. 654 */ 655 static public function listShares($owneronly = false, 656 $permission = Horde_Perms::READ) 657 { 658 if (!$GLOBALS['session']->get('turba', 'has_share') || 659 ($owneronly && !$GLOBALS['registry']->getAuth())) { 660 return array(); 661 } 662 663 try { 664 return $GLOBALS['injector']->getInstance('Turba_Shares')->listShares( 665 $GLOBALS['registry']->getAuth(), 666 array( 667 'attributes' => $owneronly ? $GLOBALS['registry']->getAuth() : null, 668 'perm' => $permission 669 ) 670 ); 671 } catch (Horde_Share_Exception $e) { 672 Horde::log($e, 'ERR'); 673 return array(); 674 } 675 } 676 677 /** 678 * Create a new Turba share. 679 * 680 * @param string $share_name The id for the new share. 681 * @param array $params Parameters for the new share. 682 * 683 * @return Horde_Share The new share object. 684 * @throws Turba_Exception 685 */ 686 static public function createShare($share_name, $params) 687 { 688 if (isset($params['name'])) { 689 $name = $params['name']; 690 unset($params['name']); 691 } else { 692 /* Sensible default for empty display names */ 693 $name = sprintf(_("Address book of %s"), $GLOBALS['injector']->getInstance('Horde_Core_Factory_Identity')->create()->getName()); 694 } 695 696 /* Generate the new share. */ 697 try { 698 $turba_shares = $GLOBALS['injector']->getInstance('Turba_Shares'); 699 700 $share = $turba_shares->newShare($GLOBALS['registry']->getAuth(), $share_name, $name); 701 702 /* Now any other params. */ 703 foreach ($params as $key => $value) { 704 if (!is_scalar($value)) { 705 $value = serialize($value); 706 } 707 $share->set($key, $value); 708 } 709 $turba_shares->addShare($share); 710 $share->save(); 711 } catch (Horde_Share_Exception $e) { 712 Horde::log($e, 'ERR'); 713 throw new Turba_Exception($e); 714 } 715 716 return $share; 717 } 718 719 /** 720 * Add browse.js javascript to page. 721 */ 722 static public function addBrowseJs() 723 { 724 global $page_output; 725 726 $page_output->addScriptFile('browse.js'); 727 $page_output->addInlineJsVars(array( 728 'TurbaBrowse.confirmdelete' => _("Are you sure that you want to delete %s?"), 729 'TurbaBrowse.contact1' => _("You must select at least one contact first."), 730 'TurbaBrowse.contact2' => _("You must select a target contact list."), 731 'TurbaBrowse.contact3' => _("Please name the new contact list:"), 732 'TurbaBrowse.copymove' => _("You must select a target address book."), 733 'TurbaBrowse.submit' => _("Are you sure that you want to delete the selected contacts?") 734 )); 735 } 736 737 /** 738 * Return an array of all available attributes of type 'email'. Optionally, 739 * ensure the field is defined in the specified $source. 740 * 741 * @param $source string An optional source identifier. 742 * @param $searchable boolean If true, and $source is provided, ensure that 743 * the email field is a configured searchable 744 * field. 745 * 746 * @return array An array of email fields. 747 * @since 4.2.9 748 */ 749 public static function getAvailableEmailFields($source = null, $searchable = true) 750 { 751 global $attributes, $injector, $cfgSources; 752 753 if (!empty($source)) { 754 $driver = $injector->getInstance('Turba_Factory_Driver') 755 ->create($source); 756 } 757 758 $emailFields = array(); 759 foreach ($attributes as $field => $data) { 760 if ($data['type'] == 'email') { 761 if (empty($source) || (!empty($source) && 762 in_array($field, array_keys($driver->map)) && 763 (!$searchable || ($searchable && in_array($field, $cfgSources[$source]['search']))))) 764 765 $emailFields[] = $field; 766 } 767 } 768 769 return $emailFields; 770 } 771 772} 773