source: subversion/trunk/roundcubemail/program/include/rcube_ldap.php @ 6033

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