1<?php 2 3/** 4 +-----------------------------------------------------------------------+ 5 | This file is part of the Roundcube Webmail client | 6 | | 7 | Copyright (C) The Roundcube Dev Team | 8 | | 9 | Licensed under the GNU General Public License version 3 or | 10 | any later version with exceptions for skins & plugins. | 11 | See the README file for a full license statement. | 12 | | 13 | PURPOSE: | 14 | Logical representation of a vcard address record | 15 +-----------------------------------------------------------------------+ 16 | Author: Thomas Bruederli <roundcube@gmail.com> | 17 | Author: Aleksander Machniak <alec@alec.pl> | 18 +-----------------------------------------------------------------------+ 19*/ 20 21/** 22 * Logical representation of a vcard-based address record 23 * Provides functions to parse and export vCard data format 24 * 25 * @package Framework 26 * @subpackage Addressbook 27 */ 28class rcube_vcard 29{ 30 private static $values_decoded = false; 31 private $raw = [ 32 'FN' => [], 33 'N' => [['','','','','']], 34 ]; 35 private static $fieldmap = [ 36 'phone' => 'TEL', 37 'birthday' => 'BDAY', 38 'website' => 'URL', 39 'notes' => 'NOTE', 40 'email' => 'EMAIL', 41 'address' => 'ADR', 42 'jobtitle' => 'TITLE', 43 'department' => 'X-DEPARTMENT', 44 'gender' => 'X-GENDER', 45 'maidenname' => 'X-MAIDENNAME', 46 'anniversary' => 'X-ANNIVERSARY', 47 'assistant' => 'X-ASSISTANT', 48 'manager' => 'X-MANAGER', 49 'spouse' => 'X-SPOUSE', 50 'edit' => 'X-AB-EDIT', 51 'groups' => 'CATEGORIES', 52 ]; 53 private $typemap = [ 54 'IPHONE' => 'mobile', 55 'CELL' => 'mobile', 56 'WORK,FAX' => 'workfax', 57 ]; 58 private $phonetypemap = [ 59 'HOME1' => 'HOME', 60 'BUSINESS1' => 'WORK', 61 'BUSINESS2' => 'WORK2', 62 'BUSINESSFAX' => 'WORK,FAX', 63 'MOBILE' => 'CELL', 64 ]; 65 private $addresstypemap = [ 66 'BUSINESS' => 'WORK', 67 ]; 68 private $immap = [ 69 'X-JABBER' => 'jabber', 70 'X-ICQ' => 'icq', 71 'X-MSN' => 'msn', 72 'X-AIM' => 'aim', 73 'X-YAHOO' => 'yahoo', 74 'X-SKYPE' => 'skype', 75 'X-SKYPE-USERNAME' => 'skype', 76 ]; 77 78 public $business = false; 79 public $displayname; 80 public $surname; 81 public $firstname; 82 public $middlename; 83 public $nickname; 84 public $organization; 85 public $email = []; 86 87 public static $eol = "\r\n"; 88 89 90 /** 91 * Constructor 92 * 93 * @param string $vcard vCard content 94 * @param string $charset Charset of string values 95 * @param bool $detect True if loading a 'foreign' vcard and extra heuristics 96 * for charset detection is required 97 * @param array $fieldmap Fields mapping definition 98 */ 99 public function __construct($vcard = null, $charset = RCUBE_CHARSET, $detect = false, $fieldmap = []) 100 { 101 if (!empty($fieldmap)) { 102 $this->extend_fieldmap($fieldmap); 103 } 104 105 if (!empty($vcard)) { 106 $this->load($vcard, $charset, $detect); 107 } 108 } 109 110 /** 111 * Load record from (internal, unfolded) vcard 3.0 format 112 * 113 * @param string $vcard vCard string to parse 114 * @param string $charset Charset of string values 115 * @param bool $detect True if loading a 'foreign' vcard and extra heuristics 116 * for charset detection is required 117 */ 118 public function load($vcard, $charset = RCUBE_CHARSET, $detect = false) 119 { 120 self::$values_decoded = false; 121 $this->raw = self::vcard_decode(self::cleanup($vcard)); 122 123 // resolve charset parameters 124 if ($charset == null) { 125 $this->raw = self::charset_convert($this->raw); 126 } 127 // vcard has encoded values and charset should be detected 128 else if ( 129 $detect && self::$values_decoded 130 && ($detected_charset = self::detect_encoding(self::vcard_encode($this->raw))) 131 && $detected_charset != RCUBE_CHARSET 132 ) { 133 $this->raw = self::charset_convert($this->raw, $detected_charset); 134 } 135 136 // find well-known address fields 137 $this->displayname = isset($this->raw['FN'][0][0]) ? $this->raw['FN'][0][0] : null; 138 $this->surname = isset($this->raw['N'][0][0]) ? $this->raw['N'][0][0] : null; 139 $this->firstname = isset($this->raw['N'][0][1]) ? $this->raw['N'][0][1] : null; 140 $this->middlename = isset($this->raw['N'][0][2]) ? $this->raw['N'][0][2] : null; 141 $this->nickname = isset($this->raw['NICKNAME'][0][0]) ? $this->raw['NICKNAME'][0][0] : null; 142 $this->organization = isset($this->raw['ORG'][0][0]) ? $this->raw['ORG'][0][0] : null; 143 $this->business = (isset($this->raw['X-ABSHOWAS'][0][0]) && $this->raw['X-ABSHOWAS'][0][0] == 'COMPANY') 144 || (!empty($this->organization) && isset($this->raw['N'][0]) && @implode('', (array) $this->raw['N'][0]) === ''); 145 146 if (!empty($this->raw['EMAIL'])) { 147 foreach ((array) $this->raw['EMAIL'] as $i => $raw_email) { 148 $this->email[$i] = is_array($raw_email) ? $raw_email[0] : $raw_email; 149 } 150 } 151 152 // make the pref e-mail address the first entry in $this->email 153 $pref_index = $this->get_type_index('EMAIL'); 154 if ($pref_index > 0) { 155 $tmp = $this->email[0]; 156 $this->email[0] = $this->email[$pref_index]; 157 $this->email[$pref_index] = $tmp; 158 } 159 160 // fix broken vcards from Outlook that only supply ORG but not the required N or FN properties 161 if (!strlen(trim($this->displayname . $this->surname . $this->firstname)) && strlen($this->organization)) { 162 $this->displayname = $this->organization; 163 } 164 } 165 166 /** 167 * Return vCard data as associative array to be used in Roundcube address books 168 * 169 * @return array Hash array with key-value pairs 170 */ 171 public function get_assoc() 172 { 173 $out = ['name' => $this->displayname]; 174 $typemap = $this->typemap; 175 176 // copy name fields to output array 177 foreach (['firstname', 'surname', 'middlename', 'nickname', 'organization'] as $col) { 178 if (strlen($this->$col)) { 179 $out[$col] = $this->$col; 180 } 181 } 182 183 if (!empty($this->raw['N'][0][3])) { 184 $out['prefix'] = $this->raw['N'][0][3]; 185 } 186 187 if (!empty($this->raw['N'][0][4])) { 188 $out['suffix'] = $this->raw['N'][0][4]; 189 } 190 191 // convert from raw vcard data into associative data for Roundcube 192 foreach (array_flip(self::$fieldmap) as $tag => $col) { 193 if (empty($this->raw[$tag])) { 194 continue; 195 } 196 197 foreach ((array) $this->raw[$tag] as $i => $raw) { 198 if (is_array($raw)) { 199 $k = -1; 200 $key = $col; 201 $subtype = ''; 202 203 if (!empty($raw['type'])) { 204 $raw['type'] = array_map('strtolower', $raw['type']); 205 206 $combined = implode(',', array_diff($raw['type'], ['internet', 'pref'])); 207 $combined = strtoupper($combined); 208 209 if (!empty($typemap[$combined])) { 210 $subtype = $typemap[$combined]; 211 } 212 else if (!empty($typemap[$raw['type'][++$k]])) { 213 $subtype = $typemap[$raw['type'][$k]]; 214 } 215 else { 216 $subtype = $raw['type'][$k]; 217 } 218 219 while ($k < count($raw['type']) && ($subtype == 'internet' || $subtype == 'pref')) { 220 $k++; 221 if (!empty($raw['type'][$k])) { 222 if (!empty($typemap[$raw['type'][$k]])) { 223 $subtype = $typemap[$raw['type'][$k]]; 224 } 225 else { 226 $subtype = $raw['type'][$k]; 227 } 228 } 229 } 230 } 231 232 // read vcard 2.1 subtype 233 if (!$subtype) { 234 foreach ($raw as $k => $v) { 235 if (!is_numeric($k) && $v === true && ($k = strtolower($k)) 236 && !in_array($k, ['pref', 'internet', 'voice', 'base64']) 237 ) { 238 $k_uc = strtoupper($k); 239 $subtype = $typemap[$k_uc] ?: $k; 240 break; 241 } 242 } 243 } 244 245 // force subtype if none set 246 if (!$subtype && preg_match('/^(email|phone|address|website)/', $key)) { 247 $subtype = 'other'; 248 } 249 250 if ($subtype) { 251 $key .= ':' . $subtype; 252 } 253 254 // split ADR values into assoc array 255 if ($tag == 'ADR') { 256 list(,, $value['street'], $value['locality'], $value['region'], $value['zipcode'], $value['country']) = $raw; 257 $out[$key][] = $value; 258 } 259 // support vCard v4 date format (YYYYMMDD) 260 else if ($tag == 'BDAY' && preg_match('/^([12][90]\d\d)([01]\d)([0123]\d)$/', $raw[0], $m)) { 261 $out[$key][] = sprintf('%04d-%02d-%02d', intval($m[1]), intval($m[2]), intval($m[3])); 262 } 263 else { 264 $out[$key][] = $raw[0]; 265 } 266 } 267 else { 268 $out[$col][] = $raw; 269 } 270 } 271 } 272 273 // handle special IM fields as used by Apple 274 foreach ($this->immap as $tag => $type) { 275 if (!empty($this->raw[$tag])) { 276 foreach ((array) $this->raw[$tag] as $i => $raw) { 277 $out['im:'.$type][] = $raw[0]; 278 } 279 } 280 } 281 282 // copy photo data 283 if (!empty($this->raw['PHOTO'])) { 284 $out['photo'] = $this->raw['PHOTO'][0][0]; 285 } 286 287 return $out; 288 } 289 290 /** 291 * Convert the data structure into a vcard 3.0 string 292 * 293 * @param bool $folder Use RFC2425 folding 294 * 295 * @return string vCard output 296 */ 297 public function export($folded = true) 298 { 299 $vcard = self::vcard_encode($this->raw); 300 return $folded ? self::rfc2425_fold($vcard) : $vcard; 301 } 302 303 /** 304 * Clear the given fields in the loaded vcard data 305 * 306 * @param array List of field names to be reset 307 */ 308 public function reset($fields = []) 309 { 310 if (empty($fields)) { 311 $fields = ['FN', 'N', 'ORG', 'NICKNAME', 'EMAIL', 'ADR', 'BDAY']; 312 $fields = array_merge(array_values(self::$fieldmap), array_keys($this->immap), $fields); 313 } 314 315 foreach ($fields as $f) { 316 unset($this->raw[$f]); 317 } 318 319 if (empty($this->raw['N'])) { 320 $this->raw['N'] = [['','','','','']]; 321 } 322 323 if (empty($this->raw['FN'])) { 324 $this->raw['FN'] = []; 325 } 326 327 $this->email = []; 328 } 329 330 /** 331 * Setter for address record fields 332 * 333 * @param string $field Field name 334 * @param mixed $value Field value 335 * @param string $type Type/section name 336 */ 337 public function set($field, $value, $type = 'HOME') 338 { 339 $field = strtolower($field); 340 $type_uc = strtoupper($type); 341 342 switch ($field) { 343 case 'name': 344 case 'displayname': 345 $this->raw['FN'][0][0] = $this->displayname = $value; 346 break; 347 348 case 'surname': 349 $this->raw['N'][0][0] = $this->surname = $value; 350 break; 351 352 case 'firstname': 353 $this->raw['N'][0][1] = $this->firstname = $value; 354 break; 355 356 case 'middlename': 357 $this->raw['N'][0][2] = $this->middlename = $value; 358 break; 359 360 case 'prefix': 361 $this->raw['N'][0][3] = $value; 362 break; 363 364 case 'suffix': 365 $this->raw['N'][0][4] = $value; 366 break; 367 368 case 'nickname': 369 $this->raw['NICKNAME'][0][0] = $this->nickname = $value; 370 break; 371 372 case 'organization': 373 $this->raw['ORG'][0][0] = $this->organization = $value; 374 break; 375 376 case 'photo': 377 if (strpos($value, 'http:') === 0) { 378 // TODO: fetch file from URL and save it locally? 379 $this->raw['PHOTO'][0] = [0 => $value, 'url' => true]; 380 } 381 else { 382 $this->raw['PHOTO'][0] = [0 => $value, 'base64' => (bool) preg_match('![^a-z0-9/=+-]!i', $value)]; 383 } 384 break; 385 386 case 'email': 387 $this->raw['EMAIL'][] = [0 => $value, 'type' => array_filter(['INTERNET', $type_uc])]; 388 $this->email[] = $value; 389 break; 390 391 case 'im': 392 // save IM subtypes into extension fields 393 $typemap = array_flip($this->immap); 394 if (!empty($typemap[strtolower($type)])) { 395 $field = $typemap[strtolower($type)]; 396 $this->raw[$field][] = [$value]; 397 } 398 break; 399 400 case 'birthday': 401 case 'anniversary': 402 if (($val = rcube_utils::anytodatetime($value)) && !empty(self::$fieldmap[$field])) { 403 $fn = self::$fieldmap[$field]; 404 $this->raw[$fn][] = [0 => $val->format('Y-m-d'), 'value' => ['date']]; 405 } 406 break; 407 408 case 'address': 409 if (!empty($this->addresstypemap[$type_uc])) { 410 $type = $this->addresstypemap[$type_uc]; 411 } 412 413 if (empty($value[0])) { 414 $value = [ 415 '', 416 '', 417 isset($value['street']) ? $value['street'] : '', 418 isset($value['locality']) ? $value['locality'] : '', 419 isset($value['region']) ? $value['region'] : '', 420 isset($value['zipcode']) ? $value['zipcode'] : '', 421 isset($value['country']) ? $value['country'] : '', 422 ]; 423 } 424 425 // fall through if not empty 426 if (!strlen(@implode('', $value))) { 427 break; 428 } 429 430 default: 431 if ($field == 'phone' && !empty($this->phonetypemap[$type_uc])) { 432 $type = $this->phonetypemap[$type_uc]; 433 } 434 435 if (!empty(self::$fieldmap[$field])) { 436 $tag = self::$fieldmap[$field]; 437 438 if (is_array($value) || strlen($value)) { 439 $this->raw[$tag][] = (array) $value; 440 if ($type) { 441 $index = count($this->raw[$tag]) - 1; 442 $typemap = array_flip($this->typemap); 443 $type_val = !empty($typemap[$type_uc]) ? $typemap[$type_uc] : $type; 444 $this->raw[$tag][$index]['type'] = explode(',', $type_val); 445 } 446 } 447 else { 448 unset($this->raw[$tag]); 449 } 450 } 451 452 break; 453 } 454 } 455 456 /** 457 * Setter for individual vcard properties 458 * 459 * @param string $tag VCard tag name 460 * @param array $value Value-set of this vcard property 461 * @param bool $append Set to true if the value-set should be appended 462 * instead of replacing any existing value-set 463 */ 464 public function set_raw($tag, $value, $append = false) 465 { 466 $index = $append && isset($this->raw[$tag]) ? count($this->raw[$tag]) : 0; 467 $this->raw[$tag][$index] = (array) $value; 468 } 469 470 /** 471 * Find index with the '$type' attribute 472 * 473 * @param string $field Field name 474 * 475 * @return int Field index having $type set 476 */ 477 private function get_type_index($field) 478 { 479 $result = 0; 480 if (!empty($this->raw[$field])) { 481 foreach ((array) $this->raw[$field] as $i => $data) { 482 if (isset($data['type']) && is_array($data['type']) && in_array_nocase('pref', $data['type'])) { 483 $result = $i; 484 } 485 } 486 } 487 488 return $result; 489 } 490 491 /** 492 * Convert a whole vcard (array) to UTF-8. 493 * If $force_charset is null, each member value that has a charset parameter will be converted 494 */ 495 private static function charset_convert($card, $force_charset = null) 496 { 497 foreach ($card as $key => $node) { 498 foreach ($node as $i => $subnode) { 499 if (!is_array($subnode)) { 500 continue; 501 } 502 503 $charset = $force_charset; 504 if (!$charset && isset($subnode['charset'][0])) { 505 $charset = $subnode['charset'][0]; 506 } 507 508 if ($charset) { 509 foreach ($subnode as $j => $value) { 510 if (is_numeric($j) && is_string($value)) { 511 $card[$key][$i][$j] = rcube_charset::convert($value, $charset); 512 } 513 } 514 unset($card[$key][$i]['charset']); 515 } 516 } 517 } 518 519 return $card; 520 } 521 522 /** 523 * Extends fieldmap definition 524 * 525 * @param array $map Field mapping definition 526 */ 527 public function extend_fieldmap($map) 528 { 529 if (is_array($map)) { 530 self::$fieldmap = array_merge($map, self::$fieldmap); 531 } 532 } 533 534 /** 535 * Factory method to import a vcard file 536 * 537 * @param string $data vCard file content 538 * 539 * @return rcube_vcard[] List of rcube_vcard objects 540 */ 541 public static function import($data) 542 { 543 $out = []; 544 545 // check if charsets are specified (usually vcard version < 3.0 but this is not reliable) 546 if (preg_match('/charset=/i', substr($data, 0, 2048))) { 547 $charset = null; 548 } 549 // detect charset and convert to utf-8 550 else if (($charset = self::detect_encoding($data)) && $charset != RCUBE_CHARSET) { 551 $data = rcube_charset::convert($data, $charset); 552 $data = preg_replace(['/^[\xFE\xFF]{2}/', '/^\xEF\xBB\xBF/', '/^\x00+/'], '', $data); // also remove BOM 553 $charset = RCUBE_CHARSET; 554 } 555 556 $vcard_block = ''; 557 $in_vcard_block = false; 558 559 foreach (preg_split("/[\r\n]+/", $data) as $line) { 560 if ($in_vcard_block && !empty($line)) { 561 $vcard_block .= $line . "\n"; 562 } 563 564 $line = trim($line); 565 566 if (preg_match('/^END:VCARD$/i', $line)) { 567 // parse vcard 568 $obj = new rcube_vcard($vcard_block, $charset, true, self::$fieldmap); 569 // FN and N is required by vCard format (RFC 2426) 570 // on import we can be less restrictive, let's addressbook decide 571 if (!empty($obj->displayname) || !empty($obj->surname) || !empty($obj->firstname) || !empty($obj->email)) { 572 $out[] = $obj; 573 } 574 575 $in_vcard_block = false; 576 } 577 else if (preg_match('/^BEGIN:VCARD$/i', $line)) { 578 $vcard_block = $line . "\n"; 579 $in_vcard_block = true; 580 } 581 } 582 583 return $out; 584 } 585 586 /** 587 * Normalize vcard data for better parsing 588 * 589 * @param string $vcard vCard block 590 * 591 * @return string Cleaned vcard block 592 */ 593 public static function cleanup($vcard) 594 { 595 // convert Apple X-ABRELATEDNAMES into X-* fields for better compatibility 596 $vcard = preg_replace_callback( 597 '/item(\d+)\.(X-ABRELATEDNAMES)([^:]*?):(.*?)item\1.X-ABLabel:(?:_\$!<)?([\w() -]*)(?:>!\$_)?./s', 598 ['rcube_vcard', 'x_abrelatednames_callback'], 599 $vcard); 600 601 // Cleanup 602 $vcard = preg_replace( 603 [ 604 // convert special types (like Skype) to normal type='skype' classes with this simple regex ;) 605 '/item(\d+)\.(TEL|EMAIL|URL)([^:]*?):(.*?)item\1.X-ABLabel:(?:_\$!<)?([\w() -]*)(?:>!\$_)?./si', 606 '/^item\d*\.X-AB.*$/mi', // remove cruft like item1.X-AB* 607 '/^item\d*\./mi', // remove item1.ADR instead of ADR 608 '/\n+/', // remove empty lines 609 '/^(N:[^;\r\n]*)$/m', // if N doesn't have any semicolons, add some 610 ], 611 [ 612 '\2;type=\5\3:\4', 613 '', 614 '', 615 "\n", 616 '\1;;;;', 617 ], 618 $vcard 619 ); 620 621 // convert X-WAB-GENDER to X-GENDER 622 if (preg_match('/X-WAB-GENDER:(\d)/', $vcard, $matches)) { 623 $value = $matches[1] == '2' ? 'male' : 'female'; 624 $vcard = preg_replace('/X-WAB-GENDER:\d/', 'X-GENDER:' . $value, $vcard); 625 } 626 627 return $vcard; 628 } 629 630 /** 631 * Apple X-ABRELATEDNAMES converter callback 632 * 633 * @param array $matches Matching entries 634 * 635 * @return string Replacement string 636 */ 637 private static function x_abrelatednames_callback($matches) 638 { 639 return 'X-' . strtoupper($matches[5]) . $matches[3] . ':'. $matches[4]; 640 } 641 642 /** 643 * RFC2425 folding callback 644 * 645 * @param array $matches Matching entries 646 * 647 * @return string Replacement string 648 */ 649 private static function rfc2425_fold_callback($matches) 650 { 651 // chunk_split string and avoid lines breaking multibyte characters 652 $c = 71; 653 $out = substr($matches[1], 0, $c); 654 655 for ($n = $c; $c < strlen($matches[1]); $c++) { 656 // break if length > 75 or multibyte character starts after position 71 657 if ($n > 75 || ($n > 71 && ord($matches[1][$c]) >> 6 == 3)) { 658 $out .= "\r\n "; 659 $n = 0; 660 } 661 662 $out .= $matches[1][$c]; 663 $n++; 664 } 665 666 return $out; 667 } 668 669 /** 670 * Apply RFC2425 folding to a vCard content 671 * 672 * @param string $val vCard content 673 * 674 * @return string Folded vCard string 675 */ 676 public static function rfc2425_fold($val) 677 { 678 return preg_replace_callback('/([^\n]{72,})/', ['rcube_vcard', 'rfc2425_fold_callback'], $val); 679 } 680 681 /** 682 * Decodes a vcard block (vcard 3.0 format, unfolded) into an array structure 683 * 684 * @param string $vcard vCard block to parse 685 * 686 * @return array Raw data structure 687 */ 688 private static function vcard_decode($vcard) 689 { 690 // Perform RFC2425 line unfolding and split lines 691 $vcard = preg_replace(["/\r/", "/\n\s+/"], '', $vcard); 692 $lines = explode("\n", $vcard); 693 $result = []; 694 695 for ($i=0; $i < count($lines); $i++) { 696 if (!($pos = strpos($lines[$i], ':'))) { 697 continue; 698 } 699 700 $prefix = substr($lines[$i], 0, $pos); 701 $data = substr($lines[$i], $pos+1); 702 703 if (preg_match('/^(BEGIN|END)$/i', $prefix)) { 704 continue; 705 } 706 707 // convert 2.1-style "EMAIL;internet;home:" to 3.0-style "EMAIL;TYPE=internet;TYPE=home:" 708 if ( 709 !empty($result['VERSION']) 710 && $result['VERSION'][0] == "2.1" 711 && preg_match('/^([^;]+);([^:]+)/', $prefix, $regs2) 712 && !preg_match('/^TYPE=/i', $regs2[2]) 713 ) { 714 $prefix = $regs2[1]; 715 foreach (explode(';', $regs2[2]) as $prop) { 716 $prefix .= ';' . (strpos($prop, '=') ? $prop : 'TYPE='.$prop); 717 } 718 } 719 720 if (preg_match_all('/([^\\;]+);?/', $prefix, $regs2)) { 721 $entry = []; 722 $field = strtoupper($regs2[1][0]); 723 $enc = null; 724 725 foreach ($regs2[1] as $attrid => $attr) { 726 $attr = preg_replace('/[\s\t\n\r\0\x0B]/', '', $attr); 727 728 if ((@list($key, $value) = explode('=', $attr)) && $value) { 729 if ($key == 'ENCODING') { 730 $value = strtoupper($value); 731 // add next line(s) to value string if QP line end detected 732 if ($value == 'QUOTED-PRINTABLE') { 733 while (preg_match('/=$/', $lines[$i])) { 734 $data .= "\n" . $lines[++$i]; 735 } 736 } 737 $enc = $value == 'BASE64' ? 'B' : $value; 738 } 739 else { 740 $lc_key = strtolower($key); 741 $value = (array) self::vcard_unquote($value, ','); 742 743 if (array_key_exists($lc_key, $entry)) { 744 $entry[$lc_key] = array_merge((array) $entry[$lc_key], $value); 745 } 746 else { 747 $entry[$lc_key] = $value; 748 } 749 } 750 } 751 else if ($attrid > 0) { 752 $entry[strtolower($key)] = true; // true means attr without =value 753 } 754 } 755 756 // decode value 757 if ($enc || !empty($entry['base64'])) { 758 // save encoding type (#1488432) 759 if ($enc == 'B') { 760 $entry['encoding'] = 'B'; 761 // should we use vCard 3.0 instead? 762 // $entry['base64'] = true; 763 } 764 765 $data = self::decode_value($data, $enc ?: 'base64'); 766 } 767 else if ($field == 'PHOTO') { 768 // vCard 4.0 data URI, "PHOTO:data:image/jpeg;base64,..." 769 if (preg_match('/^data:[a-z\/_-]+;base64,/i', $data, $m)) { 770 $entry['encoding'] = $enc = 'B'; 771 $data = substr($data, strlen($m[0])); 772 $data = self::decode_value($data, 'base64'); 773 } 774 } 775 776 if ($enc != 'B' && empty($entry['base64'])) { 777 $data = self::vcard_unquote($data); 778 } 779 780 if (is_array($data) || (is_string($data) && strlen($data))) { 781 $entry = array_merge($entry, (array) $data); 782 $result[$field][] = $entry; 783 } 784 } 785 } 786 787 unset($result['VERSION']); 788 789 return $result; 790 } 791 792 /** 793 * Decode a given string with the encoding rule from ENCODING attributes 794 * 795 * @param string $value String to decode 796 * @param string $encoding Encoding type (quoted-printable and base64 supported) 797 * 798 * @return string Decoded 8bit value 799 */ 800 private static function decode_value($value, $encoding) 801 { 802 switch (strtolower($encoding)) { 803 case 'quoted-printable': 804 self::$values_decoded = true; 805 return quoted_printable_decode($value); 806 807 case 'base64': 808 case 'b': 809 self::$values_decoded = true; 810 return base64_decode($value); 811 812 default: 813 return $value; 814 } 815 } 816 817 /** 818 * Encodes an entry for storage in our database (vcard 3.0 format, unfolded) 819 * 820 * @param array $data Raw data structure to encode 821 * 822 * @return string vCard encoded string 823 */ 824 static function vcard_encode($data) 825 { 826 $vcard = ''; 827 828 foreach ((array)$data as $type => $entries) { 829 // valid N has 5 properties 830 while ($type == "N" && is_array($entries[0]) && count($entries[0]) < 5) { 831 $entries[0][] = ""; 832 } 833 834 // make sure FN is not empty (required by RFC2426) 835 if ($type == "FN" && empty($entries) && !empty($data['EMAIL'][0][0])) { 836 $entries[0] = $data['EMAIL'][0][0]; 837 } 838 839 foreach ((array)$entries as $entry) { 840 $attr = ''; 841 if (is_array($entry)) { 842 $value = []; 843 foreach ($entry as $attrname => $attrvalues) { 844 if (is_int($attrname)) { 845 if (!empty($entry['base64']) || (!empty($entry['encoding']) && $entry['encoding'] == 'B')) { 846 $attrvalues = base64_encode($attrvalues); 847 } 848 $value[] = $attrvalues; 849 } 850 else if (is_bool($attrvalues)) { 851 // true means just a tag, not tag=value, as in PHOTO;BASE64:... 852 if ($attrvalues) { 853 // vCard v3 uses ENCODING=b (#1489183) 854 if ($attrname == 'base64') { 855 $attr .= ";ENCODING=b"; 856 } 857 else { 858 $attr .= strtoupper(";$attrname"); 859 } 860 } 861 } 862 else { 863 foreach ((array)$attrvalues as $attrvalue) { 864 $attr .= strtoupper(";$attrname=") . self::vcard_quote($attrvalue, ','); 865 } 866 } 867 } 868 } 869 else { 870 $value = $entry; 871 } 872 873 // skip empty entries 874 if (self::is_empty($value)) { 875 continue; 876 } 877 878 $vcard .= self::vcard_quote($type) . $attr . ':' . self::vcard_quote($value) . self::$eol; 879 } 880 } 881 882 return 'BEGIN:VCARD' . self::$eol . 'VERSION:3.0' . self::$eol . $vcard . 'END:VCARD'; 883 } 884 885 /** 886 * Join indexed data array to a vcard quoted string 887 * 888 * @param array $str Field data 889 * @param string $sep Separator 890 * 891 * @return string Joined and quoted string 892 */ 893 public static function vcard_quote($str, $sep = ';') 894 { 895 if (is_array($str)) { 896 $r = []; 897 898 foreach ($str as $part) { 899 $r[] = self::vcard_quote($part, $sep); 900 } 901 902 return(implode($sep, $r)); 903 } 904 905 return strtr($str, ["\\" => "\\\\", "\r" => '', "\n" => '\n', $sep => "\\$sep"]); 906 } 907 908 /** 909 * Split quoted string 910 * 911 * @param string $str vCard string to split 912 * @param string $sep Separator char/string 913 * 914 * @return string|array Unquoted string or a list of strings if $sep was found 915 */ 916 private static function vcard_unquote($str, $sep = ';') 917 { 918 // break string into parts separated by $sep 919 if (!empty($sep)) { 920 // Handle properly backslash escaping (#1488896) 921 $rep1 = ["\\\\" => "\010", "\\$sep" => "\007"]; 922 $rep2 = ["\007" => "\\$sep", "\010" => "\\\\"]; 923 924 if (count($parts = explode($sep, strtr($str, $rep1))) > 1) { 925 $result = []; 926 foreach ($parts as $s) { 927 $result[] = self::vcard_unquote(strtr($s, $rep2)); 928 } 929 930 return $result; 931 } 932 933 $str = trim(strtr($str, $rep2)); 934 } 935 936 // some implementations (GMail) use non-standard backslash before colon (#1489085) 937 // we will handle properly any backslashed character - removing dummy backslashes 938 // return strtr($str, ["\r" => '', '\\\\' => '\\', '\n' => "\n", '\N' => "\n", '\,' => ',', '\;' => ';']); 939 940 $str = str_replace("\r", '', $str); 941 $pos = 0; 942 943 while (($pos = strpos($str, "\\", $pos)) !== false) { 944 $next = substr($str, $pos + 1, 1); 945 if ($next == 'n' || $next == 'N') { 946 $str = substr_replace($str, "\n", $pos, 2); 947 } 948 else { 949 $str = substr_replace($str, '', $pos, 1); 950 } 951 952 $pos += 1; 953 } 954 955 return $str; 956 } 957 958 /** 959 * Check if vCard entry is empty: empty string or an array with 960 * all entries empty. 961 * 962 * @param string|array $value Attribute value 963 * 964 * @return bool True if the value is empty, False otherwise 965 */ 966 private static function is_empty($value) 967 { 968 foreach ((array) $value as $v) { 969 if (@strval($v) !== '') { 970 return false; 971 } 972 } 973 974 return true; 975 } 976 977 /** 978 * Returns UNICODE type based on BOM (Byte Order Mark) 979 * 980 * @param string $string Input string to test 981 * 982 * @return string Detected encoding 983 */ 984 private static function detect_encoding($string) 985 { 986 $fallback = rcube::get_instance()->config->get('default_charset', 'ISO-8859-1'); // fallback to Latin-1 987 988 return rcube_charset::detect($string, $fallback); 989 } 990} 991