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

Last change on this file since 5331 was 5331, checked in by thomasb, 21 months ago

Improve group member fetching

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