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