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