Aleksander Machniak
2016-05-16 0b7e26c1bf6bc7a684eb3a214d92d3927306cd8a
commit | author | age
d1d2c4 1 <?php
041c93 2
a95874 3 /**
d1d2c4 4  +-----------------------------------------------------------------------+
e019f2 5  | This file is part of the Roundcube Webmail client                     |
203323 6  | Copyright (C) 2006-2013, The Roundcube Dev Team                       |
TB 7  | Copyright (C) 2011-2013, Kolab Systems AG                             |
7fe381 8  |                                                                       |
T 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.                     |
d1d2c4 12  |                                                                       |
S 13  | PURPOSE:                                                              |
f11541 14  |   Interface to an LDAP address directory                              |
d1d2c4 15  +-----------------------------------------------------------------------+
f11541 16  | Author: Thomas Bruederli <roundcube@gmail.com>                        |
6039aa 17  |         Andreas Dick <andudi (at) gmx (dot) ch>                       |
c3ba0e 18  |         Aleksander Machniak <machniak@kolabsys.com>                   |
d1d2c4 19  +-----------------------------------------------------------------------+
S 20 */
6d969b 21
T 22 /**
23  * Model class to access an LDAP address directory
24  *
9ab346 25  * @package    Framework
AM 26  * @subpackage Addressbook
6d969b 27  */
cc97ea 28 class rcube_ldap extends rcube_addressbook
f11541 29 {
3e7b9b 30     // public properties
a97937 31     public $primary_key = 'ID';
4feb8e 32     public $groups      = false;
AM 33     public $readonly    = true;
34     public $ready       = false;
35     public $group_id    = 0;
36     public $coltypes    = array();
79367a 37     public $export_groups = false;
1148c6 38
3e7b9b 39     // private properties
203323 40     protected $ldap;
c8c4ca 41     protected $formats  = array();
4feb8e 42     protected $prop     = array();
a97937 43     protected $fieldmap = array();
4feb8e 44     protected $filter   = '';
13db9e 45     protected $sub_filter;
4feb8e 46     protected $result;
AM 47     protected $ldap_result;
a97937 48     protected $mail_domain = '';
c8c4ca 49     protected $debug       = false;
3e7b9b 50
AM 51     /**
52      * Group objectclass (lowercase) to member attribute mapping
53      *
54      * @var array
55      */
3ce7c5 56     private $group_types = array(
3e7b9b 57         'group'                   => 'member',
AM 58         'groupofnames'            => 'member',
59         'kolabgroupofnames'       => 'member',
60         'groupofuniquenames'      => 'uniqueMember',
61         'kolabgroupofuniquenames' => 'uniqueMember',
62         'univentiongroup'         => 'uniqueMember',
63         'groupofurls'             => null,
64     );
6039aa 65
4feb8e 66     private $base_dn        = '';
d1e08f 67     private $groups_base_dn = '';
834fb6 68     private $group_data;
TB 69     private $group_search_cache;
8fb04b 70     private $cache;
1148c6 71
A 72
a97937 73     /**
T 74     * Object constructor
75     *
c64bee 76     * @param array   $p            LDAP connection properties
0c2596 77     * @param boolean $debug        Enables debug mode
A 78     * @param string  $mail_domain  Current user mail domain name
a97937 79     */
0c2596 80     function __construct($p, $debug = false, $mail_domain = null)
f11541 81     {
a97937 82         $this->prop = $p;
b1f084 83
49cb69 84         $fetch_attributes = array('objectClass');
010274 85
a97937 86         // check if groups are configured
f763fb 87         if (is_array($p['groups']) && count($p['groups'])) {
a97937 88             $this->groups = true;
f763fb 89             // set member field
A 90             if (!empty($p['groups']['member_attr']))
015dec 91                 $this->prop['member_attr'] = strtolower($p['groups']['member_attr']);
f763fb 92             else if (empty($p['member_attr']))
A 93                 $this->prop['member_attr'] = 'member';
448f81 94             // set default name attribute to cn
T 95             if (empty($this->prop['groups']['name_attr']))
96                 $this->prop['groups']['name_attr'] = 'cn';
fb6cc8 97             if (empty($this->prop['groups']['scope']))
T 98                 $this->prop['groups']['scope'] = 'sub';
3ce7c5 99             // extend group objectclass => member attribute mapping
a2cf7c 100             if (!empty($this->prop['groups']['class_member_attr']))
TB 101                 $this->group_types = array_merge($this->group_types, $this->prop['groups']['class_member_attr']);
ec2185 102
49cb69 103             // add group name attrib to the list of attributes to be fetched
TB 104             $fetch_attributes[] = $this->prop['groups']['name_attr'];
ec2185 105         }
834fb6 106         if (is_array($p['group_filters'])) {
TB 107             $this->groups = $this->groups || count($p['group_filters']);
8862f6 108
9eaf68 109             foreach ($p['group_filters'] as $k => $group_filter) {
TB 110                 // set default name attribute to cn
111                 if (empty($group_filter['name_attr']) && empty($this->prop['groups']['name_attr']))
112                     $this->prop['group_filters'][$k]['name_attr'] = $group_filter['name_attr'] = 'cn';
113
8862f6 114                 if ($group_filter['name_attr'])
TB 115                     $fetch_attributes[] = $group_filter['name_attr'];
116             }
f763fb 117         }
cd6749 118
a97937 119         // fieldmap property is given
T 120         if (is_array($p['fieldmap'])) {
bf99c5 121             $p['fieldmap'] = array_filter($p['fieldmap']);
a97937 122             foreach ($p['fieldmap'] as $rf => $lf)
c8c4ca 123                 $this->fieldmap[$rf] = $this->_attr_name($lf);
a97937 124         }
38dc51 125         else if (!empty($p)) {
a97937 126             // read deprecated *_field properties to remain backwards compatible
T 127             foreach ($p as $prop => $value)
bf99c5 128                 if (!empty($value) && preg_match('/^(.+)_field$/', $prop, $matches))
c8c4ca 129                     $this->fieldmap[$matches[1]] = $this->_attr_name($value);
b9f9f1 130         }
4f9c83 131
a97937 132         // use fieldmap to advertise supported coltypes to the application
a605b2 133         foreach ($this->fieldmap as $colv => $lfv) {
T 134             list($col, $type) = explode(':', $colv);
c8c4ca 135             $params           = explode(':', $lfv);
a605b2 136
c8c4ca 137             $lf    = array_shift($params);
AM 138             $limit = 1;
139
140             foreach ($params as $idx => $param) {
141                 // field format specification
142                 if (preg_match('/^(date)\[(.+)\]$/i', $param, $m)) {
143                     $this->formats[$lf] = array('type' => strtolower($m[1]), 'format' => $m[2]);
144                 }
145                 // first argument is a limit
146                 else if ($idx === 0) {
147                     if ($param == '*') $limit = null;
148                     else               $limit = max(1, intval($param));
149                 }
150                 // second is a composite field separator
151                 else if ($idx === 1 && $param) {
152                     $this->coltypes[$col]['serialized'][$type] = $param;
153                 }
154             }
a605b2 155
a97937 156             if (!is_array($this->coltypes[$col])) {
T 157                 $subtypes = $type ? array($type) : null;
1078a6 158                 $this->coltypes[$col] = array('limit' => $limit, 'subtypes' => $subtypes, 'attributes' => array($lf));
1148c6 159             }
a97937 160             elseif ($type) {
T 161                 $this->coltypes[$col]['subtypes'][] = $type;
1078a6 162                 $this->coltypes[$col]['attributes'][] = $lf;
a605b2 163                 $this->coltypes[$col]['limit'] += $limit;
a97937 164             }
a605b2 165
c8c4ca 166             $this->fieldmap[$colv] = $lf;
1148c6 167         }
f76765 168
1937f4 169         // support for composite address
1078a6 170         if ($this->coltypes['street'] && $this->coltypes['locality']) {
a605b2 171             $this->coltypes['address'] = array(
T 172                'limit'    => max(1, $this->coltypes['locality']['limit'] + $this->coltypes['address']['limit']),
5b0b03 173                'subtypes' => array_merge((array)$this->coltypes['address']['subtypes'], (array)$this->coltypes['locality']['subtypes']),
a605b2 174                'childs' => array(),
T 175                ) + (array)$this->coltypes['address'];
176
3ac5cd 177             foreach (array('street','locality','zipcode','region','country') as $childcol) {
1078a6 178                 if ($this->coltypes[$childcol]) {
3ac5cd 179                     $this->coltypes['address']['childs'][$childcol] = array('type' => 'text');
T 180                     unset($this->coltypes[$childcol]);  // remove address child col from global coltypes list
181                 }
182             }
fb001f 183
AM 184             // at least one address type must be specified
185             if (empty($this->coltypes['address']['subtypes'])) {
186                 $this->coltypes['address']['subtypes'] = array('home');
187             }
1937f4 188         }
19fccd 189         else if ($this->coltypes['address']) {
24f1bf 190             $this->coltypes['address'] += array('type' => 'textarea', 'childs' => null, 'size' => 40);
T 191
192             // 'serialized' means the UI has to present a composite address field
193             if ($this->coltypes['address']['serialized']) {
194                 $childprop = array('type' => 'text');
195                 $this->coltypes['address']['type'] = 'composite';
196                 $this->coltypes['address']['childs'] = array('street' => $childprop, 'locality' => $childprop, 'zipcode' => $childprop, 'country' => $childprop);
197             }
19fccd 198         }
4f9c83 199
a97937 200         // make sure 'required_fields' is an array
19fccd 201         if (!is_array($this->prop['required_fields'])) {
a97937 202             $this->prop['required_fields'] = (array) $this->prop['required_fields'];
19fccd 203         }
4f9c83 204
19fccd 205         // make sure LDAP_rdn field is required
540e13 206         if (!empty($this->prop['LDAP_rdn']) && !in_array($this->prop['LDAP_rdn'], $this->prop['required_fields'])
AM 207             && !in_array($this->prop['LDAP_rdn'], array_keys((array)$this->prop['autovalues']))) {
19fccd 208             $this->prop['required_fields'][] = $this->prop['LDAP_rdn'];
A 209         }
210
211         foreach ($this->prop['required_fields'] as $key => $val) {
c8c4ca 212             $this->prop['required_fields'][$key] = $this->_attr_name($val);
19fccd 213         }
13db9e 214
A 215         // Build sub_fields filter
216         if (!empty($this->prop['sub_fields']) && is_array($this->prop['sub_fields'])) {
217             $this->sub_filter = '';
3725cf 218             foreach ($this->prop['sub_fields'] as $class) {
13db9e 219                 if (!empty($class)) {
A 220                     $class = is_array($class) ? array_pop($class) : $class;
221                     $this->sub_filter .= '(objectClass=' . $class . ')';
222                 }
223             }
224             if (count($this->prop['sub_fields']) > 1) {
225                 $this->sub_filter = '(|' . $this->sub_filter . ')';
226             }
227         }
f11541 228
f763fb 229         $this->sort_col    = is_array($p['sort']) ? $p['sort'][0] : $p['sort'];
A 230         $this->debug       = $debug;
a97937 231         $this->mail_domain = $mail_domain;
8fb04b 232
T 233         // initialize cache
b20025 234         $rcube = rcube::get_instance();
AM 235         if ($cache_type = $rcube->config->get('ldap_cache', 'db')) {
236             $cache_ttl  = $rcube->config->get('ldap_cache_ttl', '10m');
237             $cache_name = 'LDAP.' . asciiwords($this->prop['name']);
b07426 238
b20025 239             $this->cache = $rcube->get_cache($cache_name, $cache_type, $cache_ttl);
AM 240         }
f11541 241
807c3d 242         // determine which attributes to fetch
8862f6 243         $this->prop['list_attributes'] = array_unique($fetch_attributes);
f924f5 244         $this->prop['attributes'] = array_merge(array_values($this->fieldmap), $fetch_attributes);
807c3d 245         foreach ($rcube->config->get('contactlist_fields') as $col) {
TB 246             $this->prop['list_attributes'] = array_merge($this->prop['list_attributes'], $this->_map_field($col));
247         }
248
249         // initialize ldap wrapper object
fae90d 250         $this->ldap = new rcube_ldap_generic($this->prop);
6ac939 251         $this->ldap->config_set(array('cache' => $this->cache, 'debug' => $this->debug));
203323 252
a97937 253         $this->_connect();
736307 254     }
010274 255
a97937 256     /**
a95874 257      * Establish a connection to the LDAP server
AM 258      */
a97937 259     private function _connect()
6b603d 260     {
be98df 261         $rcube = rcube::get_instance();
f11541 262
203323 263         if ($this->ready)
a97937 264             return true;
f11541 265
a97937 266         if (!is_array($this->prop['hosts']))
T 267             $this->prop['hosts'] = array($this->prop['hosts']);
f11541 268
e114a6 269         // try to connect + bind for every host configured
TB 270         // with OpenLDAP 2.x ldap_connect() always succeeds but ldap_bind will fail if host isn't reachable
271         // see http://www.php.net/manual/en/function.ldap-connect.php
272         foreach ($this->prop['hosts'] as $host) {
203323 273             // skip host if connection failed
TB 274             if (!$this->ldap->connect($host)) {
e114a6 275                 continue;
TB 276             }
277
278             // See if the directory is writeable.
279             if ($this->prop['writable']) {
280                 $this->readonly = false;
281             }
282
283             $bind_pass = $this->prop['bind_pass'];
284             $bind_user = $this->prop['bind_user'];
285             $bind_dn   = $this->prop['bind_dn'];
286
287             $this->base_dn        = $this->prop['base_dn'];
7e3298 288             $this->groups_base_dn = $this->prop['groups']['base_dn'] ?: $this->base_dn;
e114a6 289
TB 290             // User specific access, generate the proper values to use.
291             if ($this->prop['user_specific']) {
292                 // No password set, use the session password
293                 if (empty($bind_pass)) {
294                     $bind_pass = $rcube->get_user_password();
295                 }
296
297                 // Get the pieces needed for variable replacement.
0f6341 298                 if ($fu = $rcube->get_user_email()) {
e114a6 299                     list($u, $d) = explode('@', $fu);
0f6341 300                 }
TB 301                 else {
e114a6 302                     $d = $this->mail_domain;
0f6341 303                 }
e114a6 304
TB 305                 $dc = 'dc='.strtr($d, array('.' => ',dc=')); // hierarchal domain string
306
0f6341 307                 // resolve $dc through LDAP
TB 308                 if (!empty($this->prop['domain_filter']) && !empty($this->prop['search_bind_dn']) &&
309                         method_exists($this->ldap, 'domain_root_dn')) {
310                     $this->ldap->bind($this->prop['search_bind_dn'], $this->prop['search_bind_pw']);
311                     $dc = $this->ldap->domain_root_dn($d);
312                 }
313
e114a6 314                 $replaces = array('%dn' => '', '%dc' => $dc, '%d' => $d, '%fu' => $fu, '%u' => $u);
TB 315
5a6c3a 316                 // Search for the dn to use to authenticate
e426ae 317                 if ($this->prop['search_base_dn'] && $this->prop['search_filter']
AM 318                     && (strstr($bind_dn, '%dn') || strstr($this->base_dn, '%dn') || strstr($this->groups_base_dn, '%dn'))
319                 ) {
3ce7c5 320                     $search_attribs = array('uid');
TB 321                      if ($search_bind_attrib = (array)$this->prop['search_bind_attrib']) {
322                          foreach ($search_bind_attrib as $r => $attr) {
323                              $search_attribs[] = $attr;
324                              $replaces[$r] = '';
325                          }
326                      }
327
5a6c3a 328                     $search_bind_dn = strtr($this->prop['search_bind_dn'], $replaces);
AM 329                     $search_base_dn = strtr($this->prop['search_base_dn'], $replaces);
330                     $search_filter  = strtr($this->prop['search_filter'], $replaces);
e114a6 331
5a6c3a 332                     $cache_key = 'DN.' . md5("$host:$search_bind_dn:$search_base_dn:$search_filter:"
AM 333                         .$this->prop['search_bind_pw']);
e114a6 334
b20025 335                     if ($this->cache && ($dn = $this->cache->get($cache_key))) {
5a6c3a 336                         $replaces['%dn'] = $dn;
e114a6 337                     }
TB 338                     else {
5a6c3a 339                         $ldap = $this->ldap;
AM 340                         if (!empty($search_bind_dn) && !empty($this->prop['search_bind_pw'])) {
341                             // To protect from "Critical extension is unavailable" error
342                             // we need to use a separate LDAP connection
343                             if (!empty($this->prop['vlv'])) {
344                                 $ldap = new rcube_ldap_generic($this->prop);
6ac939 345                                 $ldap->config_set(array('cache' => $this->cache, 'debug' => $this->debug));
5a6c3a 346                                 if (!$ldap->connect($host)) {
AM 347                                     continue;
348                                 }
349                             }
350
351                             if (!$ldap->bind($search_bind_dn, $this->prop['search_bind_pw'])) {
352                                 continue;  // bind failed, try next host
353                             }
354                         }
355
3ce7c5 356                         $res = $ldap->search($search_base_dn, $search_filter, 'sub', $search_attribs);
5a6c3a 357                         if ($res) {
AM 358                             $res->rewind();
8ee8be 359                             $replaces['%dn'] = key($res->entries(TRUE));
3ce7c5 360
TB 361                             // add more replacements from 'search_bind_attrib' config
362                             if ($search_bind_attrib) {
363                                 $res = $res->current();
364                                 foreach ($search_bind_attrib as $r => $attr) {
365                                     $replaces[$r] = $res[$attr][0];
366                                 }
367                             }
5a6c3a 368                         }
AM 369
370                         if ($ldap != $this->ldap) {
371                             $ldap->close();
372                         }
e114a6 373                     }
TB 374
375                     // DN not found
376                     if (empty($replaces['%dn'])) {
377                         if (!empty($this->prop['search_dn_default']))
378                             $replaces['%dn'] = $this->prop['search_dn_default'];
379                         else {
380                             rcube::raise_error(array(
381                                 'code' => 100, 'type' => 'ldap',
382                                 'file' => __FILE__, 'line' => __LINE__,
383                                 'message' => "DN not found using LDAP search."), true);
5a6c3a 384                             continue;
e114a6 385                         }
TB 386                     }
5a6c3a 387
b20025 388                     if ($this->cache && !empty($replaces['%dn'])) {
5a6c3a 389                         $this->cache->set($cache_key, $replaces['%dn']);
AM 390                     }
e114a6 391                 }
TB 392
393                 // Replace the bind_dn and base_dn variables.
394                 $bind_dn              = strtr($bind_dn, $replaces);
395                 $this->base_dn        = strtr($this->base_dn, $replaces);
396                 $this->groups_base_dn = strtr($this->groups_base_dn, $replaces);
3ce7c5 397
TB 398                 // replace placeholders in filter settings
399                 if (!empty($this->prop['filter']))
400                     $this->prop['filter'] = strtr($this->prop['filter'], $replaces);
1e9a59 401
TB 402                 foreach (array('base_dn','filter','member_filter') as $k) {
403                     if (!empty($this->prop['groups'][$k]))
404                         $this->prop['groups'][$k] = strtr($this->prop['groups'][$k], $replaces);
405                 }
3ce7c5 406
834fb6 407                 if (is_array($this->prop['group_filters'])) {
3ce7c5 408                     foreach ($this->prop['group_filters'] as $i => $gf) {
TB 409                         if (!empty($gf['base_dn']))
410                             $this->prop['group_filters'][$i]['base_dn'] = strtr($gf['base_dn'], $replaces);
411                         if (!empty($gf['filter']))
412                             $this->prop['group_filters'][$i]['filter'] = strtr($gf['filter'], $replaces);
413                     }
414                 }
e114a6 415
TB 416                 if (empty($bind_user)) {
417                     $bind_user = $u;
418                 }
419             }
420
421             if (empty($bind_pass)) {
422                 $this->ready = true;
423             }
424             else {
425                 if (!empty($bind_dn)) {
203323 426                     $this->ready = $this->ldap->bind($bind_dn, $bind_pass);
e114a6 427                 }
TB 428                 else if (!empty($this->prop['auth_cid'])) {
203323 429                     $this->ready = $this->ldap->sasl_bind($this->prop['auth_cid'], $bind_pass, $bind_user);
e114a6 430                 }
TB 431                 else {
203323 432                     $this->ready = $this->ldap->sasl_bind($bind_user, $bind_pass);
e114a6 433                 }
TB 434             }
435
436             // connection established, we're done here
437             if ($this->ready) {
a97937 438                 break;
T 439             }
440
e114a6 441         }  // end foreach hosts
c041d5 442
203323 443         if (!is_resource($this->ldap->conn)) {
0c2596 444             rcube::raise_error(array('code' => 100, 'type' => 'ldap',
c041d5 445                 'file' => __FILE__, 'line' => __LINE__,
203323 446                 'message' => "Could not connect to any LDAP server, last tried $host"), true);
c041d5 447
A 448             return false;
449         }
450
451         return $this->ready;
a97937 452     }
T 453
454     /**
cc90ed 455      * Close connection to LDAP server
A 456      */
a97937 457     function close()
T 458     {
203323 459         if ($this->ldap) {
TB 460             $this->ldap->close();
a97937 461         }
T 462     }
463
464     /**
cc90ed 465      * Returns address book name
A 466      *
467      * @return string Address book name
468      */
469     function get_name()
470     {
471         return $this->prop['name'];
203323 472     }
TB 473
474     /**
475      * Set internal list page
476      *
477      * @param  number  Page number to list
478      */
479     function set_page($page)
480     {
481         $this->list_page = (int)$page;
482         $this->ldap->set_vlv_page($this->list_page, $this->page_size);
483     }
484
485     /**
486      * Set internal page size
487      *
488      * @param  number  Number of records to display on one page
489      */
490     function set_pagesize($size)
491     {
492         $this->page_size = (int)$size;
493         $this->ldap->set_vlv_page($this->list_page, $this->page_size);
cc90ed 494     }
A 495
496     /**
438753 497      * Set internal sort settings
cc90ed 498      *
438753 499      * @param string $sort_col Sort column
T 500      * @param string $sort_order Sort order
cc90ed 501      */
438753 502     function set_sort_order($sort_col, $sort_order = null)
a97937 503     {
1078a6 504         if ($this->coltypes[$sort_col]['attributes'])
TB 505             $this->sort_col = $this->coltypes[$sort_col]['attributes'][0];
a97937 506     }
T 507
508     /**
cc90ed 509      * Save a search string for future listings
A 510      *
511      * @param string $filter Filter string
512      */
a97937 513     function set_search_set($filter)
T 514     {
515         $this->filter = $filter;
516     }
517
518     /**
cc90ed 519      * Getter for saved search properties
A 520      *
521      * @return mixed Search properties used by this class
522      */
a97937 523     function get_search_set()
T 524     {
525         return $this->filter;
526     }
527
528     /**
cc90ed 529      * Reset all saved results and search parameters
A 530      */
a97937 531     function reset()
T 532     {
533         $this->result = null;
534         $this->ldap_result = null;
535         $this->filter = '';
536     }
537
538     /**
cc90ed 539      * List the current set of contact records
A 540      *
a95874 541      * @param array List of cols to show
AM 542      * @param int   Only return this number of records
cc90ed 543      *
a95874 544      * @return array Indexed list of contact records, each a hash array
cc90ed 545      */
a97937 546     function list_records($cols=null, $subset=0)
T 547     {
203323 548         if ($this->prop['searchonly'] && empty($this->filter) && !$this->group_id) {
2d3e2b 549             $this->result = new rcube_result_set(0);
T 550             $this->result->searchonly = true;
551             return $this->result;
552         }
b1f084 553
a31482 554         // fetch group members recursively
203323 555         if ($this->group_id && $this->group_data['dn']) {
a31482 556             $entries = $this->list_group_members($this->group_data['dn']);
T 557
558             // make list of entries unique and sort it
559             $seen = array();
560             foreach ($entries as $i => $rec) {
561                 if ($seen[$rec['dn']]++)
562                     unset($entries[$i]);
563             }
564             usort($entries, array($this, '_entry_sort_cmp'));
565
566             $entries['count'] = count($entries);
567             $this->result = new rcube_result_set($entries['count'], ($this->list_page-1) * $this->page_size);
568         }
203323 569         else {
4287c9 570             $prop    = $this->group_id ? $this->group_data : $this->prop;
8fc49e 571             $base_dn = $this->group_id ? $prop['base_dn'] : $this->base_dn;
ec2185 572
203323 573             // use global search filter
TB 574             if (!empty($this->filter))
575                 $prop['filter'] = $this->filter;
a31482 576
T 577             // exec LDAP search if no result resource is stored
203323 578             if ($this->ready && !$this->ldap_result)
4287c9 579                 $this->ldap_result = $this->ldap->search($base_dn, $prop['filter'], $prop['scope'], $this->prop['attributes'], $prop);
a31482 580
T 581             // count contacts for this user
582             $this->result = $this->count();
583
584             // we have a search result resource
203323 585             if ($this->ldap_result && $this->result->count > 0) {
a31482 586                 // sorting still on the ldap server
203323 587                 if ($this->sort_col && $prop['scope'] !== 'base' && !$this->ldap->vlv_active)
TB 588                     $this->ldap_result->sort($this->sort_col);
a31482 589
T 590                 // get all entries from the ldap server
203323 591                 $entries = $this->ldap_result->entries();
a31482 592             }
T 593
594         }  // end else
595
596         // start and end of the page
203323 597         $start_row = $this->ldap->vlv_active ? 0 : $this->result->first;
a31482 598         $start_row = $subset < 0 ? $start_row + $this->page_size + $subset : $start_row;
T 599         $last_row = $this->result->first + $this->page_size;
600         $last_row = $subset != 0 ? $start_row + abs($subset) : $last_row;
601
602         // filter entries for this page
603         for ($i = $start_row; $i < min($entries['count'], $last_row); $i++)
604             $this->result->add($this->_ldap2result($entries[$i]));
605
606         return $this->result;
607     }
608
609     /**
9b3311 610      * Get all members of the given group
A 611      *
3ce7c5 612      * @param string  Group DN
TB 613      * @param boolean Count only
614      * @param array   Group entries (if called recursively)
615      * @return array  Accumulated group members
a31482 616      */
b35a0f 617     function list_group_members($dn, $count = false, $entries = null)
a31482 618     {
T 619         $group_members = array();
620
621         // fetch group object
622         if (empty($entries)) {
3ce7c5 623             $attribs = array_merge(array('dn','objectClass','memberURL'), array_values($this->group_types));
1b52cf 624             $entries = $this->ldap->read_entries($dn, '(objectClass=*)', $attribs);
203323 625             if ($entries === false) {
a31482 626                 return $group_members;
T 627             }
628         }
629
203323 630         for ($i=0; $i < $entries['count']; $i++) {
a31482 631             $entry = $entries[$i];
1b52cf 632             $attrs = array();
a31482 633
203323 634             foreach ((array)$entry['objectclass'] as $objectclass) {
3ce7c5 635                 if (($member_attr = $this->get_group_member_attr(array($objectclass), ''))
1b52cf 636                     && ($member_attr = strtolower($member_attr)) && !in_array($member_attr, $attrs)
AM 637                 ) {
638                     $members       = $this->_list_group_members($dn, $entry, $member_attr, $count);
639                     $group_members = array_merge($group_members, $members);
640                     $attrs[]       = $member_attr;
3ce7c5 641                 }
TB 642                 else if (!empty($entry['memberurl'])) {
643                     $members       = $this->_list_group_memberurl($dn, $entry, $count);
644                     $group_members = array_merge($group_members, $members);
1b52cf 645                 }
AM 646
647                 if ($this->prop['sizelimit'] && count($group_members) > $this->prop['sizelimit']) {
648                     break 2;
a31482 649                 }
T 650             }
651         }
652
653         return array_filter($group_members);
654     }
655
656     /**
657      * Fetch members of the given group entry from server
658      *
659      * @param string Group DN
660      * @param array  Group entry
661      * @param string Member attribute to use
3ce7c5 662      * @param boolean Count only
a31482 663      * @return array Accumulated group members
T 664      */
b35a0f 665     private function _list_group_members($dn, $entry, $attr, $count)
a31482 666     {
T 667         // Use the member attributes to return an array of member ldap objects
668         // NOTE that the member attribute is supposed to contain a DN
669         $group_members = array();
1b52cf 670         if (empty($entry[$attr])) {
a31482 671             return $group_members;
1b52cf 672         }
a31482 673
b35a0f 674         // read these attributes for all members
807c3d 675         $attrib = $count ? array('dn','objectClass') : $this->prop['list_attributes'];
3ce7c5 676         $attrib = array_merge($attrib, array_values($this->group_types));
b35a0f 677         $attrib[] = 'memberURL';
T 678
7e3298 679         $filter = $this->prop['groups']['member_filter'] ?: '(objectclass=*)';
c5a5f9 680
203323 681         for ($i=0; $i < $entry[$attr]['count']; $i++) {
3ed9e8 682             if (empty($entry[$attr][$i]))
T 683                 continue;
684
203323 685             $members = $this->ldap->read_entries($entry[$attr][$i], $filter, $attrib);
TB 686             if ($members == false) {
a31482 687                 $members = array();
T 688             }
689
690             // for nested groups, call recursively
b35a0f 691             $nested_group_members = $this->list_group_members($entry[$attr][$i], $count, $members);
a31482 692
T 693             unset($members['count']);
694             $group_members = array_merge($group_members, array_filter($members), $nested_group_members);
695         }
696
697         return $group_members;
698     }
699
700     /**
701      * List members of group class groupOfUrls
702      *
703      * @param string Group DN
704      * @param array  Group entry
b35a0f 705      * @param boolean True if only used for counting
a31482 706      * @return array Accumulated group members
T 707      */
b35a0f 708     private function _list_group_memberurl($dn, $entry, $count)
a31482 709     {
T 710         $group_members = array();
711
807c3d 712         for ($i=0; $i < $entry['memberurl']['count']; $i++) {
8fb04b 713             // extract components from url
5aa1d2 714             if (!preg_match('!ldap://[^/]*/([^\?]+)\?\?(\w+)\?(.*)$!', $entry['memberurl'][$i], $m)) {
a31482 715                 continue;
5aa1d2 716             }
a31482 717
T 718             // add search filter if any
719             $filter = $this->filter ? '(&(' . $m[3] . ')(' . $this->filter . '))' : $m[3];
807c3d 720             $attrs = $count ? array('dn','objectClass') : $this->prop['list_attributes'];
203323 721             if ($result = $this->ldap->search($m[1], $filter, $m[2], $attrs, $this->group_data)) {
TB 722                 $entries = $result->entries();
ec2185 723                 for ($j = 0; $j < $entries['count']; $j++) {
4c02ef 724                     if ($this->is_group_entry($entries[$j]) && ($nested_group_members = $this->list_group_members($entries[$j]['dn'], $count)))
ec2185 725                         $group_members = array_merge($group_members, $nested_group_members);
TB 726                     else
727                         $group_members[] = $entries[$j];
728                 }
8fb04b 729             }
a97937 730         }
39cafa 731
a31482 732         return $group_members;
T 733     }
a97937 734
a31482 735     /**
T 736      * Callback for sorting entries
737      */
738     function _entry_sort_cmp($a, $b)
739     {
740         return strcmp($a[$this->sort_col][0], $b[$this->sort_col][0]);
a97937 741     }
T 742
743     /**
cc90ed 744      * Search contacts
A 745      *
746      * @param mixed   $fields   The field name of array of field names to search in
747      * @param mixed   $value    Search value (or array of values when $fields is array)
f21a04 748      * @param int     $mode     Matching mode:
A 749      *                          0 - partial (*abc*),
750      *                          1 - strict (=),
751      *                          2 - prefix (abc*)
cc90ed 752      * @param boolean $select   True if results are requested, False if count only
A 753      * @param boolean $nocount  (Not used)
754      * @param array   $required List of fields that cannot be empty
755      *
756      * @return array  Indexed list of contact records and 'count' value
757      */
f21a04 758     function search($fields, $value, $mode=0, $select=true, $nocount=false, $required=array())
a97937 759     {
f21a04 760         $mode = intval($mode);
A 761
a97937 762         // special treatment for ID-based search
203323 763         if ($fields == 'ID' || $fields == $this->primary_key) {
648674 764             $ids = !is_array($value) ? explode(',', $value) : $value;
a97937 765             $result = new rcube_result_set();
203323 766             foreach ($ids as $id) {
TB 767                 if ($rec = $this->get_record($id, true)) {
a97937 768                     $result->add($rec);
T 769                     $result->count++;
770                 }
771             }
772             return $result;
773         }
774
fc91c1 775         // use VLV pseudo-search for autocompletion
e1cfb0 776         $rcube = rcube::get_instance();
699cb1 777         $list_fields = $rcube->config->get('contactlist_fields');
baecd8 778
203323 779         if ($this->prop['vlv_search'] && $this->ready && join(',', (array)$fields) == join(',', $list_fields)) {
9b3311 780             $this->result = new rcube_result_set(0);
A 781
aafcce 782             $this->ldap->config_set('fuzzy_search', intval($this->prop['fuzzy_search'] && $mode != 1));
772b73 783             $ldap_data = $this->ldap->search($this->base_dn, $this->prop['filter'], $this->prop['scope'], $this->prop['attributes'],
aafcce 784                 array('search' => $value /*, 'sort' => $this->prop['sort'] */));
772b73 785             if ($ldap_data === false) {
6bddd9 786                 return $this->result;
A 787             }
9b3311 788
fc91c1 789             // get all entries of this page and post-filter those that really match the query
772b73 790             $search = mb_strtolower($value);
9e4246 791             foreach ($ldap_data as $entry) {
772b73 792                 $rec = $this->_ldap2result($entry);
699cb1 793                 foreach ($fields as $f) {
AM 794                     foreach ((array)$rec[$f] as $val) {
83f707 795                         if ($this->compare_search_value($f, $val, $search, $mode)) {
699cb1 796                             $this->result->add($rec);
AM 797                             $this->result->count++;
798                             break 2;
799                         }
f21a04 800                     }
fc91c1 801                 }
T 802             }
803
804             return $this->result;
805         }
806
36ee2c 807         // advanced per-attribute search
TB 808         if (is_array($value)) {
809             // use AND operator for advanced searches
810             $filter = '(&';
e9a9f2 811
36ee2c 812             // set wildcards
TB 813             $wp = $ws = '';
814             if (!empty($this->prop['fuzzy_search']) && $mode != 1) {
815                 $ws = '*';
816                 if (!$mode) {
817                     $wp = '*';
e9a9f2 818                 }
3cacf9 819             }
36ee2c 820
e9a9f2 821             foreach ((array)$fields as $idx => $field) {
36ee2c 822                 $val = $value[$idx];
TB 823                 if (!strlen($val))
824                     continue;
1078a6 825                 if ($attrs = $this->_map_field($field)) {
TB 826                     if (count($attrs) > 1)
827                         $filter .= '(|';
828                     foreach ($attrs as $f)
4500b2 829                         $filter .= "($f=$wp" . rcube_ldap_generic::quote_string($val) . "$ws)";
1078a6 830                     if (count($attrs) > 1)
TB 831                         $filter .= ')';
e9a9f2 832                 }
A 833             }
36ee2c 834
TB 835             $filter .= ')';
a97937 836         }
36ee2c 837         else {
TB 838             if ($fields == '*') {
839                 // search_fields are required for fulltext search
840                 if (empty($this->prop['search_fields'])) {
841                     $this->set_error(self::ERROR_SEARCH, 'nofulltextsearch');
842                     $this->result = new rcube_result_set();
843                     return $this->result;
844                 }
845                 $attributes = (array)$this->prop['search_fields'];
846             }
847             else {
848                 // map address book fields into ldap attributes
849                 $attributes = array();
212e33 850                 foreach ((array) $fields as $field) {
AM 851                     if ($this->coltypes[$field] && ($attrs = $this->coltypes[$field]['attributes'])) {
852                         $attributes = array_merge($attributes, (array) $attrs);
36ee2c 853                     }
212e33 854                 }
36ee2c 855             }
TB 856
857             // compose a full-text-like search filter
858             $filter = rcube_ldap_generic::fulltext_search_filter($value, $attributes, $mode);
859         }
a97937 860
T 861         // add required (non empty) fields filter
862         $req_filter = '';
1078a6 863         foreach ((array)$required as $field) {
4500b2 864             if (in_array($field, (array)$fields))  // required field is already in search filter
TB 865                 continue;
1078a6 866             if ($attrs = $this->_map_field($field)) {
TB 867                 if (count($attrs) > 1)
868                     $req_filter .= '(|';
869                 foreach ($attrs as $f)
870                     $req_filter .= "($f=*)";
871                 if (count($attrs) > 1)
872                     $req_filter .= ')';
873             }
874         }
a97937 875
T 876         if (!empty($req_filter))
877             $filter = '(&' . $req_filter . $filter . ')';
878
879         // avoid double-wildcard if $value is empty
880         $filter = preg_replace('/\*+/', '*', $filter);
881
882         // add general filter to query
883         if (!empty($this->prop['filter']))
884             $filter = '(&(' . preg_replace('/^\(|\)$/', '', $this->prop['filter']) . ')' . $filter . ')';
885
886         // set filter string and execute search
887         $this->set_search_set($filter);
888
889         if ($select)
890             $this->list_records();
891         else
892             $this->result = $this->count();
893
894         return $this->result;
895     }
896
897     /**
cc90ed 898      * Count number of available contacts in database
A 899      *
900      * @return object rcube_result_set Resultset with values for 'count' and 'first'
901      */
a97937 902     function count()
T 903     {
904         $count = 0;
203323 905         if ($this->ldap_result) {
TB 906             $count = $this->ldap_result->count();
a31482 907         }
T 908         else if ($this->group_id && $this->group_data['dn']) {
b35a0f 909             $count = count($this->list_group_members($this->group_data['dn'], true));
a31482 910         }
203323 911         // We have a connection but no result set, attempt to get one.
TB 912         else if ($this->ready) {
4287c9 913             $prop    = $this->group_id ? $this->group_data : $this->prop;
AM 914             $base_dn = $this->group_id ? $this->group_base_dn : $this->base_dn;
ec2185 915
203323 916             if (!empty($this->filter)) {  // Use global search filter
TB 917                 $prop['filter'] = $this->filter;
a31482 918             }
4287c9 919             $count = $this->ldap->search($base_dn, $prop['filter'], $prop['scope'], array('dn'), $prop, true);
a31482 920         }
a97937 921
T 922         return new rcube_result_set($count, ($this->list_page-1) * $this->page_size);
923     }
924
925     /**
cc90ed 926      * Return the last result set
A 927      *
928      * @return object rcube_result_set Current resultset or NULL if nothing selected yet
929      */
a97937 930     function get_result()
T 931     {
932         return $this->result;
933     }
934
935     /**
cc90ed 936      * Get a specific contact record
A 937      *
938      * @param mixed   Record identifier
939      * @param boolean Return as associative array
940      *
941      * @return mixed  Hash array or rcube_result_set with all record fields
942      */
a97937 943     function get_record($dn, $assoc=false)
T 944     {
13db9e 945         $res = $this->result = null;
A 946
203323 947         if ($this->ready && $dn) {
c3ba0e 948             $dn = self::dn_decode($dn);
a97937 949
203323 950             if ($rec = $this->ldap->get_entry($dn)) {
TB 951                 $rec = array_change_key_case($rec, CASE_LOWER);
13db9e 952             }
a97937 953
13db9e 954             // Use ldap_list to get subentries like country (c) attribute (#1488123)
A 955             if (!empty($rec) && $this->sub_filter) {
203323 956                 if ($entries = $this->ldap->list_entries($dn, $this->sub_filter, array_keys($this->prop['sub_fields']))) {
13db9e 957                     foreach ($entries as $entry) {
A 958                         $lrec = array_change_key_case($entry, CASE_LOWER);
959                         $rec  = array_merge($lrec, $rec);
960                     }
961                 }
962             }
a97937 963
13db9e 964             if (!empty($rec)) {
a97937 965                 // Add in the dn for the entry.
T 966                 $rec['dn'] = $dn;
967                 $res = $this->_ldap2result($rec);
203323 968                 $this->result = new rcube_result_set(1);
a97937 969                 $this->result->add($res);
6039aa 970             }
T 971         }
a97937 972
T 973         return $assoc ? $res : $this->result;
028734 974     }
TB 975
976     /**
977      * Returns the last error occurred (e.g. when updating/inserting failed)
978      *
979      * @return array Hash array with the following fields: type, message
980      */
981     function get_error()
982     {
983         $err = $this->error;
984
985         // check ldap connection for errors
986         if (!$err && $this->ldap->get_error()) {
987             $err = array(self::ERROR_SEARCH, $this->ldap->get_error());
988         }
989
990         return $err;
f11541 991     }
T 992
a97937 993     /**
e84818 994      * Check the given data before saving.
T 995      * If input not valid, the message to display can be fetched using get_error()
996      *
997      * @param array Assoziative array with data to save
39cafa 998      * @param boolean Try to fix/complete record automatically
e84818 999      * @return boolean True if input is valid, False if not.
T 1000      */
39cafa 1001     public function validate(&$save_data, $autofix = false)
e84818 1002     {
19fccd 1003         // validate e-mail addresses
A 1004         if (!parent::validate($save_data, $autofix)) {
1005             return false;
1006         }
1007
e84818 1008         // check for name input
T 1009         if (empty($save_data['name'])) {
39cafa 1010             $this->set_error(self::ERROR_VALIDATE, 'nonamewarning');
T 1011             return false;
1012         }
1013
1014         // Verify that the required fields are set.
1015         $missing = null;
1016         $ldap_data = $this->_map_data($save_data);
1017         foreach ($this->prop['required_fields'] as $fld) {
19fccd 1018             if (!isset($ldap_data[$fld]) || $ldap_data[$fld] === '') {
39cafa 1019                 $missing[$fld] = 1;
T 1020             }
1021         }
1022
1023         if ($missing) {
1024             // try to complete record automatically
1025             if ($autofix) {
19fccd 1026                 $sn_field    = $this->fieldmap['surname'];
A 1027                 $fn_field    = $this->fieldmap['firstname'];
9336ba 1028                 $mail_field  = $this->fieldmap['email'];
A 1029
1030                 // try to extract surname and firstname from displayname
1031                 $name_parts  = preg_split('/[\s,.]+/', $save_data['name']);
19fccd 1032
A 1033                 if ($sn_field && $missing[$sn_field]) {
1034                     $save_data['surname'] = array_pop($name_parts);
1035                     unset($missing[$sn_field]);
39cafa 1036                 }
T 1037
19fccd 1038                 if ($fn_field && $missing[$fn_field]) {
A 1039                     $save_data['firstname'] = array_shift($name_parts);
1040                     unset($missing[$fn_field]);
1041                 }
9336ba 1042
A 1043                 // try to fix missing e-mail, very often on import
1044                 // from vCard we have email:other only defined
1045                 if ($mail_field && $missing[$mail_field]) {
1046                     $emails = $this->get_col_values('email', $save_data, true);
1047                     if (!empty($emails) && ($email = array_shift($emails))) {
1048                         $save_data['email'] = $email;
1049                         unset($missing[$mail_field]);
1050                     }
1051                 }
39cafa 1052             }
T 1053
1054             // TODO: generate message saying which fields are missing
19fccd 1055             if (!empty($missing)) {
A 1056                 $this->set_error(self::ERROR_VALIDATE, 'formincomplete');
1057                 return false;
1058             }
e84818 1059         }
cc90ed 1060
19fccd 1061         return true;
e84818 1062     }
T 1063
1064     /**
cc90ed 1065      * Create a new contact record
A 1066      *
1b39d9 1067      * @param array Associative array with save data
AM 1068      *  Keys:   Field name with optional section in the form FIELD:SECTION
1069      *  Values: Field value. Can be either a string or an array of strings for multiple values
1070      * @param boolean True to check for duplicates first
cc90ed 1071      *
1b39d9 1072      * @return mixed The created record ID on success, False on error
cc90ed 1073      */
1b39d9 1074     function insert($save_cols, $check = false)
f11541 1075     {
a97937 1076         // Map out the column names to their LDAP ones to build the new entry.
39cafa 1077         $newentry = $this->_map_data($save_cols);
a97937 1078         $newentry['objectClass'] = $this->prop['LDAP_Object_Classes'];
T 1079
3f250a 1080         // add automatically generated attributes
TB 1081         $this->add_autovalues($newentry);
1082
a97937 1083         // Verify that the required fields are set.
6f45fa 1084         $missing = null;
a97937 1085         foreach ($this->prop['required_fields'] as $fld) {
T 1086             if (!isset($newentry[$fld])) {
1087                 $missing[] = $fld;
1088             }
1089         }
1090
1091         // abort process if requiered fields are missing
1092         // TODO: generate message saying which fields are missing
1093         if ($missing) {
39cafa 1094             $this->set_error(self::ERROR_VALIDATE, 'formincomplete');
a97937 1095             return false;
T 1096         }
1097
1098         // Build the new entries DN.
4500b2 1099         $dn = $this->prop['LDAP_rdn'].'='.rcube_ldap_generic::quote_string($newentry[$this->prop['LDAP_rdn']], true).','.$this->base_dn;
a97937 1100
13db9e 1101         // Remove attributes that need to be added separately (child objects)
A 1102         $xfields = array();
1103         if (!empty($this->prop['sub_fields']) && is_array($this->prop['sub_fields'])) {
3725cf 1104             foreach (array_keys($this->prop['sub_fields']) as $xf) {
13db9e 1105                 if (!empty($newentry[$xf])) {
A 1106                     $xfields[$xf] = $newentry[$xf];
1107                     unset($newentry[$xf]);
1108                 }
1109             }
1110         }
a97937 1111
6ac939 1112         if (!$this->ldap->add_entry($dn, $newentry)) {
a97937 1113             $this->set_error(self::ERROR_SAVING, 'errorsaving');
T 1114             return false;
13db9e 1115         }
4f9c83 1116
13db9e 1117         foreach ($xfields as $xidx => $xf) {
4500b2 1118             $xdn = $xidx.'='.rcube_ldap_generic::quote_string($xf).','.$dn;
13db9e 1119             $xf = array(
A 1120                 $xidx => $xf,
1121                 'objectClass' => (array) $this->prop['sub_fields'][$xidx],
1122             );
1123
6ac939 1124             $this->ldap->add_entry($xdn, $xf);
13db9e 1125         }
4f9c83 1126
c3ba0e 1127         $dn = self::dn_encode($dn);
A 1128
a97937 1129         // add new contact to the selected group
5d692b 1130         if ($this->group_id)
c3ba0e 1131             $this->add_to_group($this->group_id, $dn);
4f9c83 1132
c3ba0e 1133         return $dn;
e83f03 1134     }
4f9c83 1135
a97937 1136     /**
cc90ed 1137      * Update a specific contact record
A 1138      *
1139      * @param mixed Record identifier
1140      * @param array Hash array with save data
1141      *
1142      * @return boolean True on success, False on error
1143      */
a97937 1144     function update($id, $save_cols)
f11541 1145     {
a97937 1146         $record = $this->get_record($id, true);
4aaecb 1147
13db9e 1148         $newdata     = array();
a97937 1149         $replacedata = array();
13db9e 1150         $deletedata  = array();
A 1151         $subdata     = array();
1152         $subdeldata  = array();
1153         $subnewdata  = array();
c3ba0e 1154
d6aafd 1155         $ldap_data = $this->_map_data($save_cols);
13db9e 1156         $old_data  = $record['_raw_attrib'];
1803f8 1157
21a0d9 1158         // special handling of photo col
A 1159         if ($photo_fld = $this->fieldmap['photo']) {
1160             // undefined means keep old photo
1161             if (!array_key_exists('photo', $save_cols)) {
1162                 $ldap_data[$photo_fld] = $record['photo'];
1163             }
1164         }
1165
3725cf 1166         foreach ($this->fieldmap as $fld) {
a97937 1167             if ($fld) {
13db9e 1168                 $val = $ldap_data[$fld];
A 1169                 $old = $old_data[$fld];
a97937 1170                 // remove empty array values
T 1171                 if (is_array($val))
1172                     $val = array_filter($val);
13db9e 1173                 // $this->_map_data() result and _raw_attrib use different format
A 1174                 // make sure comparing array with one element with a string works as expected
1175                 if (is_array($old) && count($old) == 1 && !is_array($val)) {
1176                     $old = array_pop($old);
21a0d9 1177                 }
A 1178                 if (is_array($val) && count($val) == 1 && !is_array($old)) {
1179                     $val = array_pop($val);
13db9e 1180                 }
A 1181                 // Subentries must be handled separately
1182                 if (!empty($this->prop['sub_fields']) && isset($this->prop['sub_fields'][$fld])) {
1183                     if ($old != $val) {
1184                         if ($old !== null) {
1185                             $subdeldata[$fld] = $old;
1186                         }
1187                         if ($val) {
1188                             $subnewdata[$fld] = $val;
1189                         }
1190                     }
1191                     else if ($old !== null) {
1192                         $subdata[$fld] = $old;
1193                     }
1194                     continue;
1195                 }
21a0d9 1196
a97937 1197                 // The field does exist compare it to the ldap record.
13db9e 1198                 if ($old != $val) {
a97937 1199                     // Changed, but find out how.
13db9e 1200                     if ($old === null) {
a97937 1201                         // Field was not set prior, need to add it.
T 1202                         $newdata[$fld] = $val;
1803f8 1203                     }
T 1204                     else if ($val == '') {
a97937 1205                         // Field supplied is empty, verify that it is not required.
T 1206                         if (!in_array($fld, $this->prop['required_fields'])) {
afaccf 1207                             // ...It is not, safe to clear.
AM 1208                             // #1488420: Workaround "ldap_mod_del(): Modify: Inappropriate matching in..."
1209                             // jpegPhoto attribute require an array() here. It looks to me that it works for other attribs too
1210                             $deletedata[$fld] = array();
1211                             //$deletedata[$fld] = $old_data[$fld];
1803f8 1212                         }
13db9e 1213                     }
a97937 1214                     else {
T 1215                         // The data was modified, save it out.
1216                         $replacedata[$fld] = $val;
1803f8 1217                     }
a97937 1218                 } // end if
T 1219             } // end if
1220         } // end foreach
a605b2 1221
T 1222         // console($old_data, $ldap_data, '----', $newdata, $replacedata, $deletedata, '----', $subdata, $subnewdata, $subdeldata);
1223
c3ba0e 1224         $dn = self::dn_decode($id);
a97937 1225
T 1226         // Update the entry as required.
1227         if (!empty($deletedata)) {
1228             // Delete the fields.
203323 1229             if (!$this->ldap->mod_del($dn, $deletedata)) {
a97937 1230                 $this->set_error(self::ERROR_SAVING, 'errorsaving');
T 1231                 return false;
1232             }
1233         } // end if
1234
1235         if (!empty($replacedata)) {
1236             // Handle RDN change
1237             if ($replacedata[$this->prop['LDAP_rdn']]) {
1238                 $newdn = $this->prop['LDAP_rdn'].'='
4500b2 1239                     .rcube_ldap_generic::quote_string($replacedata[$this->prop['LDAP_rdn']], true)
d1e08f 1240                     .','.$this->base_dn;
a97937 1241                 if ($dn != $newdn) {
T 1242                     $newrdn = $this->prop['LDAP_rdn'].'='
4500b2 1243                     .rcube_ldap_generic::quote_string($replacedata[$this->prop['LDAP_rdn']], true);
a97937 1244                     unset($replacedata[$this->prop['LDAP_rdn']]);
T 1245                 }
1246             }
1247             // Replace the fields.
1248             if (!empty($replacedata)) {
203323 1249                 if (!$this->ldap->mod_replace($dn, $replacedata)) {
13db9e 1250                     $this->set_error(self::ERROR_SAVING, 'errorsaving');
a97937 1251                     return false;
T 1252                 }
13db9e 1253             }
a97937 1254         } // end if
13db9e 1255
A 1256         // RDN change, we need to remove all sub-entries
1257         if (!empty($newrdn)) {
1258             $subdeldata = array_merge($subdeldata, $subdata);
1259             $subnewdata = array_merge($subnewdata, $subdata);
1260         }
1261
1262         // remove sub-entries
1263         if (!empty($subdeldata)) {
1264             foreach ($subdeldata as $fld => $val) {
4500b2 1265                 $subdn = $fld.'='.rcube_ldap_generic::quote_string($val).','.$dn;
6ac939 1266                 if (!$this->ldap->delete_entry($subdn)) {
13db9e 1267                     return false;
A 1268                 }
1269             }
1270         }
a97937 1271
T 1272         if (!empty($newdata)) {
1273             // Add the fields.
203323 1274             if (!$this->ldap->mod_add($dn, $newdata)) {
a97937 1275                 $this->set_error(self::ERROR_SAVING, 'errorsaving');
T 1276                 return false;
1277             }
1278         } // end if
1279
1280         // Handle RDN change
1281         if (!empty($newrdn)) {
203323 1282             if (!$this->ldap->rename($dn, $newrdn, null, true)) {
13db9e 1283                 $this->set_error(self::ERROR_SAVING, 'errorsaving');
a97937 1284                 return false;
T 1285             }
1286
c3ba0e 1287             $dn    = self::dn_encode($dn);
A 1288             $newdn = self::dn_encode($newdn);
1289
a97937 1290             // change the group membership of the contact
13db9e 1291             if ($this->groups) {
c3ba0e 1292                 $group_ids = $this->get_record_groups($dn);
42b9ce 1293                 foreach (array_keys($group_ids) as $group_id) {
c3ba0e 1294                     $this->remove_from_group($group_id, $dn);
A 1295                     $this->add_to_group($group_id, $newdn);
a97937 1296                 }
T 1297             }
c3ba0e 1298
13db9e 1299             $dn = self::dn_decode($newdn);
a97937 1300         }
T 1301
13db9e 1302         // add sub-entries
A 1303         if (!empty($subnewdata)) {
1304             foreach ($subnewdata as $fld => $val) {
4500b2 1305                 $subdn = $fld.'='.rcube_ldap_generic::quote_string($val).','.$dn;
13db9e 1306                 $xf = array(
A 1307                     $fld => $val,
1308                     'objectClass' => (array) $this->prop['sub_fields'][$fld],
1309                 );
6ac939 1310                 $this->ldap->add_entry($subdn, $xf);
13db9e 1311             }
A 1312         }
1313
7e3298 1314         return $newdn ?: true;
f11541 1315     }
a97937 1316
T 1317     /**
cc90ed 1318      * Mark one or more contact records as deleted
A 1319      *
63fda8 1320      * @param array   Record identifiers
A 1321      * @param boolean Remove record(s) irreversible (unsupported)
cc90ed 1322      *
A 1323      * @return boolean True on success, False on error
1324      */
63fda8 1325     function delete($ids, $force=true)
f11541 1326     {
a97937 1327         if (!is_array($ids)) {
T 1328             // Not an array, break apart the encoded DNs.
0f1fae 1329             $ids = explode(',', $ids);
a97937 1330         } // end if
T 1331
0f1fae 1332         foreach ($ids as $id) {
c3ba0e 1333             $dn = self::dn_decode($id);
13db9e 1334
A 1335             // Need to delete all sub-entries first
1336             if ($this->sub_filter) {
203323 1337                 if ($entries = $this->ldap->list_entries($dn, $this->sub_filter)) {
13db9e 1338                     foreach ($entries as $entry) {
6ac939 1339                         if (!$this->ldap->delete_entry($entry['dn'])) {
13db9e 1340                             $this->set_error(self::ERROR_SAVING, 'errorsaving');
A 1341                             return false;
1342                         }
1343                     }
1344                 }
1345             }
1346
a97937 1347             // Delete the record.
6ac939 1348             if (!$this->ldap->delete_entry($dn)) {
a97937 1349                 $this->set_error(self::ERROR_SAVING, 'errorsaving');
T 1350                 return false;
13db9e 1351             }
a97937 1352
6ac939 1353             // remove contact from all groups where he was a member
c3ba0e 1354             if ($this->groups) {
A 1355                 $dn = self::dn_encode($dn);
1356                 $group_ids = $this->get_record_groups($dn);
42b9ce 1357                 foreach (array_keys($group_ids) as $group_id) {
c3ba0e 1358                     $this->remove_from_group($group_id, $dn);
a97937 1359                 }
T 1360             }
1361         } // end foreach
1362
0f1fae 1363         return count($ids);
d6eb7c 1364     }
A 1365
1366     /**
1367      * Remove all contact records
18b40c 1368      *
AM 1369      * @param bool $with_groups Delete also groups if enabled
d6eb7c 1370      */
18b40c 1371     function delete_all($with_groups = false)
d6eb7c 1372     {
203323 1373         // searching for contact entries
7e3298 1374         $dn_list = $this->ldap->list_entries($this->base_dn, $this->prop['filter'] ?: '(objectclass=*)');
d6eb7c 1375
A 1376         if (!empty($dn_list)) {
1377             foreach ($dn_list as $idx => $entry) {
1378                 $dn_list[$idx] = self::dn_encode($entry['dn']);
1379             }
1380             $this->delete($dn_list);
1381         }
18b40c 1382
AM 1383         if ($with_groups && $this->groups && ($groups = $this->_fetch_groups()) && count($groups)) {
1384             foreach ($groups as $group) {
6ac939 1385                 $this->ldap->delete_entry($group['dn']);
18b40c 1386             }
AM 1387
1388             if ($this->cache) {
1389                 $this->cache->remove('groups');
1390             }
1391         }
f11541 1392     }
4368a0 1393
3f250a 1394     /**
TB 1395      * Generate missing attributes as configured
1396      *
1397      * @param array LDAP record attributes
1398      */
1399     protected function add_autovalues(&$attrs)
1400     {
4741d1 1401         if (empty($this->prop['autovalues'])) {
AM 1402             return;
1403         }
1404
3f250a 1405         $attrvals = array();
TB 1406         foreach ($attrs as $k => $v) {
1407             $attrvals['{'.$k.'}'] = is_array($v) ? $v[0] : $v;
1408         }
1409
1410         foreach ((array)$this->prop['autovalues'] as $lf => $templ) {
1411             if (empty($attrs[$lf])) {
c2e1ab 1412                 if (strpos($templ, '(') !== false) {
TB 1413                     // replace {attr} placeholders with (escaped!) attribute values to be safely eval'd
1414                     $code = preg_replace('/\{\w+\}/', '', strtr($templ, array_map('addslashes', $attrvals)));
4741d1 1415                     $fn   = create_function('', "return ($code);");
AM 1416                     if (!$fn) {
1417                         rcube::raise_error(array(
1418                             'code' => 505, 'type' => 'php',
1419                             'file' => __FILE__, 'line' => __LINE__,
1420                             'message' => "Expression parse error on: ($code)"), true, false);
1421                         continue;
1422                     }
1423
1424                     $attrs[$lf] = $fn();
c2e1ab 1425                 }
TB 1426                 else {
1427                     // replace {attr} placeholders with concrete attribute values
1428                     $attrs[$lf] = preg_replace('/\{\w+\}/', '', strtr($templ, $attrvals));
1429                 }
3f250a 1430             }
TB 1431         }
1432     }
4368a0 1433
a97937 1434     /**
cc90ed 1435      * Converts LDAP entry into an array
A 1436      */
a97937 1437     private function _ldap2result($rec)
T 1438     {
c5a5f9 1439         $out = array('_type' => 'person');
ec2185 1440         $fieldmap = $this->fieldmap;
a97937 1441
T 1442         if ($rec['dn'])
c3ba0e 1443             $out[$this->primary_key] = self::dn_encode($rec['dn']);
a97937 1444
c5a5f9 1445         // determine record type
4c02ef 1446         if ($this->is_group_entry($rec)) {
c5a5f9 1447             $out['_type'] = 'group';
TB 1448             $out['readonly'] = true;
7e3298 1449             $fieldmap['name'] = $this->group_data['name_attr'] ?: $this->prop['groups']['name_attr'];
c5a5f9 1450         }
a97937 1451
532c10 1452         // assign object type from object class mapping
TB 1453         if (!empty($this->prop['class_type_map'])) {
1454             foreach (array_map('strtolower', (array)$rec['objectclass']) as $objcls) {
1455                 if (!empty($this->prop['class_type_map'][$objcls])) {
1456                     $out['_type'] = $this->prop['class_type_map'][$objcls];
1457                     break;
1458                 }
1459             }
1460         }
1461
ec2185 1462         foreach ($fieldmap as $rf => $lf)
a97937 1463         {
T 1464             for ($i=0; $i < $rec[$lf]['count']; $i++) {
1465                 if (!($value = $rec[$lf][$i]))
1466                     continue;
1803f8 1467
ad8c9d 1468                 list($col, $subtype) = explode(':', $rf);
1803f8 1469                 $out['_raw_attrib'][$lf][$i] = $value;
T 1470
1078a6 1471                 if ($col == 'email' && $this->mail_domain && !strpos($value, '@'))
a97937 1472                     $out[$rf][] = sprintf('%s@%s', $value, $this->mail_domain);
ad8c9d 1473                 else if (in_array($col, array('street','zipcode','locality','country','region')))
7e3298 1474                     $out['address' . ($subtype ? ':' : '') . $subtype][$i][$col] = $value;
a605b2 1475                 else if ($col == 'address' && strpos($value, '$') !== false)  // address data is represented as string separated with $
T 1476                     list($out[$rf][$i]['street'], $out[$rf][$i]['locality'], $out[$rf][$i]['zipcode'], $out[$rf][$i]['country']) = explode('$', $value);
a97937 1477                 else if ($rec[$lf]['count'] > 1)
T 1478                     $out[$rf][] = $value;
1479                 else
1480                     $out[$rf] = $value;
1481             }
b1f084 1482
A 1483             // Make sure name fields aren't arrays (#1488108)
1484             if (is_array($out[$rf]) && in_array($rf, array('name', 'surname', 'firstname', 'middlename', 'nickname'))) {
1803f8 1485                 $out[$rf] = $out['_raw_attrib'][$lf] = $out[$rf][0];
b1f084 1486             }
a97937 1487         }
T 1488
1489         return $out;
1490     }
1491
1492     /**
1078a6 1493      * Return LDAP attribute(s) for the given field
cc90ed 1494      */
a97937 1495     private function _map_field($field)
T 1496     {
1078a6 1497         return (array)$this->coltypes[$field]['attributes'];
a97937 1498     }
T 1499
1500     /**
39cafa 1501      * Convert a record data set into LDAP field attributes
T 1502      */
1503     private function _map_data($save_cols)
1504     {
d6aafd 1505         // flatten composite fields first
T 1506         foreach ($this->coltypes as $col => $colprop) {
1507             if (is_array($colprop['childs']) && ($values = $this->get_col_values($col, $save_cols, false))) {
1508                 foreach ($values as $subtype => $childs) {
1509                     $subtype = $subtype ? ':'.$subtype : '';
1510                     foreach ($childs as $i => $child_values) {
1511                         foreach ((array)$child_values as $childcol => $value) {
1512                             $save_cols[$childcol.$subtype][$i] = $value;
1513                         }
1514                     }
1515                 }
1516             }
a605b2 1517
T 1518             // if addresses are to be saved as serialized string, do so
1519             if (is_array($colprop['serialized'])) {
1520                foreach ($colprop['serialized'] as $subtype => $delim) {
1521                   $key = $col.':'.$subtype;
49cb69 1522                   foreach ((array)$save_cols[$key] as $i => $val) {
TB 1523                      $values = array($val['street'], $val['locality'], $val['zipcode'], $val['country']);
1524                      $save_cols[$key][$i] = count(array_filter($values)) ? join($delim, $values) : null;
1525                  }
a605b2 1526                }
T 1527             }
d6aafd 1528         }
T 1529
39cafa 1530         $ldap_data = array();
1078a6 1531         foreach ($this->fieldmap as $rf => $fld) {
TB 1532             $val = $save_cols[$rf];
1533
1534             // check for value in base field (eg.g email instead of email:foo)
1535             list($col, $subtype) = explode(':', $rf);
1536             if (!$val && !empty($save_cols[$col])) {
1537                 $val = $save_cols[$col];
1538                 unset($save_cols[$col]);  // only use this value once
1539             }
1540             else if (!$val && !$subtype) { // extract values from subtype cols
1541                 $val = $this->get_col_values($col, $save_cols, true);
1542             }
1543
39cafa 1544             if (is_array($val))
T 1545                 $val = array_filter($val);  // remove empty entries
1546             if ($fld && $val) {
1547                 // The field does exist, add it to the entry.
1548                 $ldap_data[$fld] = $val;
1549             }
1550         }
13db9e 1551
c8c4ca 1552         foreach ($this->formats as $fld => $format) {
AM 1553             if (empty($ldap_data[$fld])) {
1554                 continue;
1555             }
1556
1557             switch ($format['type']) {
1558             case 'date':
1559                 if ($dt = rcube_utils::anytodatetime($ldap_data[$fld])) {
1560                     $ldap_data[$fld] = $dt->format($format['format']);
1561                 }
1562                 break;
1563             }
1564         }
1565
39cafa 1566         return $ldap_data;
T 1567     }
1568
1569     /**
cc90ed 1570      * Returns unified attribute name (resolving aliases)
A 1571      */
a605b2 1572     private static function _attr_name($namev)
a97937 1573     {
T 1574         // list of known attribute aliases
a605b2 1575         static $aliases = array(
3e7b9b 1576             'gn'            => 'givenname',
a97937 1577             'rfc822mailbox' => 'email',
3e7b9b 1578             'userid'        => 'uid',
AM 1579             'emailaddress'  => 'email',
1580             'pkcs9email'    => 'email',
a97937 1581         );
a605b2 1582
T 1583         list($name, $limit) = explode(':', $namev, 2);
1584         $suffix = $limit ? ':'.$limit : '';
c8c4ca 1585         $name   = strtolower($name);
a605b2 1586
T 1587         return (isset($aliases[$name]) ? $aliases[$name] : $name) . $suffix;
a97937 1588     }
T 1589
f924f5 1590     /**
TB 1591      * Determines whether the given LDAP entry is a group record
1592      */
4c02ef 1593     private function is_group_entry($entry)
f924f5 1594     {
3e7b9b 1595         $classes = array_map('strtolower', (array)$entry['objectclass']);
AM 1596
3ce7c5 1597         return count(array_intersect(array_keys($this->group_types), $classes)) > 0;
f924f5 1598     }
a97937 1599
T 1600     /**
06744d 1601      * Activate/deactivate debug mode
T 1602      *
1603      * @param boolean $dbg True if LDAP commands should be logged
1604      */
1605     function set_debug($dbg = true)
1606     {
1607         $this->debug = $dbg;
fae90d 1608
AM 1609         if ($this->ldap) {
6ac939 1610             $this->ldap->config_set('debug', $dbg);
fae90d 1611         }
06744d 1612     }
T 1613
1614     /**
6039aa 1615      * Setter for the current group
T 1616      */
1617     function set_group($group_id)
1618     {
ec2185 1619         if ($group_id) {
6039aa 1620             $this->group_id = $group_id;
ec2185 1621             $this->group_data = $this->get_group_entry($group_id);
6039aa 1622         }
ec2185 1623         else {
a97937 1624             $this->group_id = 0;
a31482 1625             $this->group_data = null;
8fb04b 1626         }
6039aa 1627     }
T 1628
1629     /**
1630      * List all active contact groups of this source
1631      *
1632      * @param string  Optional search string to match group name
ec4331 1633      * @param int     Matching mode:
AM 1634      *                0 - partial (*abc*),
1635      *                1 - strict (=),
1636      *                2 - prefix (abc*)
1637      *
6039aa 1638      * @return array  Indexed list of contact groups, each a hash array
T 1639      */
ec4331 1640     function list_groups($search = null, $mode = 0)
6039aa 1641     {
4feb8e 1642         if (!$this->groups) {
a97937 1643             return array();
b20025 1644         }
a31482 1645
834fb6 1646         $group_cache = $this->_fetch_groups($search, $mode);
4feb8e 1647         $groups      = array();
AM 1648
a31482 1649         if ($search) {
T 1650             foreach ($group_cache as $group) {
028734 1651                 if ($this->compare_search_value('name', $group['name'], mb_strtolower($search), $mode)) {
a31482 1652                     $groups[] = $group;
ec4331 1653                 }
a31482 1654             }
T 1655         }
4feb8e 1656         else {
a31482 1657             $groups = $group_cache;
4feb8e 1658         }
a31482 1659
T 1660         return array_values($groups);
1661     }
1662
1663     /**
1664      * Fetch groups from server
1665      */
834fb6 1666     private function _fetch_groups($search = null, $mode = 0, $vlv_page = null)
a31482 1667     {
834fb6 1668         // reset group search cache
TB 1669         if ($search !== null && $vlv_page === null) {
1670             $this->group_search_cache = null;
1671         }
1672         // return in-memory cache from previous search results
1673         else if (is_array($this->group_search_cache) && $vlv_page === null) {
1674             return $this->group_search_cache;
1675         }
1676
ec2185 1677         // special case: list groups from 'group_filters' config
834fb6 1678         if ($vlv_page === null && $search === null && is_array($this->prop['group_filters'])) {
ec2185 1679             $groups = array();
6b2b2e 1680             $rcube  = rcube::get_instance();
ec2185 1681
TB 1682             // list regular groups configuration as special filter
1683             if (!empty($this->prop['groups']['filter'])) {
1684                 $id = '__groups__';
6b2b2e 1685                 $groups[$id] = array('ID' => $id, 'name' => $rcube->gettext('groups'), 'virtual' => true) + $this->prop['groups'];
ec2185 1686             }
TB 1687
1688             foreach ($this->prop['group_filters'] as $id => $prop) {
203323 1689                 $groups[$id] = $prop + array('ID' => $id, 'name' => ucfirst($id), 'virtual' => true, 'base_dn' => $this->base_dn);
ec2185 1690             }
TB 1691
1692             return $groups;
1693         }
1694
834fb6 1695         if ($this->cache && $search === null && $vlv_page === null && ($groups = $this->cache->get('groups')) !== null) {
4feb8e 1696             return $groups;
AM 1697         }
1698
1699         $base_dn    = $this->groups_base_dn;
1700         $filter     = $this->prop['groups']['filter'];
4287c9 1701         $scope      = $this->prop['groups']['scope'];
4feb8e 1702         $name_attr  = $this->prop['groups']['name_attr'];
7e3298 1703         $email_attr = $this->prop['groups']['email_attr'] ?: 'mail';
fb6cc8 1704         $sort_attrs = $this->prop['groups']['sort'] ? (array)$this->prop['groups']['sort'] : array($name_attr);
4feb8e 1705         $sort_attr  = $sort_attrs[0];
6039aa 1706
203323 1707         $ldap = $this->ldap;
755189 1708
fb6cc8 1709         // use vlv to list groups
T 1710         if ($this->prop['groups']['vlv']) {
1711             $page_size = 200;
4feb8e 1712             if (!$this->prop['groups']['sort']) {
fb6cc8 1713                 $this->prop['groups']['sort'] = $sort_attrs;
4feb8e 1714             }
203323 1715
TB 1716             $ldap = clone $this->ldap;
6ac939 1717             $ldap->config_set($this->prop['groups']);
203323 1718             $ldap->set_vlv_page($vlv_page+1, $page_size);
fb6cc8 1719         }
T 1720
834fb6 1721         $props = array('sort' => $this->prop['groups']['sort']);
TB 1722         $attrs = array_unique(array('dn', 'objectClass', $name_attr, $email_attr, $sort_attr));
1723
1724         // add search filter
1725         if ($search !== null) {
1726             // set wildcards
1727             $wp = $ws = '';
1728             if (!empty($this->prop['fuzzy_search']) && $mode != 1) {
1729                 $ws = '*';
1730                 $wp = !$mode ? '*' : '';
1731             }
1732             $filter = "(&$filter($name_attr=$wp" . rcube_ldap_generic::quote_string($search) . "$ws))";
1733             $props['search'] = $wp . $search . $ws;
1734         }
1735
1736         $ldap_data = $ldap->search($base_dn, $filter, $scope, $attrs, $props);
4feb8e 1737
203323 1738         if ($ldap_data === false) {
6039aa 1739             return array();
T 1740         }
755189 1741
4feb8e 1742         $groups          = array();
6039aa 1743         $group_sortnames = array();
4feb8e 1744         $group_count     = $ldap_data->count();
AM 1745
203323 1746         foreach ($ldap_data as $entry) {
TB 1747             if (!$entry['dn'])  // DN is mandatory
1748                 $entry['dn'] = $ldap_data->get_dn();
1749
1750             $group_name = is_array($entry[$name_attr]) ? $entry[$name_attr][0] : $entry[$name_attr];
1751             $group_id = self::dn_encode($entry['dn']);
a31482 1752             $groups[$group_id]['ID'] = $group_id;
203323 1753             $groups[$group_id]['dn'] = $entry['dn'];
a31482 1754             $groups[$group_id]['name'] = $group_name;
203323 1755             $groups[$group_id]['member_attr'] = $this->get_group_member_attr($entry['objectclass']);
a31482 1756
T 1757             // list email attributes of a group
203323 1758             for ($j=0; $entry[$email_attr] && $j < $entry[$email_attr]['count']; $j++) {
TB 1759                 if (strpos($entry[$email_attr][$j], '@') > 0)
1760                     $groups[$group_id]['email'][] = $entry[$email_attr][$j];
a31482 1761             }
T 1762
203323 1763             $group_sortnames[] = mb_strtolower($entry[$sort_attr][0]);
6039aa 1764         }
fb6cc8 1765
T 1766         // recursive call can exit here
4feb8e 1767         if ($vlv_page > 0) {
fb6cc8 1768             return $groups;
4feb8e 1769         }
fb6cc8 1770
T 1771         // call recursively until we have fetched all groups
203323 1772         while ($this->prop['groups']['vlv'] && $group_count == $page_size) {
834fb6 1773             $next_page   = $this->_fetch_groups($search, $mode, ++$vlv_page);
4feb8e 1774             $groups      = array_merge($groups, $next_page);
fb6cc8 1775             $group_count = count($next_page);
T 1776         }
1777
1778         // when using VLV the list of groups is already sorted
4feb8e 1779         if (!$this->prop['groups']['vlv']) {
fb6cc8 1780             array_multisort($group_sortnames, SORT_ASC, SORT_STRING, $groups);
4feb8e 1781         }
8fb04b 1782
T 1783         // cache this
834fb6 1784         if ($this->cache && $search === null) {
b20025 1785             $this->cache->set('groups', $groups);
AM 1786         }
834fb6 1787         else if ($search !== null) {
TB 1788             $this->group_search_cache = $groups;
1789         }
a97937 1790
6039aa 1791         return $groups;
a31482 1792     }
T 1793
1794     /**
ec2185 1795      * Fetch a group entry from LDAP and save in local cache
TB 1796      */
1797     private function get_group_entry($group_id)
1798     {
4feb8e 1799         $group_cache = $this->_fetch_groups();
ec2185 1800
TB 1801         // add group record to cache if it isn't yet there
1802         if (!isset($group_cache[$group_id])) {
1803             $name_attr = $this->prop['groups']['name_attr'];
1804             $dn = self::dn_decode($group_id);
1805
203323 1806             if ($list = $this->ldap->read_entries($dn, '(objectClass=*)', array('dn','objectClass','member','uniqueMember','memberURL',$name_attr,$this->fieldmap['email']))) {
ec2185 1807                 $entry = $list[0];
TB 1808                 $group_name = is_array($entry[$name_attr]) ? $entry[$name_attr][0] : $entry[$name_attr];
1809                 $group_cache[$group_id]['ID'] = $group_id;
1810                 $group_cache[$group_id]['dn'] = $dn;
1811                 $group_cache[$group_id]['name'] = $group_name;
1812                 $group_cache[$group_id]['member_attr'] = $this->get_group_member_attr($entry['objectclass']);
1813             }
1814             else {
1815                 $group_cache[$group_id] = false;
1816             }
1817
b20025 1818             if ($this->cache) {
AM 1819                 $this->cache->set('groups', $group_cache);
1820             }
ec2185 1821         }
TB 1822
1823         return $group_cache[$group_id];
a31482 1824     }
T 1825
1826     /**
1827      * Get group properties such as name and email address(es)
1828      *
1829      * @param string Group identifier
1830      * @return array Group properties as hash array
1831      */
1832     function get_group($group_id)
1833     {
86552f 1834         $group_data = $this->get_group_entry($group_id);
a31482 1835         unset($group_data['dn'], $group_data['member_attr']);
T 1836
1837         return $group_data;
6039aa 1838     }
T 1839
1840     /**
1841      * Create a contact group with the given name
1842      *
1843      * @param string The group name
1844      * @return mixed False on error, array with record props in success
1845      */
1846     function create_group($group_name)
1847     {
4feb8e 1848         $new_dn      = 'cn=' . rcube_ldap_generic::quote_string($group_name, true) . ',' . $this->groups_base_dn;
AM 1849         $new_gid     = self::dn_encode($new_dn);
097dbc 1850         $member_attr = $this->get_group_member_attr();
7e3298 1851         $name_attr   = $this->prop['groups']['name_attr'] ?: 'cn';
4feb8e 1852         $new_entry   = array(
d1e08f 1853             'objectClass' => $this->prop['groups']['object_classes'],
7e3298 1854             $name_attr    => $group_name,
AM 1855             $member_attr  => '',
6039aa 1856         );
T 1857
6ac939 1858         if (!$this->ldap->add_entry($new_dn, $new_entry)) {
6039aa 1859             $this->set_error(self::ERROR_SAVING, 'errorsaving');
T 1860             return false;
1861         }
755189 1862
b20025 1863         if ($this->cache) {
AM 1864             $this->cache->remove('groups');
1865         }
755189 1866
6039aa 1867         return array('id' => $new_gid, 'name' => $group_name);
T 1868     }
1869
1870     /**
1871      * Delete the given group and all linked group members
1872      *
1873      * @param string Group identifier
1874      * @return boolean True on success, false if no data was changed
1875      */
1876     function delete_group($group_id)
1877     {
4feb8e 1878         $group_cache = $this->_fetch_groups();
AM 1879         $del_dn      = $group_cache[$group_id]['dn'];
755189 1880
6ac939 1881         if (!$this->ldap->delete_entry($del_dn)) {
6039aa 1882             $this->set_error(self::ERROR_SAVING, 'errorsaving');
T 1883             return false;
1884         }
755189 1885
b20025 1886         if ($this->cache) {
AM 1887             unset($group_cache[$group_id]);
1888             $this->cache->set('groups', $group_cache);
1889         }
755189 1890
6039aa 1891         return true;
T 1892     }
1893
1894     /**
1895      * Rename a specific contact group
1896      *
1897      * @param string Group identifier
1898      * @param string New name to set for this group
360bd3 1899      * @param string New group identifier (if changed, otherwise don't set)
6039aa 1900      * @return boolean New name on success, false if no data was changed
T 1901      */
15e944 1902     function rename_group($group_id, $new_name, &$new_gid)
6039aa 1903     {
4feb8e 1904         $group_cache = $this->_fetch_groups();
AM 1905         $old_dn      = $group_cache[$group_id]['dn'];
1906         $new_rdn     = "cn=" . rcube_ldap_generic::quote_string($new_name, true);
1907         $new_gid     = self::dn_encode($new_rdn . ',' . $this->groups_base_dn);
6039aa 1908
203323 1909         if (!$this->ldap->rename($old_dn, $new_rdn, null, true)) {
6039aa 1910             $this->set_error(self::ERROR_SAVING, 'errorsaving');
T 1911             return false;
1912         }
755189 1913
b20025 1914         if ($this->cache) {
AM 1915             $this->cache->remove('groups');
1916         }
755189 1917
6039aa 1918         return $new_name;
T 1919     }
1920
1921     /**
1922      * Add the given contact records the a certain group
1923      *
40d419 1924      * @param string       Group identifier
AM 1925      * @param array|string List of contact identifiers to be added
1926      *
1927      * @return int Number of contacts added
6039aa 1928      */
T 1929     function add_to_group($group_id, $contact_ids)
1930     {
4feb8e 1931         $group_cache = $this->_fetch_groups();
8fb04b 1932         $member_attr = $group_cache[$group_id]['member_attr'];
c8a714 1933         $group_dn    = $group_cache[$group_id]['dn'];
40d419 1934         $new_attrs   = array();
6039aa 1935
4feb8e 1936         if (!is_array($contact_ids)) {
AM 1937             $contact_ids = explode(',', $contact_ids);
1938         }
1939
1940         foreach ($contact_ids as $id) {
f763fb 1941             $new_attrs[$member_attr][] = self::dn_decode($id);
4feb8e 1942         }
6039aa 1943
203323 1944         if (!$this->ldap->mod_add($group_dn, $new_attrs)) {
6039aa 1945             $this->set_error(self::ERROR_SAVING, 'errorsaving');
T 1946             return 0;
1947         }
755189 1948
b20025 1949         if ($this->cache) {
AM 1950             $this->cache->remove('groups');
1951         }
755189 1952
40d419 1953         return count($new_attrs[$member_attr]);
6039aa 1954     }
T 1955
1956     /**
1957      * Remove the given contact records from a certain group
1958      *
40d419 1959      * @param string       Group identifier
AM 1960      * @param array|string List of contact identifiers to be removed
1961      *
1962      * @return int Number of deleted group members
6039aa 1963      */
T 1964     function remove_from_group($group_id, $contact_ids)
1965     {
4feb8e 1966         $group_cache = $this->_fetch_groups();
8fb04b 1967         $member_attr = $group_cache[$group_id]['member_attr'];
c8a714 1968         $group_dn    = $group_cache[$group_id]['dn'];
4feb8e 1969         $del_attrs   = array();
6039aa 1970
4feb8e 1971         if (!is_array($contact_ids)) {
AM 1972             $contact_ids = explode(',', $contact_ids);
1973         }
1974
1975         foreach ($contact_ids as $id) {
f763fb 1976             $del_attrs[$member_attr][] = self::dn_decode($id);
4feb8e 1977         }
6039aa 1978
203323 1979         if (!$this->ldap->mod_del($group_dn, $del_attrs)) {
6039aa 1980             $this->set_error(self::ERROR_SAVING, 'errorsaving');
T 1981             return 0;
1982         }
755189 1983
b20025 1984         if ($this->cache) {
AM 1985             $this->cache->remove('groups');
1986         }
755189 1987
40d419 1988         return count($del_attrs[$member_attr]);
6039aa 1989     }
T 1990
1991     /**
1992      * Get group assignments of a specific contact record
1993      *
1994      * @param mixed Record identifier
1995      *
1996      * @return array List of assigned groups as ID=>Name pairs
1997      * @since 0.5-beta
1998      */
1999     function get_record_groups($contact_id)
2000     {
4feb8e 2001         if (!$this->groups) {
6039aa 2002             return array();
4feb8e 2003         }
6039aa 2004
f763fb 2005         $base_dn     = $this->groups_base_dn;
A 2006         $contact_dn  = self::dn_decode($contact_id);
7e3298 2007         $name_attr   = $this->prop['groups']['name_attr'] ?: 'cn';
097dbc 2008         $member_attr = $this->get_group_member_attr();
8fb04b 2009         $add_filter  = '';
3e7b9b 2010
8fb04b 2011         if ($member_attr != 'member' && $member_attr != 'uniqueMember')
T 2012             $add_filter = "($member_attr=$contact_dn)";
2013         $filter = strtr("(|(member=$contact_dn)(uniqueMember=$contact_dn)$add_filter)", array('\\' => '\\\\'));
6039aa 2014
203323 2015         $ldap_data = $this->ldap->search($base_dn, $filter, 'sub', array('dn', $name_attr));
9e4246 2016         if ($ldap_data === false) {
6039aa 2017             return array();
T 2018         }
2019
2020         $groups = array();
203323 2021         foreach ($ldap_data as $entry) {
TB 2022             if (!$entry['dn'])
2023                 $entry['dn'] = $ldap_data->get_dn();
2024             $group_name = $entry[$name_attr][0];
2025             $group_id = self::dn_encode($entry['dn']);
42b9ce 2026             $groups[$group_id] = $group_name;
6039aa 2027         }
42b9ce 2028
6039aa 2029         return $groups;
T 2030     }
69ea3a 2031
097dbc 2032     /**
A 2033      * Detects group member attribute name
2034      */
1b52cf 2035     private function get_group_member_attr($object_classes = array(), $default = 'member')
097dbc 2036     {
A 2037         if (empty($object_classes)) {
2038             $object_classes = $this->prop['groups']['object_classes'];
2039         }
1b52cf 2040
097dbc 2041         if (!empty($object_classes)) {
A 2042             foreach ((array)$object_classes as $oc) {
3ce7c5 2043                 if ($attr = $this->group_types[strtolower($oc)]) {
3e7b9b 2044                     return $attr;
097dbc 2045                 }
A 2046             }
2047         }
2048
2049         if (!empty($this->prop['groups']['member_attr'])) {
2050             return $this->prop['groups']['member_attr'];
2051         }
2052
1b52cf 2053         return $default;
097dbc 2054     }
A 2055
69ea3a 2056     /**
c3ba0e 2057      * HTML-safe DN string encoding
A 2058      *
2059      * @param string $str DN string
2060      *
2061      * @return string Encoded HTML identifier string
2062      */
2063     static function dn_encode($str)
2064     {
2065         // @TODO: to make output string shorter we could probably
2066         //        remove dc=* items from it
2067         return rtrim(strtr(base64_encode($str), '+/', '-_'), '=');
2068     }
2069
2070     /**
2071      * Decodes DN string encoded with _dn_encode()
2072      *
2073      * @param string $str Encoded HTML identifier string
2074      *
2075      * @return string DN string
2076      */
2077     static function dn_decode($str)
2078     {
2079         $str = str_pad(strtr($str, '-_', '+/'), strlen($str) % 4, '=', STR_PAD_RIGHT);
2080         return base64_decode($str);
13db9e 2081     }
f11541 2082 }