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