Aleksander Machniak
2014-09-15 83a64265a7d8865f0381e53a0cc47d6ef53217b6
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 = '';
4feb8e 67     private $group_url;
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'])) {
121             foreach ($p['fieldmap'] as $rf => $lf)
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)
127                 if (preg_match('/^(.+)_field$/', $prop, $matches))
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
a31482 701             if (!preg_match('!ldap:///([^\?]+)\?\?(\w+)\?(.*)$!', $entry['memberurl'][$i], $m))
T 702                 continue;
703
704             // add search filter if any
705             $filter = $this->filter ? '(&(' . $m[3] . ')(' . $this->filter . '))' : $m[3];
807c3d 706             $attrs = $count ? array('dn','objectClass') : $this->prop['list_attributes'];
203323 707             if ($result = $this->ldap->search($m[1], $filter, $m[2], $attrs, $this->group_data)) {
TB 708                 $entries = $result->entries();
ec2185 709                 for ($j = 0; $j < $entries['count']; $j++) {
4c02ef 710                     if ($this->is_group_entry($entries[$j]) && ($nested_group_members = $this->list_group_members($entries[$j]['dn'], $count)))
ec2185 711                         $group_members = array_merge($group_members, $nested_group_members);
TB 712                     else
713                         $group_members[] = $entries[$j];
714                 }
8fb04b 715             }
a97937 716         }
39cafa 717
a31482 718         return $group_members;
T 719     }
a97937 720
a31482 721     /**
T 722      * Callback for sorting entries
723      */
724     function _entry_sort_cmp($a, $b)
725     {
726         return strcmp($a[$this->sort_col][0], $b[$this->sort_col][0]);
a97937 727     }
T 728
729
730     /**
cc90ed 731      * Search contacts
A 732      *
733      * @param mixed   $fields   The field name of array of field names to search in
734      * @param mixed   $value    Search value (or array of values when $fields is array)
f21a04 735      * @param int     $mode     Matching mode:
A 736      *                          0 - partial (*abc*),
737      *                          1 - strict (=),
738      *                          2 - prefix (abc*)
cc90ed 739      * @param boolean $select   True if results are requested, False if count only
A 740      * @param boolean $nocount  (Not used)
741      * @param array   $required List of fields that cannot be empty
742      *
743      * @return array  Indexed list of contact records and 'count' value
744      */
f21a04 745     function search($fields, $value, $mode=0, $select=true, $nocount=false, $required=array())
a97937 746     {
f21a04 747         $mode = intval($mode);
A 748
a97937 749         // special treatment for ID-based search
203323 750         if ($fields == 'ID' || $fields == $this->primary_key) {
648674 751             $ids = !is_array($value) ? explode(',', $value) : $value;
a97937 752             $result = new rcube_result_set();
203323 753             foreach ($ids as $id) {
TB 754                 if ($rec = $this->get_record($id, true)) {
a97937 755                     $result->add($rec);
T 756                     $result->count++;
757                 }
758             }
759             return $result;
760         }
761
fc91c1 762         // use VLV pseudo-search for autocompletion
e1cfb0 763         $rcube = rcube::get_instance();
699cb1 764         $list_fields = $rcube->config->get('contactlist_fields');
baecd8 765
203323 766         if ($this->prop['vlv_search'] && $this->ready && join(',', (array)$fields) == join(',', $list_fields)) {
9b3311 767             $this->result = new rcube_result_set(0);
A 768
772b73 769             $search_suffix = $this->prop['fuzzy_search'] && $mode != 1 ? '*' : '';
TB 770             $ldap_data = $this->ldap->search($this->base_dn, $this->prop['filter'], $this->prop['scope'], $this->prop['attributes'],
771                 array('search' => $value . $search_suffix /*, 'sort' => $this->prop['sort'] */));
772             if ($ldap_data === false) {
6bddd9 773                 return $this->result;
A 774             }
9b3311 775
fc91c1 776             // get all entries of this page and post-filter those that really match the query
772b73 777             $search = mb_strtolower($value);
TB 778             foreach ($ldap_data as $i => $entry) {
779                 $rec = $this->_ldap2result($entry);
699cb1 780                 foreach ($fields as $f) {
AM 781                     foreach ((array)$rec[$f] as $val) {
83f707 782                         if ($this->compare_search_value($f, $val, $search, $mode)) {
699cb1 783                             $this->result->add($rec);
AM 784                             $this->result->count++;
785                             break 2;
786                         }
f21a04 787                     }
fc91c1 788                 }
T 789             }
790
791             return $this->result;
792         }
793
e9a9f2 794         // use AND operator for advanced searches
A 795         $filter = is_array($value) ? '(&' : '(|';
f21a04 796         // set wildcards
A 797         $wp = $ws = '';
798         if (!empty($this->prop['fuzzy_search']) && $mode != 1) {
799             $ws = '*';
800             if (!$mode) {
801                 $wp = '*';
802             }
803         }
e9a9f2 804
203323 805         if ($fields == '*') {
3e2637 806             // search_fields are required for fulltext search
203323 807             if (empty($this->prop['search_fields'])) {
3e2637 808                 $this->set_error(self::ERROR_SEARCH, 'nofulltextsearch');
T 809                 $this->result = new rcube_result_set();
810                 return $this->result;
811             }
203323 812             if (is_array($this->prop['search_fields'])) {
e9a9f2 813                 foreach ($this->prop['search_fields'] as $field) {
4500b2 814                     $filter .= "($field=$wp" . rcube_ldap_generic::quote_string($value) . "$ws)";
e9a9f2 815                 }
3cacf9 816             }
a97937 817         }
203323 818         else {
e9a9f2 819             foreach ((array)$fields as $idx => $field) {
A 820                 $val = is_array($value) ? $value[$idx] : $value;
1078a6 821                 if ($attrs = $this->_map_field($field)) {
TB 822                     if (count($attrs) > 1)
823                         $filter .= '(|';
824                     foreach ($attrs as $f)
4500b2 825                         $filter .= "($f=$wp" . rcube_ldap_generic::quote_string($val) . "$ws)";
1078a6 826                     if (count($attrs) > 1)
TB 827                         $filter .= ')';
e9a9f2 828                 }
A 829             }
a97937 830         }
T 831         $filter .= ')';
832
833         // add required (non empty) fields filter
834         $req_filter = '';
1078a6 835         foreach ((array)$required as $field) {
4500b2 836             if (in_array($field, (array)$fields))  // required field is already in search filter
TB 837                 continue;
1078a6 838             if ($attrs = $this->_map_field($field)) {
TB 839                 if (count($attrs) > 1)
840                     $req_filter .= '(|';
841                 foreach ($attrs as $f)
842                     $req_filter .= "($f=*)";
843                 if (count($attrs) > 1)
844                     $req_filter .= ')';
845             }
846         }
a97937 847
T 848         if (!empty($req_filter))
849             $filter = '(&' . $req_filter . $filter . ')';
850
851         // avoid double-wildcard if $value is empty
852         $filter = preg_replace('/\*+/', '*', $filter);
853
854         // add general filter to query
855         if (!empty($this->prop['filter']))
856             $filter = '(&(' . preg_replace('/^\(|\)$/', '', $this->prop['filter']) . ')' . $filter . ')';
857
858         // set filter string and execute search
859         $this->set_search_set($filter);
860
861         if ($select)
862             $this->list_records();
863         else
864             $this->result = $this->count();
865
866         return $this->result;
867     }
868
869
870     /**
cc90ed 871      * Count number of available contacts in database
A 872      *
873      * @return object rcube_result_set Resultset with values for 'count' and 'first'
874      */
a97937 875     function count()
T 876     {
877         $count = 0;
203323 878         if ($this->ldap_result) {
TB 879             $count = $this->ldap_result->count();
a31482 880         }
T 881         else if ($this->group_id && $this->group_data['dn']) {
b35a0f 882             $count = count($this->list_group_members($this->group_data['dn'], true));
a31482 883         }
203323 884         // We have a connection but no result set, attempt to get one.
TB 885         else if ($this->ready) {
4287c9 886             $prop    = $this->group_id ? $this->group_data : $this->prop;
AM 887             $base_dn = $this->group_id ? $this->group_base_dn : $this->base_dn;
ec2185 888
203323 889             if (!empty($this->filter)) {  // Use global search filter
TB 890                 $prop['filter'] = $this->filter;
a31482 891             }
4287c9 892             $count = $this->ldap->search($base_dn, $prop['filter'], $prop['scope'], array('dn'), $prop, true);
a31482 893         }
a97937 894
T 895         return new rcube_result_set($count, ($this->list_page-1) * $this->page_size);
896     }
897
898
899     /**
cc90ed 900      * Return the last result set
A 901      *
902      * @return object rcube_result_set Current resultset or NULL if nothing selected yet
903      */
a97937 904     function get_result()
T 905     {
906         return $this->result;
907     }
908
909     /**
cc90ed 910      * Get a specific contact record
A 911      *
912      * @param mixed   Record identifier
913      * @param boolean Return as associative array
914      *
915      * @return mixed  Hash array or rcube_result_set with all record fields
916      */
a97937 917     function get_record($dn, $assoc=false)
T 918     {
13db9e 919         $res = $this->result = null;
A 920
203323 921         if ($this->ready && $dn) {
c3ba0e 922             $dn = self::dn_decode($dn);
a97937 923
203323 924             if ($rec = $this->ldap->get_entry($dn)) {
TB 925                 $rec = array_change_key_case($rec, CASE_LOWER);
13db9e 926             }
a97937 927
13db9e 928             // Use ldap_list to get subentries like country (c) attribute (#1488123)
A 929             if (!empty($rec) && $this->sub_filter) {
203323 930                 if ($entries = $this->ldap->list_entries($dn, $this->sub_filter, array_keys($this->prop['sub_fields']))) {
13db9e 931                     foreach ($entries as $entry) {
A 932                         $lrec = array_change_key_case($entry, CASE_LOWER);
933                         $rec  = array_merge($lrec, $rec);
934                     }
935                 }
936             }
a97937 937
13db9e 938             if (!empty($rec)) {
a97937 939                 // Add in the dn for the entry.
T 940                 $rec['dn'] = $dn;
941                 $res = $this->_ldap2result($rec);
203323 942                 $this->result = new rcube_result_set(1);
a97937 943                 $this->result->add($res);
6039aa 944             }
T 945         }
a97937 946
T 947         return $assoc ? $res : $this->result;
028734 948     }
TB 949
950     /**
951      * Returns the last error occurred (e.g. when updating/inserting failed)
952      *
953      * @return array Hash array with the following fields: type, message
954      */
955     function get_error()
956     {
957         $err = $this->error;
958
959         // check ldap connection for errors
960         if (!$err && $this->ldap->get_error()) {
961             $err = array(self::ERROR_SEARCH, $this->ldap->get_error());
962         }
963
964         return $err;
f11541 965     }
T 966
967
a97937 968     /**
e84818 969      * Check the given data before saving.
T 970      * If input not valid, the message to display can be fetched using get_error()
971      *
972      * @param array Assoziative array with data to save
39cafa 973      * @param boolean Try to fix/complete record automatically
e84818 974      * @return boolean True if input is valid, False if not.
T 975      */
39cafa 976     public function validate(&$save_data, $autofix = false)
e84818 977     {
19fccd 978         // validate e-mail addresses
A 979         if (!parent::validate($save_data, $autofix)) {
980             return false;
981         }
982
e84818 983         // check for name input
T 984         if (empty($save_data['name'])) {
39cafa 985             $this->set_error(self::ERROR_VALIDATE, 'nonamewarning');
T 986             return false;
987         }
988
989         // Verify that the required fields are set.
990         $missing = null;
991         $ldap_data = $this->_map_data($save_data);
992         foreach ($this->prop['required_fields'] as $fld) {
19fccd 993             if (!isset($ldap_data[$fld]) || $ldap_data[$fld] === '') {
39cafa 994                 $missing[$fld] = 1;
T 995             }
996         }
997
998         if ($missing) {
999             // try to complete record automatically
1000             if ($autofix) {
19fccd 1001                 $sn_field    = $this->fieldmap['surname'];
A 1002                 $fn_field    = $this->fieldmap['firstname'];
9336ba 1003                 $mail_field  = $this->fieldmap['email'];
A 1004
1005                 // try to extract surname and firstname from displayname
1006                 $name_parts  = preg_split('/[\s,.]+/', $save_data['name']);
19fccd 1007
A 1008                 if ($sn_field && $missing[$sn_field]) {
1009                     $save_data['surname'] = array_pop($name_parts);
1010                     unset($missing[$sn_field]);
39cafa 1011                 }
T 1012
19fccd 1013                 if ($fn_field && $missing[$fn_field]) {
A 1014                     $save_data['firstname'] = array_shift($name_parts);
1015                     unset($missing[$fn_field]);
1016                 }
9336ba 1017
A 1018                 // try to fix missing e-mail, very often on import
1019                 // from vCard we have email:other only defined
1020                 if ($mail_field && $missing[$mail_field]) {
1021                     $emails = $this->get_col_values('email', $save_data, true);
1022                     if (!empty($emails) && ($email = array_shift($emails))) {
1023                         $save_data['email'] = $email;
1024                         unset($missing[$mail_field]);
1025                     }
1026                 }
39cafa 1027             }
T 1028
1029             // TODO: generate message saying which fields are missing
19fccd 1030             if (!empty($missing)) {
A 1031                 $this->set_error(self::ERROR_VALIDATE, 'formincomplete');
1032                 return false;
1033             }
e84818 1034         }
cc90ed 1035
19fccd 1036         return true;
e84818 1037     }
T 1038
1039
1040     /**
cc90ed 1041      * Create a new contact record
A 1042      *
1043      * @param array    Hash array with save data
1044      *
1045      * @return encoded record ID on success, False on error
1046      */
a97937 1047     function insert($save_cols)
f11541 1048     {
a97937 1049         // Map out the column names to their LDAP ones to build the new entry.
39cafa 1050         $newentry = $this->_map_data($save_cols);
a97937 1051         $newentry['objectClass'] = $this->prop['LDAP_Object_Classes'];
T 1052
3f250a 1053         // add automatically generated attributes
TB 1054         $this->add_autovalues($newentry);
1055
a97937 1056         // Verify that the required fields are set.
6f45fa 1057         $missing = null;
a97937 1058         foreach ($this->prop['required_fields'] as $fld) {
T 1059             if (!isset($newentry[$fld])) {
1060                 $missing[] = $fld;
1061             }
1062         }
1063
1064         // abort process if requiered fields are missing
1065         // TODO: generate message saying which fields are missing
1066         if ($missing) {
39cafa 1067             $this->set_error(self::ERROR_VALIDATE, 'formincomplete');
a97937 1068             return false;
T 1069         }
1070
1071         // Build the new entries DN.
4500b2 1072         $dn = $this->prop['LDAP_rdn'].'='.rcube_ldap_generic::quote_string($newentry[$this->prop['LDAP_rdn']], true).','.$this->base_dn;
a97937 1073
13db9e 1074         // Remove attributes that need to be added separately (child objects)
A 1075         $xfields = array();
1076         if (!empty($this->prop['sub_fields']) && is_array($this->prop['sub_fields'])) {
3725cf 1077             foreach (array_keys($this->prop['sub_fields']) as $xf) {
13db9e 1078                 if (!empty($newentry[$xf])) {
A 1079                     $xfields[$xf] = $newentry[$xf];
1080                     unset($newentry[$xf]);
1081                 }
1082             }
1083         }
a97937 1084
6ac939 1085         if (!$this->ldap->add_entry($dn, $newentry)) {
a97937 1086             $this->set_error(self::ERROR_SAVING, 'errorsaving');
T 1087             return false;
13db9e 1088         }
4f9c83 1089
13db9e 1090         foreach ($xfields as $xidx => $xf) {
4500b2 1091             $xdn = $xidx.'='.rcube_ldap_generic::quote_string($xf).','.$dn;
13db9e 1092             $xf = array(
A 1093                 $xidx => $xf,
1094                 'objectClass' => (array) $this->prop['sub_fields'][$xidx],
1095             );
1096
6ac939 1097             $this->ldap->add_entry($xdn, $xf);
13db9e 1098         }
4f9c83 1099
c3ba0e 1100         $dn = self::dn_encode($dn);
A 1101
a97937 1102         // add new contact to the selected group
5d692b 1103         if ($this->group_id)
c3ba0e 1104             $this->add_to_group($this->group_id, $dn);
4f9c83 1105
c3ba0e 1106         return $dn;
e83f03 1107     }
A 1108
4f9c83 1109
a97937 1110     /**
cc90ed 1111      * Update a specific contact record
A 1112      *
1113      * @param mixed Record identifier
1114      * @param array Hash array with save data
1115      *
1116      * @return boolean True on success, False on error
1117      */
a97937 1118     function update($id, $save_cols)
f11541 1119     {
a97937 1120         $record = $this->get_record($id, true);
4aaecb 1121
13db9e 1122         $newdata     = array();
a97937 1123         $replacedata = array();
13db9e 1124         $deletedata  = array();
A 1125         $subdata     = array();
1126         $subdeldata  = array();
1127         $subnewdata  = array();
c3ba0e 1128
d6aafd 1129         $ldap_data = $this->_map_data($save_cols);
13db9e 1130         $old_data  = $record['_raw_attrib'];
1803f8 1131
21a0d9 1132         // special handling of photo col
A 1133         if ($photo_fld = $this->fieldmap['photo']) {
1134             // undefined means keep old photo
1135             if (!array_key_exists('photo', $save_cols)) {
1136                 $ldap_data[$photo_fld] = $record['photo'];
1137             }
1138         }
1139
3725cf 1140         foreach ($this->fieldmap as $fld) {
a97937 1141             if ($fld) {
13db9e 1142                 $val = $ldap_data[$fld];
A 1143                 $old = $old_data[$fld];
a97937 1144                 // remove empty array values
T 1145                 if (is_array($val))
1146                     $val = array_filter($val);
13db9e 1147                 // $this->_map_data() result and _raw_attrib use different format
A 1148                 // make sure comparing array with one element with a string works as expected
1149                 if (is_array($old) && count($old) == 1 && !is_array($val)) {
1150                     $old = array_pop($old);
21a0d9 1151                 }
A 1152                 if (is_array($val) && count($val) == 1 && !is_array($old)) {
1153                     $val = array_pop($val);
13db9e 1154                 }
A 1155                 // Subentries must be handled separately
1156                 if (!empty($this->prop['sub_fields']) && isset($this->prop['sub_fields'][$fld])) {
1157                     if ($old != $val) {
1158                         if ($old !== null) {
1159                             $subdeldata[$fld] = $old;
1160                         }
1161                         if ($val) {
1162                             $subnewdata[$fld] = $val;
1163                         }
1164                     }
1165                     else if ($old !== null) {
1166                         $subdata[$fld] = $old;
1167                     }
1168                     continue;
1169                 }
21a0d9 1170
a97937 1171                 // The field does exist compare it to the ldap record.
13db9e 1172                 if ($old != $val) {
a97937 1173                     // Changed, but find out how.
13db9e 1174                     if ($old === null) {
a97937 1175                         // Field was not set prior, need to add it.
T 1176                         $newdata[$fld] = $val;
1803f8 1177                     }
T 1178                     else if ($val == '') {
a97937 1179                         // Field supplied is empty, verify that it is not required.
T 1180                         if (!in_array($fld, $this->prop['required_fields'])) {
afaccf 1181                             // ...It is not, safe to clear.
AM 1182                             // #1488420: Workaround "ldap_mod_del(): Modify: Inappropriate matching in..."
1183                             // jpegPhoto attribute require an array() here. It looks to me that it works for other attribs too
1184                             $deletedata[$fld] = array();
1185                             //$deletedata[$fld] = $old_data[$fld];
1803f8 1186                         }
13db9e 1187                     }
a97937 1188                     else {
T 1189                         // The data was modified, save it out.
1190                         $replacedata[$fld] = $val;
1803f8 1191                     }
a97937 1192                 } // end if
T 1193             } // end if
1194         } // end foreach
a605b2 1195
T 1196         // console($old_data, $ldap_data, '----', $newdata, $replacedata, $deletedata, '----', $subdata, $subnewdata, $subdeldata);
1197
c3ba0e 1198         $dn = self::dn_decode($id);
a97937 1199
T 1200         // Update the entry as required.
1201         if (!empty($deletedata)) {
1202             // Delete the fields.
203323 1203             if (!$this->ldap->mod_del($dn, $deletedata)) {
a97937 1204                 $this->set_error(self::ERROR_SAVING, 'errorsaving');
T 1205                 return false;
1206             }
1207         } // end if
1208
1209         if (!empty($replacedata)) {
1210             // Handle RDN change
1211             if ($replacedata[$this->prop['LDAP_rdn']]) {
1212                 $newdn = $this->prop['LDAP_rdn'].'='
4500b2 1213                     .rcube_ldap_generic::quote_string($replacedata[$this->prop['LDAP_rdn']], true)
d1e08f 1214                     .','.$this->base_dn;
a97937 1215                 if ($dn != $newdn) {
T 1216                     $newrdn = $this->prop['LDAP_rdn'].'='
4500b2 1217                     .rcube_ldap_generic::quote_string($replacedata[$this->prop['LDAP_rdn']], true);
a97937 1218                     unset($replacedata[$this->prop['LDAP_rdn']]);
T 1219                 }
1220             }
1221             // Replace the fields.
1222             if (!empty($replacedata)) {
203323 1223                 if (!$this->ldap->mod_replace($dn, $replacedata)) {
13db9e 1224                     $this->set_error(self::ERROR_SAVING, 'errorsaving');
a97937 1225                     return false;
T 1226                 }
13db9e 1227             }
a97937 1228         } // end if
13db9e 1229
A 1230         // RDN change, we need to remove all sub-entries
1231         if (!empty($newrdn)) {
1232             $subdeldata = array_merge($subdeldata, $subdata);
1233             $subnewdata = array_merge($subnewdata, $subdata);
1234         }
1235
1236         // remove sub-entries
1237         if (!empty($subdeldata)) {
1238             foreach ($subdeldata as $fld => $val) {
4500b2 1239                 $subdn = $fld.'='.rcube_ldap_generic::quote_string($val).','.$dn;
6ac939 1240                 if (!$this->ldap->delete_entry($subdn)) {
13db9e 1241                     return false;
A 1242                 }
1243             }
1244         }
a97937 1245
T 1246         if (!empty($newdata)) {
1247             // Add the fields.
203323 1248             if (!$this->ldap->mod_add($dn, $newdata)) {
a97937 1249                 $this->set_error(self::ERROR_SAVING, 'errorsaving');
T 1250                 return false;
1251             }
1252         } // end if
1253
1254         // Handle RDN change
1255         if (!empty($newrdn)) {
203323 1256             if (!$this->ldap->rename($dn, $newrdn, null, true)) {
13db9e 1257                 $this->set_error(self::ERROR_SAVING, 'errorsaving');
a97937 1258                 return false;
T 1259             }
1260
c3ba0e 1261             $dn    = self::dn_encode($dn);
A 1262             $newdn = self::dn_encode($newdn);
1263
a97937 1264             // change the group membership of the contact
13db9e 1265             if ($this->groups) {
c3ba0e 1266                 $group_ids = $this->get_record_groups($dn);
42b9ce 1267                 foreach (array_keys($group_ids) as $group_id) {
c3ba0e 1268                     $this->remove_from_group($group_id, $dn);
A 1269                     $this->add_to_group($group_id, $newdn);
a97937 1270                 }
T 1271             }
c3ba0e 1272
13db9e 1273             $dn = self::dn_decode($newdn);
a97937 1274         }
T 1275
13db9e 1276         // add sub-entries
A 1277         if (!empty($subnewdata)) {
1278             foreach ($subnewdata as $fld => $val) {
4500b2 1279                 $subdn = $fld.'='.rcube_ldap_generic::quote_string($val).','.$dn;
13db9e 1280                 $xf = array(
A 1281                     $fld => $val,
1282                     'objectClass' => (array) $this->prop['sub_fields'][$fld],
1283                 );
6ac939 1284                 $this->ldap->add_entry($subdn, $xf);
13db9e 1285             }
A 1286         }
1287
1288         return $newdn ? $newdn : true;
f11541 1289     }
a97937 1290
T 1291
1292     /**
cc90ed 1293      * Mark one or more contact records as deleted
A 1294      *
63fda8 1295      * @param array   Record identifiers
A 1296      * @param boolean Remove record(s) irreversible (unsupported)
cc90ed 1297      *
A 1298      * @return boolean True on success, False on error
1299      */
63fda8 1300     function delete($ids, $force=true)
f11541 1301     {
a97937 1302         if (!is_array($ids)) {
T 1303             // Not an array, break apart the encoded DNs.
0f1fae 1304             $ids = explode(',', $ids);
a97937 1305         } // end if
T 1306
0f1fae 1307         foreach ($ids as $id) {
c3ba0e 1308             $dn = self::dn_decode($id);
13db9e 1309
A 1310             // Need to delete all sub-entries first
1311             if ($this->sub_filter) {
203323 1312                 if ($entries = $this->ldap->list_entries($dn, $this->sub_filter)) {
13db9e 1313                     foreach ($entries as $entry) {
6ac939 1314                         if (!$this->ldap->delete_entry($entry['dn'])) {
13db9e 1315                             $this->set_error(self::ERROR_SAVING, 'errorsaving');
A 1316                             return false;
1317                         }
1318                     }
1319                 }
1320             }
1321
a97937 1322             // Delete the record.
6ac939 1323             if (!$this->ldap->delete_entry($dn)) {
a97937 1324                 $this->set_error(self::ERROR_SAVING, 'errorsaving');
T 1325                 return false;
13db9e 1326             }
a97937 1327
6ac939 1328             // remove contact from all groups where he was a member
c3ba0e 1329             if ($this->groups) {
A 1330                 $dn = self::dn_encode($dn);
1331                 $group_ids = $this->get_record_groups($dn);
42b9ce 1332                 foreach (array_keys($group_ids) as $group_id) {
c3ba0e 1333                     $this->remove_from_group($group_id, $dn);
a97937 1334                 }
T 1335             }
1336         } // end foreach
1337
0f1fae 1338         return count($ids);
d6eb7c 1339     }
A 1340
1341
1342     /**
1343      * Remove all contact records
18b40c 1344      *
AM 1345      * @param bool $with_groups Delete also groups if enabled
d6eb7c 1346      */
18b40c 1347     function delete_all($with_groups = false)
d6eb7c 1348     {
203323 1349         // searching for contact entries
TB 1350         $dn_list = $this->ldap->list_entries($this->base_dn, $this->prop['filter'] ? $this->prop['filter'] : '(objectclass=*)');
d6eb7c 1351
A 1352         if (!empty($dn_list)) {
1353             foreach ($dn_list as $idx => $entry) {
1354                 $dn_list[$idx] = self::dn_encode($entry['dn']);
1355             }
1356             $this->delete($dn_list);
1357         }
18b40c 1358
AM 1359         if ($with_groups && $this->groups && ($groups = $this->_fetch_groups()) && count($groups)) {
1360             foreach ($groups as $group) {
6ac939 1361                 $this->ldap->delete_entry($group['dn']);
18b40c 1362             }
AM 1363
1364             if ($this->cache) {
1365                 $this->cache->remove('groups');
1366             }
1367         }
f11541 1368     }
4368a0 1369
3f250a 1370     /**
TB 1371      * Generate missing attributes as configured
1372      *
1373      * @param array LDAP record attributes
1374      */
1375     protected function add_autovalues(&$attrs)
1376     {
4741d1 1377         if (empty($this->prop['autovalues'])) {
AM 1378             return;
1379         }
1380
3f250a 1381         $attrvals = array();
TB 1382         foreach ($attrs as $k => $v) {
1383             $attrvals['{'.$k.'}'] = is_array($v) ? $v[0] : $v;
1384         }
1385
1386         foreach ((array)$this->prop['autovalues'] as $lf => $templ) {
1387             if (empty($attrs[$lf])) {
c2e1ab 1388                 if (strpos($templ, '(') !== false) {
TB 1389                     // replace {attr} placeholders with (escaped!) attribute values to be safely eval'd
1390                     $code = preg_replace('/\{\w+\}/', '', strtr($templ, array_map('addslashes', $attrvals)));
4741d1 1391                     $fn   = create_function('', "return ($code);");
AM 1392                     if (!$fn) {
1393                         rcube::raise_error(array(
1394                             'code' => 505, 'type' => 'php',
1395                             'file' => __FILE__, 'line' => __LINE__,
1396                             'message' => "Expression parse error on: ($code)"), true, false);
1397                         continue;
1398                     }
1399
1400                     $attrs[$lf] = $fn();
c2e1ab 1401                 }
TB 1402                 else {
1403                     // replace {attr} placeholders with concrete attribute values
1404                     $attrs[$lf] = preg_replace('/\{\w+\}/', '', strtr($templ, $attrvals));
1405                 }
3f250a 1406             }
TB 1407         }
1408     }
4368a0 1409
a97937 1410
T 1411     /**
cc90ed 1412      * Converts LDAP entry into an array
A 1413      */
a97937 1414     private function _ldap2result($rec)
T 1415     {
c5a5f9 1416         $out = array('_type' => 'person');
ec2185 1417         $fieldmap = $this->fieldmap;
a97937 1418
T 1419         if ($rec['dn'])
c3ba0e 1420             $out[$this->primary_key] = self::dn_encode($rec['dn']);
a97937 1421
c5a5f9 1422         // determine record type
4c02ef 1423         if ($this->is_group_entry($rec)) {
c5a5f9 1424             $out['_type'] = 'group';
TB 1425             $out['readonly'] = true;
8862f6 1426             $fieldmap['name'] = $this->group_data['name_attr'] ? $this->group_data['name_attr'] : $this->prop['groups']['name_attr'];
c5a5f9 1427         }
a97937 1428
532c10 1429         // assign object type from object class mapping
TB 1430         if (!empty($this->prop['class_type_map'])) {
1431             foreach (array_map('strtolower', (array)$rec['objectclass']) as $objcls) {
1432                 if (!empty($this->prop['class_type_map'][$objcls])) {
1433                     $out['_type'] = $this->prop['class_type_map'][$objcls];
1434                     break;
1435                 }
1436             }
1437         }
1438
ec2185 1439         foreach ($fieldmap as $rf => $lf)
a97937 1440         {
T 1441             for ($i=0; $i < $rec[$lf]['count']; $i++) {
1442                 if (!($value = $rec[$lf][$i]))
1443                     continue;
1803f8 1444
ad8c9d 1445                 list($col, $subtype) = explode(':', $rf);
1803f8 1446                 $out['_raw_attrib'][$lf][$i] = $value;
T 1447
1078a6 1448                 if ($col == 'email' && $this->mail_domain && !strpos($value, '@'))
a97937 1449                     $out[$rf][] = sprintf('%s@%s', $value, $this->mail_domain);
ad8c9d 1450                 else if (in_array($col, array('street','zipcode','locality','country','region')))
T 1451                     $out['address'.($subtype?':':'').$subtype][$i][$col] = $value;
a605b2 1452                 else if ($col == 'address' && strpos($value, '$') !== false)  // address data is represented as string separated with $
T 1453                     list($out[$rf][$i]['street'], $out[$rf][$i]['locality'], $out[$rf][$i]['zipcode'], $out[$rf][$i]['country']) = explode('$', $value);
a97937 1454                 else if ($rec[$lf]['count'] > 1)
T 1455                     $out[$rf][] = $value;
1456                 else
1457                     $out[$rf] = $value;
1458             }
b1f084 1459
A 1460             // Make sure name fields aren't arrays (#1488108)
1461             if (is_array($out[$rf]) && in_array($rf, array('name', 'surname', 'firstname', 'middlename', 'nickname'))) {
1803f8 1462                 $out[$rf] = $out['_raw_attrib'][$lf] = $out[$rf][0];
b1f084 1463             }
a97937 1464         }
T 1465
1466         return $out;
1467     }
1468
1469
1470     /**
1078a6 1471      * Return LDAP attribute(s) for the given field
cc90ed 1472      */
a97937 1473     private function _map_field($field)
T 1474     {
1078a6 1475         return (array)$this->coltypes[$field]['attributes'];
a97937 1476     }
T 1477
1478
1479     /**
39cafa 1480      * Convert a record data set into LDAP field attributes
T 1481      */
1482     private function _map_data($save_cols)
1483     {
d6aafd 1484         // flatten composite fields first
T 1485         foreach ($this->coltypes as $col => $colprop) {
1486             if (is_array($colprop['childs']) && ($values = $this->get_col_values($col, $save_cols, false))) {
1487                 foreach ($values as $subtype => $childs) {
1488                     $subtype = $subtype ? ':'.$subtype : '';
1489                     foreach ($childs as $i => $child_values) {
1490                         foreach ((array)$child_values as $childcol => $value) {
1491                             $save_cols[$childcol.$subtype][$i] = $value;
1492                         }
1493                     }
1494                 }
1495             }
a605b2 1496
T 1497             // if addresses are to be saved as serialized string, do so
1498             if (is_array($colprop['serialized'])) {
1499                foreach ($colprop['serialized'] as $subtype => $delim) {
1500                   $key = $col.':'.$subtype;
49cb69 1501                   foreach ((array)$save_cols[$key] as $i => $val) {
TB 1502                      $values = array($val['street'], $val['locality'], $val['zipcode'], $val['country']);
1503                      $save_cols[$key][$i] = count(array_filter($values)) ? join($delim, $values) : null;
1504                  }
a605b2 1505                }
T 1506             }
d6aafd 1507         }
T 1508
39cafa 1509         $ldap_data = array();
1078a6 1510         foreach ($this->fieldmap as $rf => $fld) {
TB 1511             $val = $save_cols[$rf];
1512
1513             // check for value in base field (eg.g email instead of email:foo)
1514             list($col, $subtype) = explode(':', $rf);
1515             if (!$val && !empty($save_cols[$col])) {
1516                 $val = $save_cols[$col];
1517                 unset($save_cols[$col]);  // only use this value once
1518             }
1519             else if (!$val && !$subtype) { // extract values from subtype cols
1520                 $val = $this->get_col_values($col, $save_cols, true);
1521             }
1522
39cafa 1523             if (is_array($val))
T 1524                 $val = array_filter($val);  // remove empty entries
1525             if ($fld && $val) {
1526                 // The field does exist, add it to the entry.
1527                 $ldap_data[$fld] = $val;
1528             }
1529         }
13db9e 1530
39cafa 1531         return $ldap_data;
T 1532     }
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     /**
cc90ed 1566      * Prints debug info to the log
A 1567      */
a97937 1568     private function _debug($str)
T 1569     {
0c2596 1570         if ($this->debug) {
be98df 1571             rcube::write_log('ldap', $str);
0c2596 1572         }
a97937 1573     }
T 1574
1575
1576     /**
06744d 1577      * Activate/deactivate debug mode
T 1578      *
1579      * @param boolean $dbg True if LDAP commands should be logged
1580      */
1581     function set_debug($dbg = true)
1582     {
1583         $this->debug = $dbg;
fae90d 1584
AM 1585         if ($this->ldap) {
6ac939 1586             $this->ldap->config_set('debug', $dbg);
fae90d 1587         }
06744d 1588     }
T 1589
1590
1591     /**
6039aa 1592      * Setter for the current group
T 1593      */
1594     function set_group($group_id)
1595     {
ec2185 1596         if ($group_id) {
6039aa 1597             $this->group_id = $group_id;
ec2185 1598             $this->group_data = $this->get_group_entry($group_id);
6039aa 1599         }
ec2185 1600         else {
a97937 1601             $this->group_id = 0;
a31482 1602             $this->group_data = null;
8fb04b 1603         }
6039aa 1604     }
T 1605
1606     /**
1607      * List all active contact groups of this source
1608      *
1609      * @param string  Optional search string to match group name
ec4331 1610      * @param int     Matching mode:
AM 1611      *                0 - partial (*abc*),
1612      *                1 - strict (=),
1613      *                2 - prefix (abc*)
1614      *
6039aa 1615      * @return array  Indexed list of contact groups, each a hash array
T 1616      */
ec4331 1617     function list_groups($search = null, $mode = 0)
6039aa 1618     {
4feb8e 1619         if (!$this->groups) {
a97937 1620             return array();
b20025 1621         }
a31482 1622
834fb6 1623         $group_cache = $this->_fetch_groups($search, $mode);
4feb8e 1624         $groups      = array();
AM 1625
a31482 1626         if ($search) {
T 1627             foreach ($group_cache as $group) {
028734 1628                 if ($this->compare_search_value('name', $group['name'], mb_strtolower($search), $mode)) {
a31482 1629                     $groups[] = $group;
ec4331 1630                 }
a31482 1631             }
T 1632         }
4feb8e 1633         else {
a31482 1634             $groups = $group_cache;
4feb8e 1635         }
a31482 1636
T 1637         return array_values($groups);
1638     }
1639
1640     /**
1641      * Fetch groups from server
1642      */
834fb6 1643     private function _fetch_groups($search = null, $mode = 0, $vlv_page = null)
a31482 1644     {
834fb6 1645         // reset group search cache
TB 1646         if ($search !== null && $vlv_page === null) {
1647             $this->group_search_cache = null;
1648         }
1649         // return in-memory cache from previous search results
1650         else if (is_array($this->group_search_cache) && $vlv_page === null) {
1651             return $this->group_search_cache;
1652         }
1653
ec2185 1654         // special case: list groups from 'group_filters' config
834fb6 1655         if ($vlv_page === null && $search === null && is_array($this->prop['group_filters'])) {
ec2185 1656             $groups = array();
6b2b2e 1657             $rcube  = rcube::get_instance();
ec2185 1658
TB 1659             // list regular groups configuration as special filter
1660             if (!empty($this->prop['groups']['filter'])) {
1661                 $id = '__groups__';
6b2b2e 1662                 $groups[$id] = array('ID' => $id, 'name' => $rcube->gettext('groups'), 'virtual' => true) + $this->prop['groups'];
ec2185 1663             }
TB 1664
1665             foreach ($this->prop['group_filters'] as $id => $prop) {
203323 1666                 $groups[$id] = $prop + array('ID' => $id, 'name' => ucfirst($id), 'virtual' => true, 'base_dn' => $this->base_dn);
ec2185 1667             }
TB 1668
1669             return $groups;
1670         }
1671
834fb6 1672         if ($this->cache && $search === null && $vlv_page === null && ($groups = $this->cache->get('groups')) !== null) {
4feb8e 1673             return $groups;
AM 1674         }
1675
1676         $base_dn    = $this->groups_base_dn;
1677         $filter     = $this->prop['groups']['filter'];
4287c9 1678         $scope      = $this->prop['groups']['scope'];
4feb8e 1679         $name_attr  = $this->prop['groups']['name_attr'];
a31482 1680         $email_attr = $this->prop['groups']['email_attr'] ? $this->prop['groups']['email_attr'] : 'mail';
fb6cc8 1681         $sort_attrs = $this->prop['groups']['sort'] ? (array)$this->prop['groups']['sort'] : array($name_attr);
4feb8e 1682         $sort_attr  = $sort_attrs[0];
6039aa 1683
203323 1684         $ldap = $this->ldap;
755189 1685
fb6cc8 1686         // use vlv to list groups
T 1687         if ($this->prop['groups']['vlv']) {
1688             $page_size = 200;
4feb8e 1689             if (!$this->prop['groups']['sort']) {
fb6cc8 1690                 $this->prop['groups']['sort'] = $sort_attrs;
4feb8e 1691             }
203323 1692
TB 1693             $ldap = clone $this->ldap;
6ac939 1694             $ldap->config_set($this->prop['groups']);
203323 1695             $ldap->set_vlv_page($vlv_page+1, $page_size);
fb6cc8 1696         }
T 1697
834fb6 1698         $props = array('sort' => $this->prop['groups']['sort']);
TB 1699         $attrs = array_unique(array('dn', 'objectClass', $name_attr, $email_attr, $sort_attr));
1700
1701         // add search filter
1702         if ($search !== null) {
1703             // set wildcards
1704             $wp = $ws = '';
1705             if (!empty($this->prop['fuzzy_search']) && $mode != 1) {
1706                 $ws = '*';
1707                 $wp = !$mode ? '*' : '';
1708             }
1709             $filter = "(&$filter($name_attr=$wp" . rcube_ldap_generic::quote_string($search) . "$ws))";
1710             $props['search'] = $wp . $search . $ws;
1711         }
1712
1713         $ldap_data = $ldap->search($base_dn, $filter, $scope, $attrs, $props);
4feb8e 1714
203323 1715         if ($ldap_data === false) {
6039aa 1716             return array();
T 1717         }
755189 1718
4feb8e 1719         $groups          = array();
6039aa 1720         $group_sortnames = array();
4feb8e 1721         $group_count     = $ldap_data->count();
AM 1722
203323 1723         foreach ($ldap_data as $entry) {
TB 1724             if (!$entry['dn'])  // DN is mandatory
1725                 $entry['dn'] = $ldap_data->get_dn();
1726
1727             $group_name = is_array($entry[$name_attr]) ? $entry[$name_attr][0] : $entry[$name_attr];
1728             $group_id = self::dn_encode($entry['dn']);
a31482 1729             $groups[$group_id]['ID'] = $group_id;
203323 1730             $groups[$group_id]['dn'] = $entry['dn'];
a31482 1731             $groups[$group_id]['name'] = $group_name;
203323 1732             $groups[$group_id]['member_attr'] = $this->get_group_member_attr($entry['objectclass']);
a31482 1733
T 1734             // list email attributes of a group
203323 1735             for ($j=0; $entry[$email_attr] && $j < $entry[$email_attr]['count']; $j++) {
TB 1736                 if (strpos($entry[$email_attr][$j], '@') > 0)
1737                     $groups[$group_id]['email'][] = $entry[$email_attr][$j];
a31482 1738             }
T 1739
203323 1740             $group_sortnames[] = mb_strtolower($entry[$sort_attr][0]);
6039aa 1741         }
fb6cc8 1742
T 1743         // recursive call can exit here
4feb8e 1744         if ($vlv_page > 0) {
fb6cc8 1745             return $groups;
4feb8e 1746         }
fb6cc8 1747
T 1748         // call recursively until we have fetched all groups
203323 1749         while ($this->prop['groups']['vlv'] && $group_count == $page_size) {
834fb6 1750             $next_page   = $this->_fetch_groups($search, $mode, ++$vlv_page);
4feb8e 1751             $groups      = array_merge($groups, $next_page);
fb6cc8 1752             $group_count = count($next_page);
T 1753         }
1754
1755         // when using VLV the list of groups is already sorted
4feb8e 1756         if (!$this->prop['groups']['vlv']) {
fb6cc8 1757             array_multisort($group_sortnames, SORT_ASC, SORT_STRING, $groups);
4feb8e 1758         }
8fb04b 1759
T 1760         // cache this
834fb6 1761         if ($this->cache && $search === null) {
b20025 1762             $this->cache->set('groups', $groups);
AM 1763         }
834fb6 1764         else if ($search !== null) {
TB 1765             $this->group_search_cache = $groups;
1766         }
a97937 1767
6039aa 1768         return $groups;
a31482 1769     }
T 1770
1771     /**
ec2185 1772      * Fetch a group entry from LDAP and save in local cache
TB 1773      */
1774     private function get_group_entry($group_id)
1775     {
4feb8e 1776         $group_cache = $this->_fetch_groups();
ec2185 1777
TB 1778         // add group record to cache if it isn't yet there
1779         if (!isset($group_cache[$group_id])) {
1780             $name_attr = $this->prop['groups']['name_attr'];
1781             $dn = self::dn_decode($group_id);
1782
203323 1783             if ($list = $this->ldap->read_entries($dn, '(objectClass=*)', array('dn','objectClass','member','uniqueMember','memberURL',$name_attr,$this->fieldmap['email']))) {
ec2185 1784                 $entry = $list[0];
TB 1785                 $group_name = is_array($entry[$name_attr]) ? $entry[$name_attr][0] : $entry[$name_attr];
1786                 $group_cache[$group_id]['ID'] = $group_id;
1787                 $group_cache[$group_id]['dn'] = $dn;
1788                 $group_cache[$group_id]['name'] = $group_name;
1789                 $group_cache[$group_id]['member_attr'] = $this->get_group_member_attr($entry['objectclass']);
1790             }
1791             else {
1792                 $group_cache[$group_id] = false;
1793             }
1794
b20025 1795             if ($this->cache) {
AM 1796                 $this->cache->set('groups', $group_cache);
1797             }
ec2185 1798         }
TB 1799
1800         return $group_cache[$group_id];
a31482 1801     }
T 1802
1803     /**
1804      * Get group properties such as name and email address(es)
1805      *
1806      * @param string Group identifier
1807      * @return array Group properties as hash array
1808      */
1809     function get_group($group_id)
1810     {
86552f 1811         $group_data = $this->get_group_entry($group_id);
a31482 1812         unset($group_data['dn'], $group_data['member_attr']);
T 1813
1814         return $group_data;
6039aa 1815     }
T 1816
1817     /**
1818      * Create a contact group with the given name
1819      *
1820      * @param string The group name
1821      * @return mixed False on error, array with record props in success
1822      */
1823     function create_group($group_name)
1824     {
4feb8e 1825         $new_dn      = 'cn=' . rcube_ldap_generic::quote_string($group_name, true) . ',' . $this->groups_base_dn;
AM 1826         $new_gid     = self::dn_encode($new_dn);
097dbc 1827         $member_attr = $this->get_group_member_attr();
4feb8e 1828         $name_attr   = $this->prop['groups']['name_attr'] ? $this->prop['groups']['name_attr'] : 'cn';
AM 1829         $new_entry   = array(
d1e08f 1830             'objectClass' => $this->prop['groups']['object_classes'],
448f81 1831             $name_attr => $group_name,
b4b377 1832             $member_attr => '',
6039aa 1833         );
T 1834
6ac939 1835         if (!$this->ldap->add_entry($new_dn, $new_entry)) {
6039aa 1836             $this->set_error(self::ERROR_SAVING, 'errorsaving');
T 1837             return false;
1838         }
755189 1839
b20025 1840         if ($this->cache) {
AM 1841             $this->cache->remove('groups');
1842         }
755189 1843
6039aa 1844         return array('id' => $new_gid, 'name' => $group_name);
T 1845     }
1846
1847     /**
1848      * Delete the given group and all linked group members
1849      *
1850      * @param string Group identifier
1851      * @return boolean True on success, false if no data was changed
1852      */
1853     function delete_group($group_id)
1854     {
4feb8e 1855         $group_cache = $this->_fetch_groups();
AM 1856         $del_dn      = $group_cache[$group_id]['dn'];
755189 1857
6ac939 1858         if (!$this->ldap->delete_entry($del_dn)) {
6039aa 1859             $this->set_error(self::ERROR_SAVING, 'errorsaving');
T 1860             return false;
1861         }
755189 1862
b20025 1863         if ($this->cache) {
AM 1864             unset($group_cache[$group_id]);
1865             $this->cache->set('groups', $group_cache);
1866         }
755189 1867
6039aa 1868         return true;
T 1869     }
1870
1871     /**
1872      * Rename a specific contact group
1873      *
1874      * @param string Group identifier
1875      * @param string New name to set for this group
360bd3 1876      * @param string New group identifier (if changed, otherwise don't set)
6039aa 1877      * @return boolean New name on success, false if no data was changed
T 1878      */
15e944 1879     function rename_group($group_id, $new_name, &$new_gid)
6039aa 1880     {
4feb8e 1881         $group_cache = $this->_fetch_groups();
AM 1882         $old_dn      = $group_cache[$group_id]['dn'];
1883         $new_rdn     = "cn=" . rcube_ldap_generic::quote_string($new_name, true);
1884         $new_gid     = self::dn_encode($new_rdn . ',' . $this->groups_base_dn);
6039aa 1885
203323 1886         if (!$this->ldap->rename($old_dn, $new_rdn, null, true)) {
6039aa 1887             $this->set_error(self::ERROR_SAVING, 'errorsaving');
T 1888             return false;
1889         }
755189 1890
b20025 1891         if ($this->cache) {
AM 1892             $this->cache->remove('groups');
1893         }
755189 1894
6039aa 1895         return $new_name;
T 1896     }
1897
1898     /**
1899      * Add the given contact records the a certain group
1900      *
40d419 1901      * @param string       Group identifier
AM 1902      * @param array|string List of contact identifiers to be added
1903      *
1904      * @return int Number of contacts added
6039aa 1905      */
T 1906     function add_to_group($group_id, $contact_ids)
1907     {
4feb8e 1908         $group_cache = $this->_fetch_groups();
8fb04b 1909         $member_attr = $group_cache[$group_id]['member_attr'];
c8a714 1910         $group_dn    = $group_cache[$group_id]['dn'];
40d419 1911         $new_attrs   = array();
6039aa 1912
4feb8e 1913         if (!is_array($contact_ids)) {
AM 1914             $contact_ids = explode(',', $contact_ids);
1915         }
1916
1917         foreach ($contact_ids as $id) {
f763fb 1918             $new_attrs[$member_attr][] = self::dn_decode($id);
4feb8e 1919         }
6039aa 1920
203323 1921         if (!$this->ldap->mod_add($group_dn, $new_attrs)) {
6039aa 1922             $this->set_error(self::ERROR_SAVING, 'errorsaving');
T 1923             return 0;
1924         }
755189 1925
b20025 1926         if ($this->cache) {
AM 1927             $this->cache->remove('groups');
1928         }
755189 1929
40d419 1930         return count($new_attrs[$member_attr]);
6039aa 1931     }
T 1932
1933     /**
1934      * Remove the given contact records from a certain group
1935      *
40d419 1936      * @param string       Group identifier
AM 1937      * @param array|string List of contact identifiers to be removed
1938      *
1939      * @return int Number of deleted group members
6039aa 1940      */
T 1941     function remove_from_group($group_id, $contact_ids)
1942     {
4feb8e 1943         $group_cache = $this->_fetch_groups();
8fb04b 1944         $member_attr = $group_cache[$group_id]['member_attr'];
c8a714 1945         $group_dn    = $group_cache[$group_id]['dn'];
4feb8e 1946         $del_attrs   = array();
6039aa 1947
4feb8e 1948         if (!is_array($contact_ids)) {
AM 1949             $contact_ids = explode(',', $contact_ids);
1950         }
1951
1952         foreach ($contact_ids as $id) {
f763fb 1953             $del_attrs[$member_attr][] = self::dn_decode($id);
4feb8e 1954         }
6039aa 1955
203323 1956         if (!$this->ldap->mod_del($group_dn, $del_attrs)) {
6039aa 1957             $this->set_error(self::ERROR_SAVING, 'errorsaving');
T 1958             return 0;
1959         }
755189 1960
b20025 1961         if ($this->cache) {
AM 1962             $this->cache->remove('groups');
1963         }
755189 1964
40d419 1965         return count($del_attrs[$member_attr]);
6039aa 1966     }
T 1967
1968     /**
1969      * Get group assignments of a specific contact record
1970      *
1971      * @param mixed Record identifier
1972      *
1973      * @return array List of assigned groups as ID=>Name pairs
1974      * @since 0.5-beta
1975      */
1976     function get_record_groups($contact_id)
1977     {
4feb8e 1978         if (!$this->groups) {
6039aa 1979             return array();
4feb8e 1980         }
6039aa 1981
f763fb 1982         $base_dn     = $this->groups_base_dn;
A 1983         $contact_dn  = self::dn_decode($contact_id);
097dbc 1984         $name_attr   = $this->prop['groups']['name_attr'] ? $this->prop['groups']['name_attr'] : 'cn';
A 1985         $member_attr = $this->get_group_member_attr();
8fb04b 1986         $add_filter  = '';
3e7b9b 1987
8fb04b 1988         if ($member_attr != 'member' && $member_attr != 'uniqueMember')
T 1989             $add_filter = "($member_attr=$contact_dn)";
1990         $filter = strtr("(|(member=$contact_dn)(uniqueMember=$contact_dn)$add_filter)", array('\\' => '\\\\'));
6039aa 1991
203323 1992         $ldap_data = $this->ldap->search($base_dn, $filter, 'sub', array('dn', $name_attr));
TB 1993         if ($res === false) {
6039aa 1994             return array();
T 1995         }
1996
1997         $groups = array();
203323 1998         foreach ($ldap_data as $entry) {
TB 1999             if (!$entry['dn'])
2000                 $entry['dn'] = $ldap_data->get_dn();
2001             $group_name = $entry[$name_attr][0];
2002             $group_id = self::dn_encode($entry['dn']);
42b9ce 2003             $groups[$group_id] = $group_name;
6039aa 2004         }
42b9ce 2005
6039aa 2006         return $groups;
T 2007     }
69ea3a 2008
097dbc 2009     /**
A 2010      * Detects group member attribute name
2011      */
1b52cf 2012     private function get_group_member_attr($object_classes = array(), $default = 'member')
097dbc 2013     {
A 2014         if (empty($object_classes)) {
2015             $object_classes = $this->prop['groups']['object_classes'];
2016         }
1b52cf 2017
097dbc 2018         if (!empty($object_classes)) {
A 2019             foreach ((array)$object_classes as $oc) {
3ce7c5 2020                 if ($attr = $this->group_types[strtolower($oc)]) {
3e7b9b 2021                     return $attr;
097dbc 2022                 }
A 2023             }
2024         }
2025
2026         if (!empty($this->prop['groups']['member_attr'])) {
2027             return $this->prop['groups']['member_attr'];
2028         }
2029
1b52cf 2030         return $default;
097dbc 2031     }
A 2032
69ea3a 2033
T 2034     /**
c3ba0e 2035      * HTML-safe DN string encoding
A 2036      *
2037      * @param string $str DN string
2038      *
2039      * @return string Encoded HTML identifier string
2040      */
2041     static function dn_encode($str)
2042     {
2043         // @TODO: to make output string shorter we could probably
2044         //        remove dc=* items from it
2045         return rtrim(strtr(base64_encode($str), '+/', '-_'), '=');
2046     }
2047
2048     /**
2049      * Decodes DN string encoded with _dn_encode()
2050      *
2051      * @param string $str Encoded HTML identifier string
2052      *
2053      * @return string DN string
2054      */
2055     static function dn_decode($str)
2056     {
2057         $str = str_pad(strtr($str, '-_', '+/'), strlen($str) % 4, '=', STR_PAD_RIGHT);
2058         return base64_decode($str);
13db9e 2059     }
A 2060
f11541 2061 }