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