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