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