1<?php 2/** 3 * EGroupware API: Contacts 4 * 5 * @link http://www.egroupware.org 6 * @author Cornelius Weiss <egw@von-und-zu-weiss.de> 7 * @author Ralf Becker <RalfBecker-AT-outdoor-training.de> 8 * @author Joerg Lehrke <jlehrke@noc.de> 9 * @package api 10 * @subpackage contacts 11 * @copyright (c) 2005-16 by Ralf Becker <RalfBecker-AT-outdoor-training.de> 12 * @copyright (c) 2005/6 by Cornelius Weiss <egw@von-und-zu-weiss.de> 13 * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License 14 * @version $Id$ 15 */ 16 17namespace EGroupware\Api; 18 19use calendar_bo; // to_do: do NOT require it, just use if there 20 21/** 22 * Business object for contacts 23 */ 24class Contacts extends Contacts\Storage 25{ 26 27 /** 28 * Birthdays are read into the cache, cache is expired when a 29 * birthday changes, or after 10 days. 30 */ 31 const BIRTHDAY_CACHE_TIME = 864000; /* 10 days*/ 32 33 /** 34 * Custom ACL allowing to share into the AB / setting shared_with 35 */ 36 const ACL_SHARED = Acl::CUSTOM1; 37 /** 38 * Mask to allow to share into the AB, at least one of the following need to be set: 39 * - custom ACL_SHARED 40 * - ACL::EDIT 41 */ 42 const CHECK_ACL_SHARED = Acl::EDIT|self::ACL_SHARED; 43 44 /** 45 * @var int $now_su actual user (!) time 46 */ 47 var $now_su; 48 49 /** 50 * @var array $timestamps timestamps 51 */ 52 var $timestamps = array('modified','created'); 53 54 /** 55 * @var array $fileas_types 56 */ 57 var $fileas_types = array( 58 'org_name: n_family, n_given', 59 'org_name: n_family, n_prefix', 60 'org_name: n_given n_family', 61 'org_name: n_fn', 62 'org_name, org_unit: n_family, n_given', 63 'org_name, adr_one_locality: n_family, n_given', 64 'org_name, org_unit, adr_one_locality: n_family, n_given', 65 'n_family, n_given: org_name', 66 'n_family, n_given (org_name)', 67 'n_family, n_prefix: org_name', 68 'n_given n_family: org_name', 69 'n_prefix n_family: org_name', 70 'n_fn: org_name', 71 'org_name', 72 'org_name - org_unit', 73 'n_given n_family', 74 'n_prefix n_family', 75 'n_family, n_given', 76 'n_family, n_prefix', 77 'n_fn', 78 'n_family, n_given (bday)', 79 ); 80 81 /** 82 * @var array $org_fields fields belonging to the (virtual) organisation entry 83 */ 84 var $org_fields = array( 85 'org_name', 86 'org_unit', 87 'adr_one_street', 88 'adr_one_street2', 89 'adr_one_locality', 90 'adr_one_region', 91 'adr_one_postalcode', 92 'adr_one_countryname', 93 'adr_one_countrycode', 94 'label', 95 'tel_work', 96 'tel_fax', 97 'tel_assistent', 98 'assistent', 99 'email', 100 'url', 101 'tz', 102 ); 103 104 /** 105 * Which fields is a (non-admin) user allowed to edit in his own account 106 * 107 * @var array 108 */ 109 var $own_account_acl; 110 111 /** 112 * @var double $org_common_factor minimum percentage of the contacts with identical values to construct the "common" (virtual) org-entry 113 */ 114 var $org_common_factor = 0.6; 115 116 var $contact_fields = array(); 117 var $business_contact_fields = array(); 118 var $home_contact_fields = array(); 119 120 /** 121 * Set Logging 122 * 123 * @var boolean 124 */ 125 var $log = false; 126 var $logfile = '/tmp/log-addressbook_bo'; 127 128 /** 129 * Number and message of last error or false if no error, atm. only used for saving 130 * 131 * @var string/boolean 132 */ 133 var $error; 134 /** 135 * Addressbook preferences of the user 136 * 137 * @var array 138 */ 139 var $prefs; 140 /** 141 * Default addressbook for new contacts, if no addressbook is specified (user preference) 142 * 143 * @var int 144 */ 145 var $default_addressbook; 146 /** 147 * Default addressbook is the private one 148 * 149 * @var boolean 150 */ 151 var $default_private; 152 /** 153 * Use a separate private addressbook (former private flag), for contacts not shareable via regular read acl 154 * 155 * @var boolean 156 */ 157 var $private_addressbook = false; 158 /** 159 * Categories object 160 * 161 * @var Categories 162 */ 163 var $categories; 164 165 /** 166 * Tracking changes 167 * 168 * @var Contacts\Tracking 169 */ 170 protected $tracking; 171 172 /** 173 * Keep deleted addresses, or really delete them 174 * Set in Admin -> Addressbook -> Site Configuration 175 * ''=really delete, 'history'=keep, only admins delete, 'userpurge'=keep, users delete 176 * 177 * @var string 178 */ 179 protected $delete_history = ''; 180 181 /** 182 * Constructor 183 * 184 * @param string $contact_app ='addressbook' used for acl->get_grants() 185 * @param Db $db =null 186 */ 187 function __construct($contact_app='addressbook',Db $db=null) 188 { 189 parent::__construct($contact_app,$db); 190 if ($this->log) 191 { 192 $this->logfile = $GLOBALS['egw_info']['server']['temp_dir'].'/log-addressbook_bo'; 193 error_log(__FILE__.'['.__LINE__.'] '.__METHOD__."($contact_app)\n", 3 ,$this->logfile); 194 } 195 196 $this->now_su = DateTime::to('now','ts'); 197 198 $this->prefs =& $GLOBALS['egw_info']['user']['preferences']['addressbook']; 199 if(!isset($this->prefs['hide_accounts'])) 200 { 201 $this->prefs['hide_accounts'] = '0'; 202 } 203 // get the default addressbook from the users prefs 204 $this->default_addressbook = $GLOBALS['egw_info']['user']['preferences']['addressbook']['add_default'] ? 205 (int)$GLOBALS['egw_info']['user']['preferences']['addressbook']['add_default'] : $this->user; 206 $this->default_private = substr($GLOBALS['egw_info']['user']['preferences']['addressbook']['add_default'],-1) == 'p'; 207 if ($this->default_addressbook > 0 && $this->default_addressbook != $this->user && 208 ($this->default_private || 209 $this->default_addressbook == (int)$GLOBALS['egw']->preferences->forced['addressbook']['add_default'] || 210 $this->default_addressbook == (int)$GLOBALS['egw']->preferences->default['addressbook']['add_default'])) 211 { 212 $this->default_addressbook = $this->user; // admin set a default or forced pref for personal addressbook 213 } 214 $this->private_addressbook = self::private_addressbook($this->contact_repository == 'sql', $this->prefs); 215 216 $this->contact_fields = array( 217 'id' => lang('Contact ID'), 218 'tid' => lang('Type'), 219 'owner' => lang('Addressbook'), 220 'private' => lang('private'), 221 'cat_id' => lang('Category'), 222 'n_prefix' => lang('prefix'), 223 'n_given' => lang('first name'), 224 'n_middle' => lang('middle name'), 225 'n_family' => lang('last name'), 226 'n_suffix' => lang('suffix'), 227 'n_fn' => lang('full name'), 228 'n_fileas' => lang('own sorting'), 229 'bday' => lang('birthday'), 230 'org_name' => lang('Organisation'), 231 'org_unit' => lang('Department'), 232 'title' => lang('title'), 233 'role' => lang('role'), 234 'assistent' => lang('Assistent'), 235 'room' => lang('Room'), 236 'adr_one_street' => lang('business street'), 237 'adr_one_street2' => lang('business address line 2'), 238 'adr_one_locality' => lang('business city'), 239 'adr_one_region' => lang('business state'), 240 'adr_one_postalcode' => lang('business zip code'), 241 'adr_one_countryname' => lang('business country'), 242 'adr_one_countrycode' => lang('business country code'), 243 'label' => lang('label'), 244 'adr_two_street' => lang('street (private)'), 245 'adr_two_street2' => lang('address line 2 (private)'), 246 'adr_two_locality' => lang('city (private)'), 247 'adr_two_region' => lang('state (private)'), 248 'adr_two_postalcode' => lang('zip code (private)'), 249 'adr_two_countryname' => lang('country (private)'), 250 'adr_two_countrycode' => lang('country code (private)'), 251 'tel_work' => lang('work phone'), 252 'tel_cell' => lang('mobile phone'), 253 'tel_fax' => lang('business fax'), 254 'tel_assistent' => lang('assistent phone'), 255 'tel_car' => lang('car phone'), 256 'tel_pager' => lang('pager'), 257 'tel_home' => lang('home phone'), 258 'tel_fax_home' => lang('fax (private)'), 259 'tel_cell_private' => lang('mobile phone (private)'), 260 'tel_other' => lang('other phone'), 261 'tel_prefer' => lang('preferred phone'), 262 'email' => lang('business email'), 263 'email_home' => lang('email (private)'), 264 'url' => lang('url (business)'), 265 'url_home' => lang('url (private)'), 266 'freebusy_uri' => lang('Freebusy URI'), 267 'calendar_uri' => lang('Calendar URI'), 268 'note' => lang('note'), 269 'tz' => lang('time zone'), 270 'geo' => lang('geo'), 271 'pubkey' => lang('public key'), 272 'created' => lang('created'), 273 'creator' => lang('created by'), 274 'modified' => lang('last modified'), 275 'modifier' => lang('last modified by'), 276 'jpegphoto' => lang('photo'), 277 'account_id' => lang('Account ID'), 278 ); 279 $this->business_contact_fields = array( 280 'org_name' => lang('Company'), 281 'org_unit' => lang('Department'), 282 'title' => lang('Title'), 283 'role' => lang('Role'), 284 'n_prefix' => lang('prefix'), 285 'n_given' => lang('first name'), 286 'n_middle' => lang('middle name'), 287 'n_family' => lang('last name'), 288 'n_suffix' => lang('suffix'), 289 'adr_one_street' => lang('street').' ('.lang('business').')', 290 'adr_one_street2' => lang('address line 2').' ('.lang('business').')', 291 'adr_one_locality' => lang('city').' ('.lang('business').')', 292 'adr_one_region' => lang('state').' ('.lang('business').')', 293 'adr_one_postalcode' => lang('zip code').' ('.lang('business').')', 294 'adr_one_countryname' => lang('country').' ('.lang('business').')', 295 ); 296 $this->home_contact_fields = array( 297 'org_name' => lang('Company'), 298 'org_unit' => lang('Department'), 299 'title' => lang('Title'), 300 'role' => lang('Role'), 301 'n_prefix' => lang('prefix'), 302 'n_given' => lang('first name'), 303 'n_middle' => lang('middle name'), 304 'n_family' => lang('last name'), 305 'n_suffix' => lang('suffix'), 306 'adr_two_street' => lang('street').' ('.lang('business').')', 307 'adr_two_street2' => lang('address line 2').' ('.lang('business').')', 308 'adr_two_locality' => lang('city').' ('.lang('business').')', 309 'adr_two_region' => lang('state').' ('.lang('business').')', 310 'adr_two_postalcode' => lang('zip code').' ('.lang('business').')', 311 'adr_two_countryname' => lang('country').' ('.lang('business').')', 312 ); 313 //_debug_array($this->contact_fields); 314 $this->own_account_acl = $GLOBALS['egw_info']['server']['own_account_acl']; 315 if (!is_array($this->own_account_acl)) $this->own_account_acl = json_php_unserialize($this->own_account_acl, true); 316 // we have only one acl (n_fn) for the whole name, as not all backends store every part in an own field 317 if ($this->own_account_acl && in_array('n_fn',$this->own_account_acl)) 318 { 319 $this->own_account_acl = array_merge($this->own_account_acl,array('n_prefix','n_given','n_middle','n_family','n_suffix')); 320 } 321 if ($GLOBALS['egw_info']['server']['org_fileds_to_update']) 322 { 323 $this->org_fields = $GLOBALS['egw_info']['server']['org_fileds_to_update']; 324 if (!is_array($this->org_fields)) $this->org_fields = unserialize($this->org_fields); 325 326 // Set country code if country name is selected 327 $supported_fields = $this->get_fields('supported',null,0); 328 if(in_array('adr_one_countrycode', $supported_fields) && in_array('adr_one_countryname',$this->org_fields)) 329 { 330 $this->org_fields[] = 'adr_one_countrycode'; 331 } 332 if(in_array('adr_two_countrycode', $supported_fields) && in_array('adr_two_countryname',$this->org_fields)) 333 { 334 $this->org_fields[] = 'adr_two_countrycode'; 335 } 336 } 337 $this->categories = new Categories($this->user,'addressbook'); 338 339 $this->delete_history = $GLOBALS['egw_info']['server']['history']; 340 } 341 342 /** 343 * Do we use a private addressbook (in comparison to a personal one) 344 * 345 * Used to set $this->private_addressbook for current user. 346 * 347 * @param string $contact_repository 348 * @param array $prefs addressbook preferences 349 * @return boolean 350 */ 351 public static function private_addressbook($contact_repository, array $prefs=null) 352 { 353 return $contact_repository == 'sql' && $prefs['private_addressbook']; 354 } 355 356 /** 357 * Get the availible addressbooks of the user 358 * 359 * @param int $required =Acl::READ required rights on the addressbook or multiple rights or'ed together, 360 * to return only addressbooks fullfilling all the given rights 361 * @param ?string $extra_label first label if given (already translated) 362 * @param ?int $user =null account_id or null for current user 363 * @param boolean $check_all =true false: only require any of the given right-bits is set 364 * @return array with owner => label pairs 365 */ 366 function get_addressbooks($required=Acl::READ,$extra_label=null,$user=null,$check_all=true) 367 { 368 if (is_null($user)) 369 { 370 $user = $this->user; 371 $preferences = $GLOBALS['egw_info']['user']['preferences']; 372 $grants = $this->grants; 373 } 374 else 375 { 376 $prefs_obj = new Preferences($user); 377 $preferences = $prefs_obj->read_repository(); 378 $grants = $this->get_grants($user, 'addressbook', $preferences); 379 } 380 381 $addressbooks = $to_sort = array(); 382 if ($extra_label) $addressbooks[''] = $extra_label; 383 $addressbooks[$user] = lang('Personal'); 384 // add all group addressbooks the user has the necessary rights too 385 foreach($grants as $uid => $rights) 386 { 387 if (self::is_set($rights, $required, $check_all) && $GLOBALS['egw']->accounts->get_type($uid) == 'g') 388 { 389 $to_sort[$uid] = lang('Group %1',$GLOBALS['egw']->accounts->id2name($uid)); 390 } 391 } 392 if ($to_sort) 393 { 394 asort($to_sort); 395 $addressbooks += $to_sort; 396 } 397 if ($required != Acl::ADD && // do NOT allow to set accounts as default addressbook (AB can add accounts) 398 $preferences['addressbook']['hide_accounts'] !== '1' && ( 399 ($grants[0] & $required) == $required || 400 $preferences['common']['account_selection'] == 'groupmembers' && 401 $this->account_repository != 'ldap' && ($required & Acl::READ))) 402 { 403 $addressbooks[0] = lang('Accounts'); 404 } 405 // add all other user addressbooks the user has the necessary rights too 406 $to_sort = array(); 407 foreach($grants as $uid => $rights) 408 { 409 if ($uid != $user && self::is_set($rights, $required, $check_all) && $GLOBALS['egw']->accounts->get_type($uid) == 'u') 410 { 411 $to_sort[$uid] = Accounts::username($uid); 412 } 413 } 414 if ($to_sort) 415 { 416 asort($to_sort); 417 $addressbooks += $to_sort; 418 } 419 if ($user > 0 && self::private_addressbook($this->contact_repository, $preferences['addressbook'])) 420 { 421 $addressbooks[$user.'p'] = lang('Private'); 422 } 423 return $addressbooks; 424 } 425 426 /** 427 * Check rights for one or more required rights 428 * @param int $rights 429 * @param int $required 430 * @param boolean $check_all =true false: only require any of the given right-bits is set 431 * @return bool 432 */ 433 private static function is_set($rights, $required, $check_all=true) 434 { 435 $result = $rights & $required; 436 return $check_exact ? $result == $required : $result !== 0; 437 } 438 439 /** 440 * calculate the file_as string from the contact and the file_as type 441 * 442 * @param array $contact 443 * @param string $type =null file_as type, default null to read it from the contact, unknown/not set type default to the first one 444 * @param boolean $isUpdate =false If true, reads the old record for any not set fields 445 * @return string 446 */ 447 function fileas($contact,$type=null, $isUpdate=false) 448 { 449 if (is_null($type)) $type = $contact['fileas_type']; 450 if (!$type) $type = $this->prefs['fileas_default'] ? $this->prefs['fileas_default'] : $this->fileas_types[0]; 451 452 if (strpos($type,'n_fn') !== false) $contact['n_fn'] = $this->fullname($contact); 453 454 if($isUpdate) 455 { 456 $fileas_fields = array('n_prefix','n_given','n_middle','n_family','n_suffix','n_fn','org_name','org_unit','adr_one_locality','bday'); 457 $old = null; 458 foreach($fileas_fields as $field) 459 { 460 if(!isset($contact[$field])) 461 { 462 if(is_null($old)) $old = $this->read($contact['id']); 463 $contact[$field] = $old[$field]; 464 } 465 } 466 unset($old); 467 } 468 469 // removing empty delimiters, caused by empty contact fields 470 $fileas = str_replace(array(', , : ',', : ',': , ',', , ',': : ',' ()'), 471 array(': ',': ',': ',', ',': ',''), 472 strtr($type, array( 473 'n_prefix' => $contact['n_prefix'], 474 'n_given' => $contact['n_given'], 475 'n_middle' => $contact['n_middle'], 476 'n_family' => $contact['n_family'], 477 'n_suffix' => $contact['n_suffix'], 478 'n_fn' => $contact['n_fn'], 479 'org_name' => $contact['org_name'], 480 'org_unit' => $contact['org_unit'], 481 'adr_one_locality' => $contact['adr_one_locality'], 482 'bday' => (int)$contact['bday'] ? DateTime::to($contact['bday'], true) : $contact['bday'], 483 ))); 484 485 while ($fileas[0] == ':' || $fileas[0] == ',') 486 { 487 $fileas = substr($fileas,2); 488 } 489 while (substr($fileas,-2) == ': ' || substr($fileas,-2) == ', ') 490 { 491 $fileas = substr($fileas,0,-2); 492 } 493 return $fileas; 494 } 495 496 /** 497 * determine the file_as type from the file_as string and the contact 498 * 499 * @param array $contact 500 * @param string $file_as =null file_as type, default null to read it from the contact, unknown/not set type default to the first one 501 * @return string 502 */ 503 function fileas_type($contact,$file_as=null) 504 { 505 if (is_null($file_as)) $file_as = $contact['n_fileas']; 506 507 if ($file_as) 508 { 509 foreach($this->fileas_types as $type) 510 { 511 if ($this->fileas($contact,$type) == $file_as) 512 { 513 return $type; 514 } 515 } 516 } 517 return $this->prefs['fileas_default'] ? $this->prefs['fileas_default'] : $this->fileas_types[0]; 518 } 519 520 /** 521 * get selectbox options for the customfields 522 * 523 * @param array $field =null 524 * @return array with options: 525 */ 526 public static function cf_options() 527 { 528 $cf_fields = Storage\Customfields::get('addressbook',TRUE); 529 foreach ($cf_fields as $key => $value ) 530 { 531 $options[$key]= $value['label']; 532 } 533 return $options; 534 } 535 536 /** 537 * get selectbox options for the fileas types with translated labels, or real content 538 * 539 * @param array $contact =null real content to use, default none 540 * @return array with options: fileas type => label pairs 541 */ 542 function fileas_options($contact=null) 543 { 544 $labels = array( 545 'n_prefix' => lang('prefix'), 546 'n_given' => lang('first name'), 547 'n_middle' => lang('middle name'), 548 'n_family' => lang('last name'), 549 'n_suffix' => lang('suffix'), 550 'n_fn' => lang('full name'), 551 'org_name' => lang('company'), 552 'org_unit' => lang('department'), 553 'adr_one_locality' => lang('city'), 554 'bday' => lang('Birthday'), 555 ); 556 foreach(array_keys($labels) as $name) 557 { 558 if ($contact[$name]) $labels[$name] = $contact[$name]; 559 } 560 foreach($this->fileas_types as $fileas_type) 561 { 562 $options[$fileas_type] = $this->fileas($labels,$fileas_type); 563 } 564 return $options; 565 } 566 567 /** 568 * Set n_fileas (and n_fn) in contacts of all users (called by Admin >> Addressbook >> Site configuration (Admin only) 569 * 570 * If $all all fileas fields will be set, if !$all only empty ones 571 * 572 * @param string $fileas_type '' or type of $this->fileas_types 573 * @param int $all =false update all contacts or only ones with empty values 574 * @param int &$errors=null on return number of errors 575 * @return int|boolean number of contacts updated, false for wrong fileas type 576 */ 577 function set_all_fileas($fileas_type,$all=false,&$errors=null,$ignore_acl=false) 578 { 579 if ($fileas_type != '' && !in_array($fileas_type, $this->fileas_types)) 580 { 581 return false; 582 } 583 if ($ignore_acl) 584 { 585 unset($this->somain->grants); // to NOT limit search to contacts readable by current user 586 } 587 // to be able to work on huge contact repositories we read the contacts in chunks of 100 588 for($n = $updated = $errors = 0; ($contacts = parent::search($all ? array() : array( 589 'n_fileas IS NULL', 590 "n_fileas=''", 591 'n_fn IS NULL', 592 "n_fn=''", 593 ),false,'','','',false,'OR',array($n*100,100))); ++$n) 594 { 595 foreach($contacts as $contact) 596 { 597 $old_fn = $contact['n_fn']; 598 $old_fileas = $contact['n_fileas']; 599 $contact['n_fn'] = $this->fullname($contact); 600 // only update fileas if type is given AND (all should be updated or n_fileas is empty) 601 if ($fileas_type && ($all || empty($contact['n_fileas']))) 602 { 603 $contact['n_fileas'] = $this->fileas($contact,$fileas_type); 604 } 605 if ($old_fileas != $contact['n_fileas'] || $old_fn != $contact['n_fn']) 606 { 607 // only specify/write updated fields plus "keys" 608 $contact = array_intersect_key($contact,array( 609 'id' => true, 610 'owner' => true, 611 'private' => true, 612 'account_id' => true, 613 'uid' => true, 614 )+($old_fileas != $contact['n_fileas'] ? array('n_fileas' => true) : array())+($old_fn != $contact['n_fn'] ? array('n_fn' => true) : array())); 615 if ($this->save($contact,$ignore_acl)) 616 { 617 $updated++; 618 } 619 else 620 { 621 $errors++; 622 } 623 } 624 } 625 } 626 return $updated; 627 } 628 629 /** 630 * Cleanup all contacts db fields of all users (called by Admin >> Addressbook >> Site configuration (Admin only) 631 * 632 * Cleanup means to truncate all unnecessary chars like whitespaces or tabs, 633 * remove unneeded carriage returns or set empty fields to NULL 634 * 635 * @param int &$errors=null on return number of errors 636 * @return int|boolean number of contacts updated 637 */ 638 function set_all_cleanup(&$errors=null,$ignore_acl=false) 639 { 640 if ($ignore_acl) 641 { 642 unset($this->somain->grants); // to NOT limit search to contacts readable by current user 643 } 644 645 // fields that must not be touched 646 $fields_exclude = array( 647 'id' => true, 648 'tid' => true, 649 'owner' => true, 650 'private' => true, 651 'created' => true, 652 'creator' => true, 653 'modified' => true, 654 'modifier' => true, 655 'account_id' => true, 656 'etag' => true, 657 'uid' => true, 658 'freebusy_uri' => true, 659 'calendar_uri' => true, 660 'photo' => true, 661 ); 662 663 // to be able to work on huge contact repositories we read the contacts in chunks of 100 664 for($n = $updated = $errors = 0; ($contacts = parent::search(array(),false,'','','',false,'OR',array($n*100,100))); ++$n) 665 { 666 foreach($contacts as $contact) 667 { 668 $fields_to_update = array(); 669 foreach($contact as $field_name => $field_value) 670 { 671 if($fields_exclude[$field_name] === true) continue; // dont touch specified field 672 673 if (is_string($field_value) && $field_name != 'pubkey' && $field_name != 'jpegphoto') 674 { 675 // check if field has to be trimmed 676 if (strlen($field_value) != strlen(trim($field_value))) 677 { 678 $fields_to_update[$field_name] = $field_value = trim($field_value); 679 } 680 // check if field contains a carriage return - exclude notes 681 if ($field_name != 'note' && strpos($field_value,"\x0D\x0A") !== false) 682 { 683 $fields_to_update[$field_name] = $field_value = str_replace("\x0D\x0A"," ",$field_value); 684 } 685 } 686 // check if a field contains an empty string 687 if (is_string($field_value) && strlen($field_value) == 0) 688 { 689 $fields_to_update[$field_name] = $field_value = null; 690 } 691 // check for valid birthday date 692 if ($field_name == 'bday' && $field_value != null && 693 !preg_match('/^(18|19|20|21|22)\d{2}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$/',$field_value)) 694 { 695 $fields_to_update[$field_name] = $field_value = null; 696 } 697 } 698 699 if(count($fields_to_update) > 0) 700 { 701 $contact_to_save = array( 702 'id' => $contact['id'], 703 'owner' => $contact['owner'], 704 'private' => $contact['private'], 705 'account_id' => $contact['account_id'], 706 'uid' => $contact['uid']) + $fields_to_update; 707 708 if ($this->save($contact_to_save,$ignore_acl)) 709 { 710 $updated++; 711 } 712 else 713 { 714 $errors++; 715 } 716 } 717 } 718 } 719 return $updated; 720 } 721 722 /** 723 * get full name from the name-parts 724 * 725 * @param array $contact 726 * @return string full name 727 */ 728 function fullname($contact) 729 { 730 if (empty($contact['n_family']) && empty($contact['n_given'])) { 731 $cpart = array('org_name'); 732 } else { 733 $cpart = array('n_prefix','n_given','n_middle','n_family','n_suffix'); 734 } 735 $parts = array(); 736 foreach($cpart as $n) 737 { 738 if ($contact[$n]) $parts[] = $contact[$n]; 739 } 740 return implode(' ',$parts); 741 } 742 743 /** 744 * changes the data from the db-format to your work-format 745 * 746 * it gets called everytime when data is read from the db 747 * This function needs to be reimplemented in the derived class 748 * 749 * @param array $data 750 * @param $date_format ='ts' date-formats: 'ts'=timestamp, 'server'=timestamp in server-time, 'array'=array or string with date-format 751 * 752 * @return array updated data 753 */ 754 function db2data($data, $date_format='ts') 755 { 756 static $fb_url = false; 757 758 // convert timestamps from server-time in the db to user-time 759 foreach ($this->timestamps as $name) 760 { 761 if (isset($data[$name])) 762 { 763 $data[$name] = DateTime::server2user($data[$name], $date_format); 764 } 765 } 766 $data['photo'] = $this->photo_src($data['id'],$data['jpegphoto'] || ($data['files'] & self::FILES_BIT_PHOTO), '', $data['etag']); 767 768 // set freebusy_uri for accounts 769 if (!$data['freebusy_uri'] && !$data['owner'] && $data['account_id'] && !is_object($GLOBALS['egw_setup'])) 770 { 771 if ($fb_url || @is_dir(EGW_SERVER_ROOT.'/calendar/inc')) 772 { 773 $fb_url = true; 774 $user = isset($data['account_lid']) ? $data['account_lid'] : $GLOBALS['egw']->accounts->id2name($data['account_id']); 775 $data['freebusy_uri'] = calendar_bo::freebusy_url($user); 776 } 777 } 778 return $data; 779 } 780 781 /** 782 * src for photo: returns array with linkparams if jpeg exists or the $default image-name if not 783 * @param int $id contact_id 784 * @param boolean $jpeg =false jpeg exists or not 785 * @param string $default ='' image-name to use if !$jpeg, eg. 'template' 786 * @param string $etag =null etag to set in url to allow caching with Expires header 787 * @return string 788 */ 789 function photo_src($id,$jpeg,$default='',$etag=null) 790 { 791 //error_log(__METHOD__."($id, ..., etag=$etag) ". function_backtrace()); 792 return $jpeg || !$default ? Egw::link('/api/avatar.php', array( 793 'contact_id' => $id, 794 'lavatar' => !$jpeg ? true : false 795 )+(isset($etag) ? array( 796 'etag' => $etag 797 ) : array())) : $default; 798 } 799 800 /** 801 * changes the data from your work-format to the db-format 802 * 803 * It gets called everytime when data gets writen into db or on keys for db-searches 804 * this needs to be reimplemented in the derived class 805 * 806 * @param array $data 807 * @param $date_format ='ts' date-formats: 'ts'=timestamp, 'server'=timestamp in server-time, 'array'=array or string with date-format 808 * 809 * @return array upated data 810 */ 811 function data2db($data, $date_format='ts') 812 { 813 // convert timestamps from user-time to server-time in the db 814 foreach ($this->timestamps as $name) 815 { 816 if (isset($data[$name])) 817 { 818 $data[$name] = DateTime::user2server($data[$name], $date_format); 819 } 820 } 821 return $data; 822 } 823 824 /** 825 * deletes contact in db 826 * 827 * @param mixed &$contact contact array with key id or (array of) id(s) 828 * @param boolean $deny_account_delete =true if true never allow to delete accounts 829 * @param int $check_etag =null 830 * @return boolean|int true on success or false on failiure, 0 if etag does not match 831 */ 832 function delete($contact,$deny_account_delete=true,$check_etag=null) 833 { 834 if (is_array($contact) && isset($contact['id'])) 835 { 836 $contact = array($contact); 837 } 838 elseif (!is_array($contact)) 839 { 840 $contact = array($contact); 841 } 842 foreach($contact as $c) 843 { 844 $id = is_array($c) ? $c['id'] : $c; 845 846 $ok = false; 847 if ($this->check_perms(Acl::DELETE,$c,$deny_account_delete)) 848 { 849 if (!($old = $this->read($id))) return false; 850 // check if we only mark contacts as deleted, or really delete them 851 // already marked as deleted item and accounts are always really deleted 852 // we cant mark accounts as deleted, as no such thing exists for accounts! 853 if ($old['owner'] && $this->delete_history != '' && $old['tid'] != self::DELETED_TYPE) 854 { 855 $delete = $old; 856 $delete['tid'] = self::DELETED_TYPE; 857 if ($check_etag) $delete['etag'] = $check_etag; 858 if (($ok = $this->save($delete))) $ok = true; // we have to return true or false 859 Link::unlink(0,'addressbook',$id,'','','',true); 860 } 861 elseif (($ok = parent::delete($id,$check_etag))) 862 { 863 Link::unlink(0,'addressbook',$id); 864 } 865 866 // Don't notify of final purge 867 if ($ok && $old['tid'] != self::DELETED_TYPE) 868 { 869 if (!isset($this->tracking)) $this->tracking = new Contacts\Tracking($this); 870 $this->tracking->track(array('id' => $id), array('id' => $id), null, true); 871 } 872 } 873 else 874 { 875 break; 876 } 877 } 878 //error_log(__METHOD__.'('.array2string($contact).', deny_account_delete='.array2string($deny_account_delete).', check_etag='.array2string($check_etag).' returning '.array2string($ok)); 879 return $ok; 880 } 881 882 /** 883 * saves contact to db 884 * 885 * @param array &$contact contact array from etemplate::exec 886 * @param boolean $ignore_acl =false should the acl be checked or not 887 * @param boolean $touch_modified =true should modified/r be updated 888 * @return int/string/boolean id on success, false on failure, the error-message is in $this->error 889 */ 890 function save(&$contact, $ignore_acl=false, $touch_modified=true) 891 { 892 $update_type = "update"; 893 894 // Make sure photo remains unchanged unless its purposely set to be false 895 // which means photo has changed. 896 if (!array_key_exists('photo_unchanged',$contact)) $contact['photo_unchanged'] = true; 897 898 // remember if we add or update a entry 899 if (($isUpdate = $contact['id'])) 900 { 901 if (!isset($contact['owner']) || !isset($contact['private'])) // owner/private not set on update, eg. SyncML 902 { 903 if (($old = $this->read($contact['id']))) // --> try reading the old entry and set it from there 904 { 905 if(!isset($contact['owner'])) 906 { 907 $contact['owner'] = $old['owner']; 908 } 909 if(!isset($contact['private'])) 910 { 911 $contact['private'] = $old['private']; 912 } 913 } 914 else // entry not found --> create a new one 915 { 916 $isUpdate = $contact['id'] = null; 917 $update_type = "add"; 918 } 919 } 920 } 921 else 922 { 923 // if no owner/addressbook set use the setting of the add_default prefs (if set, otherwise the users personal addressbook) 924 if (!isset($contact['owner'])) $contact['owner'] = $this->default_addressbook; 925 if (!isset($contact['private'])) $contact['private'] = (int)$this->default_private; 926 // do NOT allow to create new accounts via addressbook, they are broken without an account_id 927 if (!$contact['owner'] && empty($contact['account_id'])) 928 { 929 $contact['owner'] = $this->default_addressbook ? $this->default_addressbook : $this->user; 930 } 931 // allow admins to import contacts with creator / created date set 932 if (!$contact['creator'] || !$ignore_acl && !$this->is_admin($contact)) $contact['creator'] = $this->user; 933 if (!$contact['created'] || !$ignore_acl && !$this->is_admin($contact)) $contact['created'] = $this->now_su; 934 935 if (!$contact['tid']) $contact['tid'] = 'n'; 936 $update_type = "add"; 937 } 938 // ensure accounts and group addressbooks are never private! 939 if ($contact['owner'] <= 0) 940 { 941 $contact['private'] = 0; 942 } 943 if(!$ignore_acl && !$this->check_perms($isUpdate ? Acl::EDIT : Acl::ADD,$contact)) 944 { 945 $this->error = 'access denied'; 946 return false; 947 } 948 // resize image to 60px width 949 if (!empty($contact['jpegphoto'])) 950 { 951 $contact['jpegphoto'] = $this->resize_photo($contact['jpegphoto']); 952 } 953 // convert categories 954 if (is_array($contact['cat_id'])) 955 { 956 $contact['cat_id'] = implode(',',$contact['cat_id']); 957 } 958 959 // Update country codes 960 foreach(array('adr_one_', 'adr_two_') as $c_prefix) { 961 if($contact[$c_prefix.'countryname'] && !$contact[$c_prefix.'countrycode'] && 962 $code = Country::country_code($contact[$c_prefix.'countryname'])) 963 { 964 if(strlen($code) == 2) 965 { 966 $contact[$c_prefix.'countrycode'] = $code; 967 } 968 else 969 { 970 $contact[$c_prefix.'countrycode'] = null; 971 } 972 } 973 if($contact[$c_prefix.'countrycode'] != null) 974 { 975 $contact[$c_prefix.'countryname'] = null; 976 } 977 } 978 979 // last modified 980 if ($touch_modified) 981 { 982 $contact['modifier'] = $this->user; 983 $contact['modified'] = $this->now_su; 984 } 985 // set full name and fileas from the content 986 if (!isset($contact['n_fn'])) 987 { 988 $contact['n_fn'] = $this->fullname($contact); 989 } 990 if (isset($contact['org_name'])) $contact['n_fileas'] = $this->fileas($contact, null, false); 991 992 // Get old record for tracking changes 993 if (!isset($old) && $isUpdate) 994 { 995 $old = $this->read($contact['id']); 996 } 997 $to_write = $contact; 998 // (non-admin) user editing his own account, make sure he does not change fields he is not allowed to (eg. via SyncML or xmlrpc) 999 if (!$ignore_acl && !$contact['owner'] && !($this->is_admin($contact) || $this->allow_account_edit())) 1000 { 1001 foreach(array_keys($contact) as $field) 1002 { 1003 if (!in_array($field,$this->own_account_acl) && !in_array($field,array('id','owner','account_id','modified','modifier', 'photo_unchanged'))) 1004 { 1005 // user is not allowed to change that 1006 if ($old) 1007 { 1008 $to_write[$field] = $contact[$field] = $old[$field]; 1009 } 1010 else 1011 { 1012 unset($to_write[$field]); 1013 } 1014 } 1015 } 1016 } 1017 1018 // IF THE OLD ENTRY IS A ACCOUNT, dont allow to change the owner/location 1019 // maybe we need that for id and account_id as well. 1020 if (is_array($old) && (!isset($old['owner']) || empty($old['owner']))) 1021 { 1022 if (isset($to_write['owner']) && !empty($to_write['owner'])) 1023 { 1024 error_log(__METHOD__.__LINE__." Trying to change account to owner:". $to_write['owner'].' Account affected:'.array2string($old).' Data send:'.array2string($to_write)); 1025 unset($to_write['owner']); 1026 } 1027 } 1028 1029 if(!($this->error = parent::save($to_write))) 1030 { 1031 $contact['id'] = $to_write['id']; 1032 $contact['uid'] = $to_write['uid']; 1033 $contact['etag'] = $to_write['etag']; 1034 $contact['files'] = $to_write['files']; 1035 1036 // Clear any files saved with new entries 1037 // They've been dealt with already and they cause errors with linking 1038 foreach(array_keys($this->customfields) as $field) 1039 { 1040 if(is_array($to_write[Storage::CF_PREFIX.$field])) 1041 { 1042 unset($to_write[Storage::CF_PREFIX.$field]); 1043 } 1044 } 1045 1046 // if contact is an account and account-relevant data got updated, handle it like account got updated 1047 if ($contact['account_id'] && $isUpdate && 1048 ($old['email'] != $contact['email'] || $old['n_family'] != $contact['n_family'] || $old['n_given'] != $contact['n_given'])) 1049 { 1050 // invalidate the cache of the accounts class 1051 $GLOBALS['egw']->accounts->cache_invalidate($contact['account_id']); 1052 // call edit-accout hook, to let other apps know about changed account (names or email) 1053 $GLOBALS['hook_values'] = (array)$GLOBALS['egw']->accounts->read($contact['account_id']); 1054 Hooks::process($GLOBALS['hook_values']+array( 1055 'location' => 'editaccount', 1056 ),False,True); // called for every app now, not only enabled ones) 1057 } 1058 // notify interested apps about changes in the account-contact data 1059 if (!$to_write['owner'] && $to_write['account_id'] && $isUpdate) 1060 { 1061 $to_write['location'] = 'editaccountcontact'; 1062 Hooks::process($to_write,False,True); // called for every app now, not only enabled ones)); 1063 } 1064 1065 // Check for restore of deleted contact, restore held links 1066 if($old && $old['tid'] == self::DELETED_TYPE && $contact['tid'] != self::DELETED_TYPE) 1067 { 1068 Link::restore('addressbook', $contact['id']); 1069 } 1070 1071 // Record change history for sql - doesn't work for LDAP accounts 1072 $deleted = ($old['tid'] == self::DELETED_TYPE || $contact['tid'] == self::DELETED_TYPE); 1073 if(!$contact['account_id'] || $contact['account_id'] && $this->account_repository == 'sql') 1074 { 1075 if (!isset($this->tracking)) $this->tracking = new Contacts\Tracking($this); 1076 $this->tracking->track($to_write, $old ? $old : null, null, $deleted); 1077 } 1078 1079 // Notify linked apps about changes in the contact data 1080 Link::notify_update('addressbook', $contact['id'], $contact, $deleted ? 'delete' : $update_type); 1081 1082 // Expire birthday cache for this year and next if birthday changed 1083 if($isUpdate && $old['bday'] !== $to_write['bday'] || !$isUpdate && $to_write['bday']) 1084 { 1085 $year = (int) date('Y',time()); 1086 $this->clear_birthday_cache($year, $to_write['owner']); 1087 $year++; 1088 $this->clear_birthday_cache($year, $to_write['owner']); 1089 } 1090 } 1091 1092 return $this->error ? false : $contact['id']; 1093 } 1094 1095 /** 1096 * Since birthdays are cached for the instance for BIRTHDAY_CACHE_TIME, we 1097 * need to clear them if a birthday changes. 1098 * 1099 * @param type $year 1100 */ 1101 protected function clear_birthday_cache($year, $owner) 1102 { 1103 // Cache is kept per-language, so clear them all 1104 foreach(array_keys(Translation::get_installed_langs()) as $lang) 1105 { 1106 Cache::unsetInstance(__CLASS__,"birthday-$year-{$owner}-$lang"); 1107 } 1108 } 1109 1110 /** 1111 * Resize photo down to 240pixel width and returns it 1112 * 1113 * Also makes sures photo is a JPEG. 1114 * 1115 * @param string|FILE $photo string with image or open filedescribtor 1116 * @param int $dst_w =240 max width to resize to 1117 * @return string with resized jpeg photo, null on error 1118 */ 1119 public static function resize_photo($photo, $dst_w=240) 1120 { 1121 if (is_resource($photo)) 1122 { 1123 $photo = stream_get_contents($photo); 1124 } 1125 if (empty($photo) || !($image = imagecreatefromstring($photo))) 1126 { 1127 error_log(__METHOD__."() invalid image!"); 1128 return null; 1129 } 1130 $src_w = imagesx($image); 1131 $src_h = imagesy($image); 1132 //error_log(__METHOD__."() got image $src_w * $src_h, is_jpeg=".array2string(substr($photo,0,2) === "\377\330")); 1133 1134 // if $photo is to width or not a jpeg image --> resize it 1135 if ($src_w > $dst_w || cut_bytes($photo,0,2) !== "\377\330") 1136 { 1137 //error_log(__METHOD__."(,dst_w=$dst_w) src_w=$src_w, cut_bytes(photo,0,2)=".array2string(cut_bytes($photo,0,2)).' --> resizing'); 1138 // scale the image to a width of 60 and a height according to the proportion of the source image 1139 $resized = imagecreatetruecolor($dst_w,$dst_h = round($src_h * $dst_w / $src_w)); 1140 imagecopyresized($resized,$image,0,0,0,0,$dst_w,$dst_h,$src_w,$src_h); 1141 1142 ob_start(); 1143 imagejpeg($resized,null,90); 1144 $photo = ob_get_contents(); 1145 ob_end_clean(); 1146 1147 imagedestroy($resized); 1148 //error_log(__METHOD__."() resized image $src_w*$src_h to $dst_w*$dst_h"); 1149 } 1150 //else error_log(__METHOD__."(,dst_w=$dst_w) src_w=$src_w, cut_bytes(photo,0,2)=".array2string(cut_bytes($photo,0,2)).' --> NOT resizing'); 1151 1152 imagedestroy($image); 1153 1154 return $photo; 1155 } 1156 1157 /** 1158 * reads contacts matched by key and puts all cols in the data array 1159 * 1160 * @param int|string $contact_id 1161 * @param boolean $ignore_acl =false true: no acl check 1162 * @return array|boolean array with contact data, null if not found or false on no view perms 1163 */ 1164 function read($contact_id, $ignore_acl=false) 1165 { 1166 // get so_sql_cf to read private customfields too, if we ignore acl 1167 if ($ignore_acl && is_a($this->somain, __CLASS__.'\\Sql')) 1168 { 1169 $cf_backup = (array)$this->somain->customfields; 1170 $this->somain->customfields = Storage\Customfields::get('addressbook', true); 1171 } 1172 if (!($data = parent::read($contact_id))) 1173 { 1174 $data = null; // not found 1175 } 1176 elseif (!$ignore_acl && !$this->check_perms(Acl::READ,$data)) 1177 { 1178 $data = false; // no view perms 1179 } 1180 else 1181 { 1182 // determine the file-as type 1183 $data['fileas_type'] = $this->fileas_type($data); 1184 1185 // Update country name from code 1186 if($data['adr_one_countrycode'] != null) { 1187 $data['adr_one_countryname'] = Country::get_full_name($data['adr_one_countrycode'], true); 1188 } 1189 if($data['adr_two_countrycode'] != null) { 1190 $data['adr_two_countryname'] = Country::get_full_name($data['adr_two_countrycode'], true); 1191 } 1192 } 1193 if (isset($cf_backup)) 1194 { 1195 $this->somain->customfields = $cf_backup; 1196 } 1197 //error_log(__METHOD__.'('.array2string($contact_id).') returning '.array2string($data)); 1198 return $data; 1199 } 1200 1201 /** 1202 * Checks if the current user has the necessary ACL rights 1203 * 1204 * If the access of a contact is set to private, one need a private grant for a personal addressbook 1205 * or the group membership for a group-addressbook 1206 * 1207 * @param int $needed necessary ACL right: Acl::{READ|EDIT|DELETE} 1208 * @param mixed $contact contact as array or the contact-id 1209 * @param boolean $deny_account_delete =false if true never allow to delete accounts 1210 * @param ?int $user =null for which user to check, default current user 1211 * @param int $check_shared =3 limits the nesting level of sharing checks, use 0 to NOT check sharing 1212 * @return ?boolean|"shared" true permission granted, false for permission denied, null for contact does not exist 1213 * "shared" if permission is from sharing 1214 */ 1215 function check_perms($needed,$contact,$deny_account_delete=false,$user=null,$check_shared=3) 1216 { 1217 if (!$user) $user = $this->user; 1218 if ($user == $this->user) 1219 { 1220 $grants = $this->grants; 1221 $memberships = $this->memberships; 1222 } 1223 else 1224 { 1225 $grants = $this->get_grants($user); 1226 $memberships = $GLOBALS['egw']->accounts->memberships($user,true); 1227 } 1228 1229 if ((!is_array($contact) || !isset($contact['owner'])) && 1230 1231 !($contact = parent::read(is_array($contact) ? $contact['id'] : $contact))) 1232 { 1233 return null; 1234 } 1235 $owner = $contact['owner']; 1236 1237 // allow the user to edit his own account 1238 if (!$owner && $needed == Acl::EDIT && $contact['account_id'] == $user && $this->own_account_acl) 1239 { 1240 $access = true; 1241 } 1242 // dont allow to delete own account (as admin handels it too) 1243 elseif (!$owner && $needed == Acl::DELETE && ($deny_account_delete || $contact['account_id'] == $user)) 1244 { 1245 $access = false; 1246 } 1247 // for reading accounts (owner == 0) and account_selection == groupmembers, check if current user and contact are groupmembers 1248 elseif ($owner == 0 && $needed == Acl::READ && 1249 $GLOBALS['egw_info']['user']['preferences']['common']['account_selection'] == 'groupmembers' && 1250 !isset($GLOBALS['egw_info']['user']['apps']['admin'])) 1251 { 1252 $access = !!array_intersect($memberships,$GLOBALS['egw']->accounts->memberships($contact['account_id'],true)); 1253 } 1254 else if ($contact['id'] && $GLOBALS['egw']->acl->check('A'.$contact['id'], $needed, 'addressbook')) 1255 { 1256 $access = true; 1257 } 1258 else 1259 { 1260 $access = ($grants[$owner] & $needed) && 1261 (!$contact['private'] || ($grants[$owner] & Acl::PRIVAT) || in_array($owner,$memberships)); 1262 } 1263 // check if we might have access via sharing (not for delete) 1264 if ($access === false && !empty($contact['shared']) && $needed != Acl::DELETE && $check_shared > 0) 1265 { 1266 foreach($contact['shared'] as $shared) 1267 { 1268 if (isset($grants[$shared['shared_with']]) && (!($needed & Acl::EDIT) || 1269 // if shared writable, we check if the one who shared the contact still has edit rights 1270 $shared['shared_writable'] && $this->check_perms($needed, $contact, $deny_account_delete, $shared['shared_by'], $check_shared-1))) 1271 { 1272 $access = "shared"; 1273 error_log(__METHOD__."($needed,$contact[id],$deny_account_delete,$user,$check_shared) shared=".json_encode($shared)." returning ".array2string($access)); 1274 break; 1275 } 1276 } 1277 } 1278 //error_log(__METHOD__."($needed,$contact[id],$deny_account_delete,$user,$check_shared) returning ".array2string($access)); 1279 return $access; 1280 } 1281 1282 /** 1283 * Check if user has right to share with / into given AB 1284 * 1285 * @param array[]& $shared_with array of arrays with values for keys "shared_with", "shared_by", ... 1286 * @param ?string& $error on return error-message 1287 * @return array entries removed from $shared_with because current user is not allowed to share into (key is preserved) 1288 */ 1289 function check_shared_with(array &$shared_with=null, &$error=null) 1290 { 1291 $removed = []; 1292 foreach((array)$shared_with as $key => $shared) 1293 { 1294 if (!empty($shared['shared_by']) && $shared['shared_by'] != $this->user) 1295 { 1296 $grants = $this->get_grants($shared['shared_by']); 1297 } 1298 else 1299 { 1300 $grants = $this->grants; 1301 } 1302 if (!($grants[$shared['shared_with']] & self::CHECK_ACL_SHARED)) 1303 { 1304 $removed[$key] = $shared; 1305 unset($shared_with[$key]); 1306 } 1307 } 1308 // allow apps to modifiy 1309 $results = []; 1310 foreach(Hooks::process([ 1311 'location' => 'check_shared_with', 1312 'shared_with' => &$shared_with, 1313 'removed' => &$removed, 1314 ], true) as $result) 1315 { 1316 if ($result) 1317 { 1318 $results = array_merge($results, $result); 1319 } 1320 } 1321 if ($results) $error = implode("\n", $results); 1322 1323 return $removed; 1324 } 1325 1326 /** 1327 * Check access to the file store 1328 * 1329 * @param int|array $id id of entry or entry array 1330 * @param int $check Acl::READ for read and Acl::EDIT for write or delete access 1331 * @param string $rel_path =null currently not used in InfoLog 1332 * @param int $user =null for which user to check, default current user 1333 * @return boolean true if access is granted or false otherwise 1334 */ 1335 function file_access($id,$check,$rel_path=null,$user=null) 1336 { 1337 unset($rel_path); // not used, but required by function signature 1338 1339 return $this->check_perms($check,$id,false,$user); 1340 } 1341 1342 /** 1343 * Read (virtual) org-entry (values "common" for most contacts in the given org) 1344 * 1345 * @param string $org_id org_name:oooooo|||org_unit:uuuuuuuuu|||adr_one_locality:lllllll (org_unit and adr_one_locality are optional) 1346 * @return array/boolean array with common org fields or false if org not found 1347 */ 1348 function read_org($org_id) 1349 { 1350 if (!$org_id) return false; 1351 if (strpos($org_id,'*AND*')!== false) $org_id = str_replace('*AND*','&',$org_id); 1352 $org = array(); 1353 foreach(explode('|||',$org_id) as $part) 1354 { 1355 list($name,$value) = explode(':',$part,2); 1356 $org[$name] = $value; 1357 } 1358 $csvs = array('cat_id'); // fields with comma-separated-values 1359 1360 // split regular fields and custom fields 1361 $custom_fields = $regular_fields = array(); 1362 foreach($this->org_fields as $name) 1363 { 1364 if ($name[0] != '#') 1365 { 1366 $regular_fields[] = $name; 1367 } 1368 else 1369 { 1370 $custom_fields[] = $name = substr($name,1); 1371 $regular_fields['id'] = 'id'; 1372 if (substr($this->customfields[$name]['type'],0,6)=='select' && $this->customfields[$name]['rows'] || // multiselection 1373 $this->customfields[$name]['type'] == 'radio') 1374 { 1375 $csvs[] = '#'.$name; 1376 } 1377 } 1378 } 1379 // read the regular fields 1380 $contacts = parent::search('',$regular_fields,'','','',false,'AND',false,$org); 1381 if (!$contacts) return false; 1382 1383 // if we have custom fields, read and merge them in 1384 if ($custom_fields) 1385 { 1386 foreach($contacts as $contact) 1387 { 1388 $ids[] = $contact['id']; 1389 } 1390 if (($cfs = $this->read_customfields($ids,$custom_fields))) 1391 { 1392 foreach ($contacts as &$contact) 1393 { 1394 $id = $contact['id']; 1395 if (isset($cfs[$id])) 1396 { 1397 foreach($cfs[$id] as $name => $value) 1398 { 1399 $contact['#'.$name] = $value; 1400 } 1401 } 1402 } 1403 unset($contact); 1404 } 1405 } 1406 1407 // create a statistic about the commonness of each fields values 1408 $fields = array(); 1409 foreach($contacts as $contact) 1410 { 1411 foreach($contact as $name => $value) 1412 { 1413 if (!in_array($name,$csvs)) 1414 { 1415 $fields[$name][$value]++; 1416 } 1417 else 1418 { 1419 // for comma separated fields, we have to use each single value 1420 foreach(explode(',',$value) as $val) 1421 { 1422 $fields[$name][$val]++; 1423 } 1424 } 1425 } 1426 } 1427 foreach($fields as $name => $values) 1428 { 1429 if (!in_array($name,$this->org_fields)) continue; 1430 1431 arsort($values,SORT_NUMERIC); 1432 $value = key($values); 1433 $num = current($values); 1434 if ($value && $num / (double) count($contacts) >= $this->org_common_factor) 1435 { 1436 if (!in_array($name,$csvs)) 1437 { 1438 $org[$name] = $value; 1439 } 1440 else 1441 { 1442 $org[$name] = array(); 1443 foreach ($values as $value => $num) 1444 { 1445 if ($value && $num / (double) count($contacts) >= $this->org_common_factor) 1446 { 1447 $org[$name][] = $value; 1448 } 1449 } 1450 $org[$name] = implode(',',$org[$name]); 1451 } 1452 } 1453 } 1454 return $org; 1455 } 1456 1457 /** 1458 * Return all org-members with same content in one or more of the given fields (only org_fields are counting) 1459 * 1460 * @param string $org_name 1461 * @param array $fields field-name => value pairs 1462 * @return array with contacts 1463 */ 1464 function org_similar($org_name,$fields) 1465 { 1466 $criteria = array(); 1467 foreach($this->org_fields as $name) 1468 { 1469 if (isset($fields[$name])) 1470 { 1471 if (empty($fields[$name])) 1472 { 1473 $criteria[] = "($name IS NULL OR $name='')"; 1474 } 1475 else 1476 { 1477 $criteria[$name] = $fields[$name]; 1478 } 1479 } 1480 } 1481 return parent::search($criteria,false,'n_family,n_given','','',false,'OR',false,array('org_name'=>$org_name)); 1482 } 1483 1484 /** 1485 * Return the changed fields from two versions of a contact (not modified or modifier) 1486 * 1487 * @param array $from original/old version of the contact 1488 * @param array $to changed/new version of the contact 1489 * @param boolean $only_org_fields =true check and return only org_fields, default true 1490 * @return array with field-name => value from $from 1491 */ 1492 function changed_fields($from,$to,$only_org_fields=true) 1493 { 1494 // we only care about countryname, if contrycode is empty 1495 foreach(array( 1496 'adr_one_countryname' => 'adr_one_countrycode', 1497 'adr_two_countryname' => 'adr_one_countrycode', 1498 ) as $name => $code) 1499 { 1500 if (!empty($from[$code])) $from[$name] = ''; 1501 if (!empty($to[$code])) $to[$name] = ''; 1502 } 1503 $changed = array(); 1504 foreach($only_org_fields ? $this->org_fields : array_keys($this->contact_fields) as $name) 1505 { 1506 if (in_array($name,array('modified','modifier'))) // never count these 1507 { 1508 continue; 1509 } 1510 if ((string) $from[$name] != (string) $to[$name]) 1511 { 1512 $changed[$name] = $from[$name]; 1513 } 1514 } 1515 return $changed; 1516 } 1517 1518 /** 1519 * Change given fields in all members of the org with identical content in the field 1520 * 1521 * @param string $org_name 1522 * @param array $from original/old version of the contact 1523 * @param array $to changed/new version of the contact 1524 * @param array $members =null org-members to change, default null --> function queries them itself 1525 * @return array/boolean (changed-members,changed-fields,failed-members) or false if no org_fields changed or no (other) members matching that fields 1526 */ 1527 function change_org($org_name,$from,$to,$members=null) 1528 { 1529 if (!($changed = $this->changed_fields($from,$to,true))) return false; 1530 1531 if (is_null($members) || !is_array($members)) 1532 { 1533 $members = $this->org_similar($org_name,$changed); 1534 } 1535 if (!$members) return false; 1536 1537 $ids = array(); 1538 foreach($members as $member) 1539 { 1540 $ids[] = $member['id']; 1541 } 1542 $customfields = $this->read_customfields($ids); 1543 1544 $changed_members = $changed_fields = $failed_members = 0; 1545 foreach($members as $member) 1546 { 1547 if (isset($customfields[$member['id']])) 1548 { 1549 foreach(array_keys($this->customfields) as $name) 1550 { 1551 $member['#'.$name] = $customfields[$member['id']][$name]; 1552 } 1553 } 1554 $fields = 0; 1555 foreach($changed as $name => $value) 1556 { 1557 if ((string)$value == (string)$member[$name]) 1558 { 1559 $member[$name] = $to[$name]; 1560 ++$fields; 1561 } 1562 } 1563 if ($fields) 1564 { 1565 if (!$this->check_perms(Acl::EDIT,$member) || !$this->save($member)) 1566 { 1567 ++$failed_members; 1568 } 1569 else 1570 { 1571 ++$changed_members; 1572 $changed_fields += $fields; 1573 } 1574 } 1575 } 1576 return array($changed_members,$changed_fields,$failed_members); 1577 } 1578 1579 /** 1580 * get title for a contact identified by $contact 1581 * 1582 * Is called as hook to participate in the linking. The format is determined by the link_title preference. 1583 * 1584 * @param int|string|array $contact int/string id or array with contact 1585 * @return string/boolean string with the title, null if contact does not exitst, false if no perms to view it 1586 */ 1587 function link_title($contact) 1588 { 1589 if (!is_array($contact) && $contact) 1590 { 1591 $contact = $this->read($contact); 1592 } 1593 if (!is_array($contact)) 1594 { 1595 return $contact; 1596 } 1597 $type = $this->prefs['link_title']; 1598 if (!$type || $type === 'n_fileas') 1599 { 1600 if ($contact['n_fileas']) return $contact['n_fileas']; 1601 $type = null; 1602 } 1603 $title = $this->fileas($contact,$type); 1604 1605 if (!empty($this->prefs['link_title_cf'])) 1606 { 1607 $field_list = is_string($this->prefs['link_title_cf']) ? explode(',', $this->prefs['link_title_cf']) : $this->prefs['link_title_cf']; 1608 foreach ($field_list as $field) 1609 { 1610 if($contact['#'.$field]) 1611 { 1612 $title .= ', ' . $contact['#'.$field]; 1613 } 1614 } 1615 } 1616 return $title ; 1617 } 1618 1619 /** 1620 * get title for multiple contacts identified by $ids 1621 * 1622 * Is called as hook to participate in the linking. The format is determined by the link_title preference. 1623 * 1624 * @param array $ids array with contact-id's 1625 * @return array with titles, see link_title 1626 */ 1627 function link_titles(array $ids) 1628 { 1629 $titles = array(); 1630 if (($contacts =& $this->search(array('contact_id' => $ids),false,'',$extra_cols='','',False,'AND',False,array('tid'=>null)))) 1631 { 1632 $ids = array(); 1633 foreach($contacts as $contact) 1634 { 1635 $ids[] = $contact['id']; 1636 } 1637 $cfs = $this->read_customfields($ids); 1638 foreach($contacts as $contact) 1639 { 1640 $titles[$contact['id']] = $this->link_title($contact+(array)$cfs[$contact['id']]); 1641 } 1642 } 1643 // we assume all not returned contacts are not readable for the user (as we report all deleted contacts to egw_link) 1644 foreach($ids as $id) 1645 { 1646 if (!isset($titles[$id])) 1647 { 1648 $titles[$id] = false; 1649 } 1650 } 1651 return $titles; 1652 } 1653 1654 /** 1655 * query addressbook for contacts matching $pattern 1656 * 1657 * Is called as hook to participate in the linking 1658 * 1659 * @param string|array $pattern pattern to search, or an array with a 'search' key 1660 * @param array $options Array of options for the search 1661 * @return array with id - title pairs of the matching entries 1662 */ 1663 function link_query($pattern, Array &$options = array()) 1664 { 1665 $result = $criteria = array(); 1666 $limit = false; 1667 if ($pattern) 1668 { 1669 $criteria = is_array($pattern) ? $pattern['search'] : $pattern; 1670 } 1671 if($options['start'] || $options['num_rows']) 1672 { 1673 $limit = array($options['start'], $options['num_rows']); 1674 } 1675 $filter = (array)$options['filter']; 1676 if ($GLOBALS['egw_info']['user']['preferences']['addressbook']['hide_accounts'] === '1') $filter['account_id'] = null; 1677 if (($contacts =& parent::search($criteria,false,'org_name,n_family,n_given,cat_id,contact_email','','%',false,'OR', $limit, $filter))) 1678 { 1679 $ids = array(); 1680 foreach($contacts as $contact) 1681 { 1682 $ids[] = $contact['id']; 1683 } 1684 $cfs = $this->read_customfields($ids); 1685 foreach($contacts as $contact) 1686 { 1687 $result[$contact['id']] = $this->link_title($contact+(array)$cfs[$contact['id']]); 1688 // make sure to return a correctly quoted rfc822 address, if requested 1689 if ($options['type'] === 'email') 1690 { 1691 $args = explode('@', $contact['email']); 1692 $args[] = $result[$contact['id']]; 1693 $result[$contact['id']] = call_user_func_array('imap_rfc822_write_address', $args); 1694 } 1695 // show category color 1696 if ($contact['cat_id'] && ($color = Categories::cats2color($contact['cat_id']))) 1697 { 1698 $result[$contact['id']] = array( 1699 'label' => $result[$contact['id']], 1700 'style.backgroundColor' => $color, 1701 ); 1702 } 1703 } 1704 } 1705 $options['total'] = $this->total; 1706 return $result; 1707 } 1708 1709 /** 1710 * Query for subtype email (returns only contacts with email address set) 1711 * 1712 * @param string|array $pattern 1713 * @param array $options 1714 * @return Ambigous <multitype:, string, multitype:Ambigous <multitype:, string> string > 1715 */ 1716 function link_query_email($pattern, Array &$options = array()) 1717 { 1718 if (isset($options['filter']) && !is_array($options['filter'])) 1719 { 1720 $options['filter'] = (array)$options['filter']; 1721 } 1722 // return only contacts with email set 1723 $options['filter'][] = "contact_email ".$this->db->capabilities[Db::CAPABILITY_CASE_INSENSITIV_LIKE]." '%@%'"; 1724 1725 // let link query know, to append email to list 1726 $options['type'] = 'email'; 1727 1728 return $this->link_query($pattern,$options); 1729 } 1730 1731 /** 1732 * returns info about contacts for calender 1733 * 1734 * @param int|array $ids single contact-id or array of id's 1735 * @return array 1736 */ 1737 function calendar_info($ids) 1738 { 1739 if (!$ids) return null; 1740 1741 $data = array(); 1742 foreach(!is_array($ids) ? array($ids) : $ids as $id) 1743 { 1744 if (!($contact = $this->read($id))) continue; 1745 1746 $data[] = array( 1747 'res_id' => $id, 1748 'email' => $contact['email'] ? $contact['email'] : $contact['email_home'], 1749 'rights' => Acl::CUSTOM1|Acl::CUSTOM3, // calendar_bo::ACL_READ_FOR_PARTICIPANTS|ACL_INVITE 1750 'name' => $this->link_title($contact), 1751 'cn' => trim($contact['n_given'].' '.$contact['n_family']), 1752 ); 1753 } 1754 return $data; 1755 } 1756 1757 /** 1758 * Read the next and last event of given contacts 1759 * 1760 * @param array $uids participant IDs. Contacts should be c<contact_id>, user accounts <account_id> 1761 * @param boolean $extra_title =true if true, use a short date only title and put the full title as extra_title (tooltip) 1762 * @return array 1763 */ 1764 function read_calendar($uids,$extra_title=true) 1765 { 1766 if (!$GLOBALS['egw_info']['user']['apps']['calendar'] || 1767 $GLOBALS['egw_info']['server']['disable_event_column'] == 'True' 1768 ) 1769 { 1770 return array(); 1771 } 1772 1773 $split_uids = array(); 1774 $events = array(); 1775 1776 foreach($uids as $id => $uid) 1777 { 1778 $type = is_numeric($uid[0]) ? 'u' : $uid[0]; 1779 if($GLOBALS['egw_info']['server']['disable_event_column'] == 'contacts' && $type == 'u') 1780 { 1781 continue; 1782 } 1783 $split_uids[$type][$id] = str_replace($type, '', $uid); 1784 } 1785 1786 foreach($split_uids as $type => $s_uids) 1787 { 1788 $events += $this->read_calendar_type($s_uids, $type, $extra_title); 1789 } 1790 return $events; 1791 } 1792 1793 private function read_calendar_type($uids, $type='c', $extra_title = true) 1794 { 1795 $calendars = array(); 1796 $bocal = new calendar_bo(); 1797 $type_field = $type=='u' ? 'account_id' : 'contact_id'; 1798 $type_field_varchar = $this->db->to_varchar($type_field); 1799 $concat_start_id_recurrance = $this->db->concat('cal_start',"':'",'egw_cal_user.cal_id',"':'",'cal_recur_date'); 1800 $now = $this->db->unix_timestamp('NOW()'); 1801 $sql = "SELECT n_fn,org_name,$type_field AS user_id, 1802 ( 1803 SELECT $concat_start_id_recurrance 1804 FROM egw_cal_user 1805 JOIN egw_cal_dates on egw_cal_dates.cal_id=egw_cal_user.cal_id and (cal_recur_date=0 or cal_recur_date=cal_start) 1806 JOIN egw_cal ON egw_cal.cal_id=egw_cal_user.cal_id AND egw_cal.cal_deleted IS NULL 1807 WHERE cal_user_type='$type' and cal_user_id=$type_field_varchar and cal_start < $now"; 1808 if ( !$GLOBALS['egw_info']['user']['preferences']['calendar']['show_rejected']) 1809 { 1810 $sql .= " AND egw_cal_user.cal_status != 'R'"; 1811 } 1812 $sql .= " 1813 order by cal_start DESC Limit 1 1814 ) as last_event, 1815 ( 1816 SELECT $concat_start_id_recurrance 1817 FROM egw_cal_user 1818 JOIN egw_cal_dates on egw_cal_dates.cal_id=egw_cal_user.cal_id and (cal_recur_date=0 or cal_recur_date=cal_start) 1819 JOIN egw_cal ON egw_cal.cal_id=egw_cal_user.cal_id AND egw_cal.cal_deleted IS NULL 1820 WHERE cal_user_type='$type' and cal_user_id=$type_field_varchar and cal_start > $now"; 1821 if ( !$GLOBALS['egw_info']['user']['preferences']['calendar']['show_rejected']) 1822 { 1823 $sql .= " AND egw_cal_user.cal_status != 'R'"; 1824 } 1825 $sql .= ' order by cal_recur_date ASC, cal_start ASC Limit 1 1826 1827 ) as next_event 1828 FROM egw_addressbook 1829 WHERE '.$this->db->expression('egw_addressbook', array($type_field => $uids)); 1830 1831 1832 $contacts =& $this->db->query($sql, __LINE__, __FILE__); 1833 1834 if (!$contacts) return array(); 1835 1836 // Extract the event info and generate what is needed for next/last event 1837 $do_event = function($key, $contact) use (&$bocal, &$calendars, $type, $extra_title) 1838 { 1839 list($start, $cal_id, $recur_date) = explode(':', $contact[$key.'_event']); 1840 1841 $link = array( 1842 'id' => $cal_id,//.':'.$start, 1843 'app' => 'calendar', 1844 'title' => $bocal->link_title($cal_id . ($start ? '-'.$start : '')), 1845 'extra_args' => array( 1846 'date' => \EGroupware\Api\DateTime::server2user($start,\EGroupware\Api\DateTime::ET2), 1847 'exception'=> 1 1848 ), 1849 ); 1850 if ($extra_title) 1851 { 1852 $link['extra_title'] = $link['title']; 1853 $link['title'] = \EGroupware\Api\DateTime::server2user($start, true); 1854 } 1855 $user_id = ($type == 'u' ? '' : $type) . $contact['user_id']; 1856 $calendars[$user_id][$key.'_event'] = $start; 1857 $calendars[$user_id][$key.'_link'] = $link; 1858 }; 1859 1860 foreach($contacts as $contact) 1861 { 1862 if($contact['last_event']) 1863 { 1864 $do_event('last', $contact); 1865 } 1866 if($contact['next_event']) 1867 { 1868 $do_event('next', $contact); 1869 } 1870 } 1871 return $calendars; 1872 } 1873 1874 /** 1875 * Read the holidays (birthdays) from the given addressbook, either from the 1876 * instance cache, or read them & cache for next time. Cached for HOLIDAY_CACHE_TIME. 1877 * 1878 * @param int $addressbook - Addressbook to search. We cache them separately in the instance. 1879 * @param int $year 1880 */ 1881 public function read_birthdays($addressbook, $year) 1882 { 1883 if (($birthdays = Cache::getInstance(__CLASS__,"birthday-$year-$addressbook-".$GLOBALS['egw_info']['user']['preferences']['common']['lang'])) !== null) 1884 { 1885 return $birthdays; 1886 } 1887 1888 $birthdays = array(); 1889 $filter = array( 1890 'owner' => (int)$addressbook, 1891 'n_family' => "!''", 1892 'bday' => "!''", 1893 ); 1894 $bdays =& $this->search('',array('id','n_family','n_given','n_prefix','n_middle','bday'), 1895 'contact_bday ASC', '', '', false, 'AND', false, $filter); 1896 1897 if ($bdays) 1898 { 1899 // sort by month and day only 1900 usort($bdays, function($a, $b) 1901 { 1902 return (int) $a['bday'] == (int) $b['bday'] ? 1903 strcmp($a['bday'], $b['bday']) : 1904 (int) $a['bday'] - (int) $b['bday']; 1905 }); 1906 foreach($bdays as $pers) 1907 { 1908 if (empty($pers['bday']) || $pers['bday']=='0000-00-00 0' || $pers['bday']=='0000-00-00' || $pers['bday']=='0.0.00') 1909 { 1910 //error_log(__METHOD__.__LINE__.' Skipping entry for invalid birthday:'.array2string($pers)); 1911 continue; 1912 } 1913 list($y,$m,$d) = explode('-',$pers['bday']); 1914 if ($y > $year) 1915 { 1916 // not yet born 1917 continue; 1918 } 1919 $birthdays[sprintf('%04d%02d%02d',$year,$m,$d)][] = array( 1920 'day' => $d, 1921 'month' => $m, 1922 'occurence' => 0, 1923 'name' => implode(' ', array_filter(array(lang('Birthday'),($pers['n_given'] ? $pers['n_given'] : $pers['n_prefix']), $pers['n_middle'], 1924 $pers['n_family'], ($GLOBALS['egw_info']['server']['hide_birthdays'] == 'age' ? ($year - $y): '')))). 1925 ($y && in_array($GLOBALS['egw_info']['server']['hide_birthdays'], array('','age')) ? ' ('.$y.')' : ''), 1926 'birthyear' => $y, // this can be used to identify birthdays from holidays 1927 ); 1928 } 1929 } 1930 Cache::setInstance(__CLASS__,"birthday-$year-$addressbook-".$GLOBALS['egw_info']['user']['preferences']['common']['lang'], $birthdays, self::BIRTHDAY_CACHE_TIME); 1931 return $birthdays; 1932 } 1933 1934 /** 1935 * Called by delete-account hook, when an account get deleted --> deletes/moves the personal addressbook 1936 * 1937 * @param array $data 1938 */ 1939 function deleteaccount($data) 1940 { 1941 // delete/move personal addressbook 1942 parent::deleteaccount($data); 1943 } 1944 1945 /** 1946 * Called by delete_category hook, when a category gets deleted. 1947 * Removes the category from addresses 1948 */ 1949 function delete_category($data) 1950 { 1951 // get all cats if you want to drop sub cats 1952 $drop_subs = ($data['drop_subs'] && !$data['modify_subs']); 1953 if($drop_subs) 1954 { 1955 $cats = new Categories('', 'addressbook'); 1956 $cat_ids = $cats->return_all_children($data['cat_id']); 1957 } 1958 else 1959 { 1960 $cat_ids = array($data['cat_id']); 1961 } 1962 1963 // Get addresses that use the category 1964 @set_time_limit( 0 ); 1965 foreach($cat_ids as $cat_id) 1966 { 1967 if (($ids = $this->search(array('cat_id' => $cat_id), false))) 1968 { 1969 foreach($ids as &$info) 1970 { 1971 $info['cat_id'] = implode(',',array_diff(explode(',',$info['cat_id']), $cat_ids)); 1972 $this->save($info); 1973 } 1974 } 1975 } 1976 } 1977 1978 /** 1979 * Merges some given addresses into the first one and delete the others 1980 * 1981 * If one of the other addresses is an account, everything is merged into the account. 1982 * If two accounts are in $ids, the function fails (returns false). 1983 * 1984 * @param array $ids contact-id's to merge 1985 * @return int number of successful merged contacts, false on a fatal error (eg. cant merge two accounts) 1986 */ 1987 function merge($ids) 1988 { 1989 $this->error = false; 1990 $account = null; 1991 $custom_fields = Storage\Customfields::get('addressbook', true); 1992 $custom_field_list = $this->read_customfields($ids); 1993 foreach(parent::search(array('id'=>$ids),false) as $contact) // $this->search calls the extended search from ui! 1994 { 1995 if ($contact['account_id']) 1996 { 1997 if (!is_null($account)) 1998 { 1999 echo $this->error = 'Can not merge more then one account!'; 2000 return false; // we dont deal with two accounts! 2001 } 2002 $account = $contact; 2003 continue; 2004 } 2005 // Add in custom fields 2006 if (is_array($custom_field_list[$contact['id']])) $contact = array_merge($contact, $custom_field_list[$contact['id']]); 2007 2008 $pos = array_search($contact['id'],$ids); 2009 $contacts[$pos] = $contact; 2010 } 2011 if (!is_null($account)) // we found an account, so we merge the contacts into it 2012 { 2013 $target = $account; 2014 unset($account); 2015 } 2016 else // we found no account, so we merge all but the first into the first 2017 { 2018 $target = $contacts[0]; 2019 unset($contacts[0]); 2020 } 2021 if (!$this->check_perms(Acl::EDIT,$target)) 2022 { 2023 echo $this->error = 'No edit permission for the target contact!'; 2024 return 0; 2025 } 2026 foreach($contacts as $contact) 2027 { 2028 foreach($contact as $name => $value) 2029 { 2030 if (!$value) continue; 2031 2032 switch($name) 2033 { 2034 case 'id': 2035 case 'tid': 2036 case 'owner': 2037 case 'private': 2038 case 'etag'; 2039 break; // ignored 2040 2041 case 'cat_id': // cats are all merged together 2042 if (!is_array($target['cat_id'])) $target['cat_id'] = $target['cat_id'] ? explode(',',$target['cat_id']) : array(); 2043 $target['cat_id'] = array_unique(array_merge($target['cat_id'],is_array($value)?$value:explode(',',$value))); 2044 break; 2045 2046 default: 2047 // Multi-select custom fields can also be merged 2048 if($name[0] == '#') { 2049 $c_name = substr($name, 1); 2050 if($custom_fields[$c_name]['type'] == 'select' && $custom_fields[$c_name]['rows'] > 1) { 2051 if (!is_array($target[$name])) $target[$name] = $target[$name] ? explode(',',$target[$name]) : array(); 2052 $target[$name] = implode(',',array_unique(array_merge($target[$name],is_array($value)?$value:explode(',',$value)))); 2053 } 2054 } 2055 if (!$target[$name]) $target[$name] = $value; 2056 break; 2057 } 2058 } 2059 2060 // Merge distribution lists 2061 $lists = $this->read_distributionlist(array($contact['id'])); 2062 foreach($lists[$contact['id']] as $list_id => $list_name) 2063 { 2064 parent::add2list($target['id'], $list_id); 2065 } 2066 } 2067 if (!$this->save($target)) return 0; 2068 2069 $success = 1; 2070 foreach($contacts as $contact) 2071 { 2072 if (!$this->check_perms(Acl::DELETE,$contact)) 2073 { 2074 continue; 2075 } 2076 foreach(Link::get_links('addressbook',$contact['id']) as $data) 2077 { 2078 //_debug_array(array('function'=>__METHOD__,'line'=>__LINE__,'app'=>'addressbook','id'=>$contact['id'],'data:'=>$data,'target'=>$target['id'])); 2079 // info_from and info_link_id (main link) 2080 $newlinkID = Link::link('addressbook',$target['id'],$data['app'],$data['id'],$data['remark'],$target['owner']); 2081 //_debug_array(array('newLinkID'=>$newlinkID)); 2082 if ($newlinkID) 2083 { 2084 // update egw_infolog set info_link_id=$newlinkID where info_id=$data['id'] and info_link_id=$data['link_id'] 2085 if ($data['app']=='infolog') 2086 { 2087 $this->db->update('egw_infolog',array( 2088 'info_link_id' => $newlinkID 2089 ),array( 2090 'info_id' => $data['id'], 2091 'info_link_id' => $data['link_id'] 2092 ),__LINE__,__FILE__,'infolog'); 2093 } 2094 unset($newlinkID); 2095 } 2096 } 2097 // Update calendar 2098 $this->merge_calendar('c'.$contact['id'], $target['account_id'] ? 'u'.$target['account_id'] : 'c'.$target['id']); 2099 2100 if ($this->delete($contact['id'])) $success++; 2101 } 2102 return $success; 2103 } 2104 2105 /** 2106 * Change the contact ID in any calendar events from the old contact ID 2107 * to the new merged ID 2108 * 2109 * @param int $old_id 2110 * @param int $new_id 2111 */ 2112 protected function merge_calendar($old_id, $new_id) 2113 { 2114 static $bo; 2115 if(!is_object($bo)) 2116 { 2117 $bo = new \calendar_boupdate(); 2118 } 2119 2120 // Find all events with this contact 2121 $events = $bo->search(array('users' => $old_id, 'ignore_acl' => true)); 2122 2123 foreach($events as $event) 2124 { 2125 $event['participants'][$new_id] = $event['participants'][$old_id]; 2126 unset($event['participants'][$old_id]); 2127 2128 // Quietly update, ignoring ACL & no notifications 2129 $bo->update($event, true, true, true, true, $messages, true); 2130 } 2131 } 2132 2133 /** 2134 * Some caching for lists within request 2135 * 2136 * @var array 2137 */ 2138 private static $list_cache = array(); 2139 2140 /** 2141 * Check if user has required rights for a list or list-owner 2142 * 2143 * @param int $list 2144 * @param int $required 2145 * @param int $owner =null 2146 * @return boolean 2147 */ 2148 function check_list($list,$required,$owner=null) 2149 { 2150 if ($list && ($list_data = $this->read_list($list))) 2151 { 2152 $owner = $list_data['list_owner']; 2153 } 2154 //error_log(__METHOD__."($list, $required, $owner) grants[$owner]=".$this->grants[$owner]." returning ".array2string(!!($this->grants[$owner] & $required))); 2155 return !!($this->grants[$owner] & $required); 2156 } 2157 2158 /** 2159 * Adds / updates a distribution list 2160 * 2161 * @param string|array $keys list-name or array with column-name => value pairs to specify the list 2162 * @param int $owner user- or group-id 2163 * @param array $contacts =array() contacts to add (only for not yet existing lists!) 2164 * @param array &$data=array() values for keys 'list_uid', 'list_carddav_name', 'list_name' 2165 * @return int|boolean integer list_id or false on error 2166 */ 2167 function add_list($keys,$owner,$contacts=array(),array &$data=array()) 2168 { 2169 if (!$this->check_list(null,Acl::ADD|Acl::EDIT,$owner)) return false; 2170 2171 try { 2172 $ret = parent::add_list($keys,$owner,$contacts,$data); 2173 if ($ret) unset(self::$list_cache[$ret]); 2174 } 2175 // catch sql error, as creating same name&owner list gives a sql error doublicate key 2176 catch(Db\Exception\InvalidSql $e) { 2177 unset($e); // not used 2178 return false; 2179 } 2180 return $ret; 2181 } 2182 2183 /** 2184 * Adds contacts to a distribution list 2185 * 2186 * @param int|array $contact contact_id(s) 2187 * @param int $list list-id 2188 * @param array $existing =null array of existing contact-id(s) of list, to not reread it, eg. array() 2189 * @return false on error 2190 */ 2191 function add2list($contact,$list,array $existing=null) 2192 { 2193 if (!$this->check_list($list,Acl::EDIT)) return false; 2194 2195 unset(self::$list_cache[$list]); 2196 2197 return parent::add2list($contact,$list,$existing); 2198 } 2199 2200 /** 2201 * Removes one contact from distribution list(s) 2202 * 2203 * @param int|array $contact contact_id(s) 2204 * @param int $list list-id 2205 * @return false on error 2206 */ 2207 function remove_from_list($contact,$list=null) 2208 { 2209 if ($list && !$this->check_list($list,Acl::EDIT)) return false; 2210 2211 if ($list) 2212 { 2213 unset(self::$list_cache[$list]); 2214 } 2215 else 2216 { 2217 self::$list_cache = array(); 2218 } 2219 2220 return parent::remove_from_list($contact,$list); 2221 } 2222 2223 /** 2224 * Deletes a distribution list (incl. it's members) 2225 * 2226 * @param int|array $list list_id(s) 2227 * @return number of members deleted or false if list does not exist 2228 */ 2229 function delete_list($list) 2230 { 2231 foreach((array)$list as $l) 2232 { 2233 if (!$this->check_list($l, Acl::DELETE)) return false; 2234 2235 unset(self::$list_cache[$l]); 2236 } 2237 2238 return parent::delete_list($list); 2239 } 2240 2241 /** 2242 * Read data of a distribution list 2243 * 2244 * @param int $list list_id 2245 * @return array of data or false if list does not exist 2246 */ 2247 function read_list($list) 2248 { 2249 if (isset(self::$list_cache[$list])) return self::$list_cache[$list]; 2250 2251 return self::$list_cache[$list] = parent::read_list($list); 2252 } 2253 2254 /** 2255 * Get the address-format of a country 2256 * 2257 * This is a good reference where I got nearly all information, thanks to mikaelarhelger-AT-gmail.com 2258 * http://www.bitboost.com/ref/international-address-formats.html 2259 * 2260 * Mail me (RalfBecker-AT-outdoor-training.de) if you want your nation added or fixed. 2261 * 2262 * @param string $country 2263 * @return string 'city_state_postcode' (eg. US) or 'postcode_city' (eg. DE) 2264 */ 2265 function addr_format_by_country($country) 2266 { 2267 $code = Country::country_code($country); 2268 2269 switch($code) 2270 { 2271 case 'AU': 2272 case 'CA': 2273 case 'GB': // not exactly right, postcode is in separate line 2274 case 'HK': // not exactly right, they have no postcode 2275 case 'IN': 2276 case 'ID': 2277 case 'IE': // not exactly right, they have no postcode 2278 case 'JP': // not exactly right 2279 case 'KR': 2280 case 'LV': 2281 case 'NZ': 2282 case 'TW': 2283 case 'SA': // not exactly right, postcode is in separate line 2284 case 'SG': 2285 case 'US': 2286 $adr_format = 'city_state_postcode'; 2287 break; 2288 2289 case 'AR': 2290 case 'AT': 2291 case 'BE': 2292 case 'CH': 2293 case 'CZ': 2294 case 'DK': 2295 case 'EE': 2296 case 'ES': 2297 case 'FI': 2298 case 'FR': 2299 case 'DE': 2300 case 'GL': 2301 case 'IS': 2302 case 'IL': 2303 case 'IT': 2304 case 'LT': 2305 case 'LU': 2306 case 'MY': 2307 case 'MX': 2308 case 'NL': 2309 case 'NO': 2310 case 'PL': 2311 case 'PT': 2312 case 'RO': 2313 case 'RU': 2314 case 'SE': 2315 $adr_format = 'postcode_city'; 2316 break; 2317 2318 default: 2319 $adr_format = $this->prefs['addr_format'] ? $this->prefs['addr_format'] : 'postcode_city'; 2320 } 2321 return $adr_format; 2322 } 2323 2324 /** 2325 * Find existing categories in database by name or add categories that do not exist yet 2326 * currently used for vcard import 2327 * 2328 * @param array $catname_list names of the categories which should be found or added 2329 * @param int $contact_id =null match against existing contact and expand the returned category ids 2330 * by the ones the user normally does not see due to category permissions - used to preserve categories 2331 * @return array category ids (found, added and preserved categories) 2332 */ 2333 function find_or_add_categories($catname_list, $contact_id=null) 2334 { 2335 if ($contact_id && $contact_id > 0 && ($old_contact = $this->read($contact_id))) 2336 { 2337 // preserve categories without users read access 2338 $old_categories = explode(',',$old_contact['cat_id']); 2339 $old_cats_preserve = array(); 2340 if (is_array($old_categories) && count($old_categories) > 0) 2341 { 2342 foreach ($old_categories as $cat_id) 2343 { 2344 if (!$this->categories->check_perms(Acl::READ, $cat_id)) 2345 { 2346 $old_cats_preserve[] = $cat_id; 2347 } 2348 } 2349 } 2350 } 2351 2352 $cat_id_list = array(); 2353 foreach ((array)$catname_list as $cat_name) 2354 { 2355 $cat_name = trim($cat_name); 2356 $cat_id = $this->categories->name2id($cat_name, 'X-'); 2357 if (!$cat_id) 2358 { 2359 // some SyncML clients (mostly phones) add an X- to the category names 2360 if (strncmp($cat_name, 'X-', 2) == 0) 2361 { 2362 $cat_name = substr($cat_name, 2); 2363 } 2364 $cat_id = $this->categories->add(array('name' => $cat_name, 'descr' => $cat_name, 'access' => 'private')); 2365 } 2366 2367 if ($cat_id) 2368 { 2369 $cat_id_list[] = $cat_id; 2370 } 2371 } 2372 2373 if (is_array($old_cats_preserve) && count($old_cats_preserve) > 0) 2374 { 2375 $cat_id_list = array_merge($cat_id_list, $old_cats_preserve); 2376 } 2377 2378 if (count($cat_id_list) > 1) 2379 { 2380 $cat_id_list = array_unique($cat_id_list); 2381 sort($cat_id_list, SORT_NUMERIC); 2382 } 2383 2384 //error_log(__METHOD__."(".array2string($catname_list).", $contact_id) returning ".array2string($cat_id_list)); 2385 return $cat_id_list; 2386 } 2387 2388 function get_categories($cat_id_list) 2389 { 2390 if (!is_object($this->categories)) 2391 { 2392 $this->categories = new Categories($this->user,'addressbook'); 2393 } 2394 2395 if (!is_array($cat_id_list)) 2396 { 2397 $cat_id_list = explode(',',$cat_id_list); 2398 } 2399 $cat_list = array(); 2400 foreach($cat_id_list as $cat_id) 2401 { 2402 if ($cat_id && $this->categories->check_perms(Acl::READ, $cat_id) && 2403 ($cat_name = $this->categories->id2name($cat_id)) && $cat_name != '--') 2404 { 2405 $cat_list[] = $cat_name; 2406 } 2407 } 2408 2409 return $cat_list; 2410 } 2411 2412 function fixup_contact(&$contact) 2413 { 2414 if (empty($contact['n_fn'])) 2415 { 2416 $contact['n_fn'] = $this->fullname($contact); 2417 } 2418 2419 if (empty($contact['n_fileas'])) 2420 { 2421 $contact['n_fileas'] = $this->fileas($contact); 2422 } 2423 } 2424 2425 /** 2426 * Try to find a matching db entry 2427 * 2428 * @param array $contact the contact data we try to find 2429 * @param boolean $relax =false if asked to relax, we only match against some key fields 2430 * @return array od matching contact_ids 2431 */ 2432 function find_contact($contact, $relax=false) 2433 { 2434 $empty_addr_one = $empty_addr_two = true; 2435 2436 if ($this->log) 2437 { 2438 error_log(__FILE__.'['.__LINE__.'] '.__METHOD__ 2439 . '('. ($relax ? 'RELAX': 'EXACT') . ')[ContactData]:' 2440 . array2string($contact) 2441 . "\n", 3, $this->logfile); 2442 } 2443 2444 $matchingContacts = array(); 2445 if ($contact['id'] && ($found = $this->read($contact['id']))) 2446 { 2447 if ($this->log) 2448 { 2449 error_log(__FILE__.'['.__LINE__.'] '.__METHOD__ 2450 . '()[ContactID]: ' . $contact['id'] 2451 . "\n", 3, $this->logfile); 2452 } 2453 // We only do a simple consistency check 2454 if (!$relax || ((empty($found['n_family']) || $found['n_family'] == $contact['n_family']) 2455 && (empty($found['n_given']) || $found['n_given'] == $contact['n_given']) 2456 && (empty($found['org_name']) || $found['org_name'] == $contact['org_name']))) 2457 { 2458 return array($found['id']); 2459 } 2460 } 2461 unset($contact['id']); 2462 2463 if (!$relax && !empty($contact['uid'])) 2464 { 2465 if ($this->log) 2466 { 2467 error_log(__FILE__.'['.__LINE__.'] '.__METHOD__ 2468 . '()[ContactUID]: ' . $contact['uid'] 2469 . "\n", 3, $this->logfile); 2470 } 2471 // Try the given UID first 2472 $criteria = array ('contact_uid' => $contact['uid']); 2473 if (($foundContacts = parent::search($criteria))) 2474 { 2475 foreach ($foundContacts as $egwContact) 2476 { 2477 $matchingContacts[] = $egwContact['id']; 2478 } 2479 } 2480 return $matchingContacts; 2481 } 2482 unset($contact['uid']); 2483 2484 $columns_to_search = array('n_family', 'n_given', 'n_middle', 'n_prefix', 'n_suffix', 2485 'bday', 'org_name', 'org_unit', 'title', 'role', 2486 'email', 'email_home'); 2487 $tolerance_fields = array('n_middle', 'n_prefix', 'n_suffix', 2488 'bday', 'org_unit', 'title', 'role', 2489 'email', 'email_home'); 2490 $addr_one_fields = array('adr_one_street', 'adr_one_locality', 2491 'adr_one_region', 'adr_one_postalcode'); 2492 $addr_two_fields = array('adr_two_street', 'adr_two_locality', 2493 'adr_two_region', 'adr_two_postalcode'); 2494 2495 if (!empty($contact['owner'])) 2496 { 2497 $columns_to_search += array('owner'); 2498 } 2499 2500 $criteria = array(); 2501 2502 foreach ($columns_to_search as $field) 2503 { 2504 if ($relax && in_array($field, $tolerance_fields)) continue; 2505 2506 if (empty($contact[$field])) 2507 { 2508 // Not every device supports all fields 2509 if (!in_array($field, $tolerance_fields)) 2510 { 2511 $criteria[$field] = ''; 2512 } 2513 } 2514 else 2515 { 2516 $criteria[$field] = $contact[$field]; 2517 } 2518 } 2519 2520 if (!$relax) 2521 { 2522 // We use addresses only for strong matching 2523 2524 foreach ($addr_one_fields as $field) 2525 { 2526 if (empty($contact[$field])) 2527 { 2528 $criteria[$field] = ''; 2529 } 2530 else 2531 { 2532 $empty_addr_one = false; 2533 $criteria[$field] = $contact[$field]; 2534 } 2535 } 2536 2537 foreach ($addr_two_fields as $field) 2538 { 2539 if (empty($contact[$field])) 2540 { 2541 $criteria[$field] = ''; 2542 } 2543 else 2544 { 2545 $empty_addr_two = false; 2546 $criteria[$field] = $contact[$field]; 2547 } 2548 } 2549 } 2550 2551 if ($this->log) 2552 { 2553 error_log(__FILE__.'['.__LINE__.'] '.__METHOD__ 2554 . '()[Addressbook FIND Step 1]: ' 2555 . 'CRITERIA = ' . array2string($criteria) 2556 . "\n", 3, $this->logfile); 2557 } 2558 2559 // first try full match 2560 if (($foundContacts = parent::search($criteria, true, '', '', '', true))) 2561 { 2562 foreach ($foundContacts as $egwContact) 2563 { 2564 $matchingContacts[] = $egwContact['id']; 2565 } 2566 } 2567 2568 // No need for more searches for relaxed matching 2569 if ($relax || count($matchingContacts)) return $matchingContacts; 2570 2571 2572 if (!$empty_addr_one && $empty_addr_two) 2573 { 2574 // try given address and ignore the second one in EGW 2575 foreach ($addr_two_fields as $field) 2576 { 2577 unset($criteria[$field]); 2578 } 2579 2580 if ($this->log) 2581 { 2582 error_log(__FILE__.'['.__LINE__.'] '.__METHOD__ 2583 . '()[Addressbook FIND Step 2]: ' 2584 . 'CRITERIA = ' . array2string($criteria) 2585 . "\n", 3, $this->logfile); 2586 } 2587 2588 if (($foundContacts = parent::search($criteria, true, '', '', '', true))) 2589 { 2590 foreach ($foundContacts as $egwContact) 2591 { 2592 $matchingContacts[] = $egwContact['id']; 2593 } 2594 } 2595 else 2596 { 2597 // try address as home address -- some devices don't qualify addresses 2598 foreach ($addr_two_fields as $key => $field) 2599 { 2600 $criteria[$field] = $criteria[$addr_one_fields[$key]]; 2601 unset($criteria[$addr_one_fields[$key]]); 2602 } 2603 2604 if ($this->log) 2605 { 2606 error_log(__FILE__.'['.__LINE__.'] '.__METHOD__ 2607 . '()[Addressbook FIND Step 3]: ' 2608 . 'CRITERIA = ' . array2string($criteria) 2609 . "\n", 3, $this->logfile); 2610 } 2611 2612 if (($foundContacts = parent::search($criteria, true, '', '', '', true))) 2613 { 2614 foreach ($foundContacts as $egwContact) 2615 { 2616 $matchingContacts[] = $egwContact['id']; 2617 } 2618 } 2619 } 2620 } 2621 elseif (!$empty_addr_one && !$empty_addr_two) 2622 { // try again after address swap 2623 2624 foreach ($addr_one_fields as $key => $field) 2625 { 2626 $_temp = $criteria[$field]; 2627 $criteria[$field] = $criteria[$addr_two_fields[$key]]; 2628 $criteria[$addr_two_fields[$key]] = $_temp; 2629 } 2630 if ($this->log) 2631 { 2632 error_log(__FILE__.'['.__LINE__.'] '.__METHOD__ 2633 . '()[Addressbook FIND Step 4]: ' 2634 . 'CRITERIA = ' . array2string($criteria) 2635 . "\n", 3, $this->logfile); 2636 } 2637 if (($foundContacts = parent::search($criteria, true, '', '', '', true))) 2638 { 2639 foreach ($foundContacts as $egwContact) 2640 { 2641 $matchingContacts[] = $egwContact['id']; 2642 } 2643 } 2644 } 2645 if ($this->log) 2646 { 2647 error_log(__FILE__.'['.__LINE__.'] '.__METHOD__ 2648 . '()[FOUND]: ' . array2string($matchingContacts) 2649 . "\n", 3, $this->logfile); 2650 } 2651 return $matchingContacts; 2652 } 2653 2654 /** 2655 * Get a ctag (collection tag) for one addressbook or all addressbooks readable by a user 2656 * 2657 * Currently implemented as maximum modification date (1 seconde granularity!) 2658 * 2659 * We have to include deleted entries, as otherwise the ctag will not change if an entry gets deleted! 2660 * (Only works if tracking of deleted entries / history is switched on!) 2661 * 2662 * @param int|array $owner =null 0=accounts, null=all addressbooks or integer account_id of user or group 2663 * @return string 2664 */ 2665 public function get_ctag($owner=null) 2666 { 2667 $filter = array('tid' => null); // tid=null --> use all entries incl. deleted (tid='D') 2668 // show addressbook of a single user? 2669 if (!is_null($owner)) $filter['owner'] = $owner; 2670 2671 // should we hide the accounts addressbook 2672 if (!$owner && $GLOBALS['egw_info']['user']['preferences']['addressbook']['hide_accounts'] === '1') 2673 { 2674 $filter['account_id'] = null; 2675 } 2676 $result = $this->search(array(),'contact_modified','contact_modified DESC','','',false,'AND',array(0,1),$filter); 2677 2678 if (!$result || !isset($result[0]['modified'])) 2679 { 2680 $ctag = 'empty'; // ctag for empty addressbook 2681 } 2682 else 2683 { 2684 // need to convert modified time back to server-time (was converted to user-time by search) 2685 // as we use it direct in server-queries eg. CardDAV sync-report and to be consistent with CalDAV 2686 $ctag = DateTime::user2server($result[0]['modified']); 2687 } 2688 //error_log(__METHOD__.'('.array2string($owner).') returning '.array2string($ctag)); 2689 return $ctag; 2690 } 2691 2692 /** 2693 * download photo of the given ($_GET['contact_id'] or $_GET['account_id']) contact 2694 */ 2695 function photo() 2696 { 2697 ob_start(); 2698 2699 $contact_id = isset($_GET['contact_id']) ? $_GET['contact_id'] : 2700 (isset($_GET['account_id']) ? 'account:'.$_GET['account_id'] : 0); 2701 2702 if (substr($contact_id,0,8) == 'account:') 2703 { 2704 $contact_id = $GLOBALS['egw']->accounts->id2name(substr($contact_id,8),'person_id'); 2705 } 2706 2707 $contact = $this->read($contact_id); 2708 2709 if (!($contact) || 2710 empty($contact['jpegphoto']) && // LDAP/AD (not updated SQL) 2711 !(($contact['files'] & \EGroupware\Api\Contacts::FILES_BIT_PHOTO) && // new SQL in VFS 2712 ($size = filesize($url= \EGroupware\Api\Link::vfs_path('addressbook', $contact_id, \EGroupware\Api\Contacts::FILES_PHOTO))))) 2713 { 2714 if (is_array($contact)) 2715 { 2716 header('Content-type: image/jpeg'); 2717 $contact['jpegphoto'] = \EGroupware\Api\avatar::lavatar(array( 2718 'id' => $contact['id'], 2719 'firstname' => $contact['n_given'], 2720 'lastname' => $contact['n_family']) 2721 ); 2722 } 2723 } 2724 2725 // use an etag over the image mapp 2726 $etag = '"'.$contact_id.':'.$contact['etag'].'"'; 2727 if (!ob_get_contents()) 2728 { 2729 header('Content-type: image/jpeg'); 2730 header('ETag: '.$etag); 2731 // if etag parameter given in url, we can allow browser to cache picture via an Expires header 2732 // different url with different etag parameter will force a reload 2733 if (isset($_GET['etag'])) 2734 { 2735 \EGroupware\Api\Session::cache_control(30*86400); // cache for 30 days 2736 } 2737 // if servers send a If-None-Match header, response with 304 Not Modified, if etag matches 2738 if (isset($_SERVER['HTTP_IF_NONE_MATCH']) && $_SERVER['HTTP_IF_NONE_MATCH'] === $etag) 2739 { 2740 header("HTTP/1.1 304 Not Modified"); 2741 } 2742 elseif(!empty($contact['jpegphoto'])) 2743 { 2744 header('Content-length: '.bytes($contact['jpegphoto'])); 2745 echo $contact['jpegphoto']; 2746 } 2747 else 2748 { 2749 header('Content-length: '.$size); 2750 readfile($url); 2751 } 2752 exit(); 2753 } 2754 Egw::redirect(\EGroupware\Api\Image::find('addressbook','photo')); 2755 } 2756} 2757