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