source: subversion/branches/release-0.7/program/include/rcube_ldap.php @ 5518

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