source: github/program/include/rcube_ldap.php @ cc90ed1

HEADcourier-fixdev-browser-capabilitiespdorelease-0.6release-0.7release-0.8
Last change on this file since cc90ed1 was cc90ed1, checked in by alecpl <alec@…>, 2 years ago
  • Add addressbook name in contact info frame (#1487958)
  • Property mode set to 100644
File size: 44.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 | Licensed under the GNU GPL                                            |
9 |                                                                       |
10 | PURPOSE:                                                              |
11 |   Interface to an LDAP address directory                              |
12 |                                                                       |
13 +-----------------------------------------------------------------------+
14 | Author: Thomas Bruederli <roundcube@gmail.com>                        |
15 |         Andreas Dick <andudi (at) gmx (dot) ch>                       |
16 +-----------------------------------------------------------------------+
17
18 $Id$
19
20*/
21
22
23/**
24 * Model class to access an LDAP address directory
25 *
26 * @package Addressbook
27 */
28class rcube_ldap extends rcube_addressbook
29{
30    /** public properties */
31    public $primary_key = 'ID';
32    public $groups = false;
33    public $readonly = true;
34    public $ready = false;
35    public $group_id = 0;
36    public $list_page = 1;
37    public $page_size = 10;
38    public $coltypes = array();
39
40    /** private properties */
41    protected $conn;
42    protected $prop = array();
43    protected $fieldmap = array();
44
45    protected $filter = '';
46    protected $result = null;
47    protected $ldap_result = null;
48    protected $sort_col = '';
49    protected $mail_domain = '';
50    protected $debug = false;
51
52    private $base_dn = '';
53    private $groups_base_dn = '';
54    private $group_cache = array();
55    private $group_members = array();
56
57    private $vlv_active = false;
58    private $vlv_count = 0;
59
60
61    /**
62    * Object constructor
63    *
64    * @param array      LDAP connection properties
65    * @param boolean    Enables debug mode
66    * @param string     Current user mail domain name
67    * @param integer User-ID
68    */
69    function __construct($p, $debug=false, $mail_domain=NULL)
70    {
71        $this->prop = $p;
72
73        // check if groups are configured
74        if (is_array($p['groups']) and count($p['groups']))
75            $this->groups = true;
76
77        // fieldmap property is given
78        if (is_array($p['fieldmap'])) {
79            foreach ($p['fieldmap'] as $rf => $lf)
80                $this->fieldmap[$rf] = $this->_attr_name(strtolower($lf));
81        }
82        else {
83            // read deprecated *_field properties to remain backwards compatible
84            foreach ($p as $prop => $value)
85                if (preg_match('/^(.+)_field$/', $prop, $matches))
86                    $this->fieldmap[$matches[1]] = $this->_attr_name(strtolower($value));
87        }
88
89        // use fieldmap to advertise supported coltypes to the application
90        foreach ($this->fieldmap as $col => $lf) {
91            list($col, $type) = explode(':', $col);
92            if (!is_array($this->coltypes[$col])) {
93                $subtypes = $type ? array($type) : null;
94                $this->coltypes[$col] = array('limit' => 2, 'subtypes' => $subtypes);
95            }
96            elseif ($type) {
97                $this->coltypes[$col]['subtypes'][] = $type;
98                $this->coltypes[$col]['limit']++;
99            }
100            if ($type && !$this->fieldmap[$col])
101                $this->fieldmap[$col] = $lf;
102        }
103
104        if ($this->fieldmap['street'] && $this->fieldmap['locality'])
105            $this->coltypes['address'] = array('limit' => 1);
106        else if ($this->coltypes['address'])
107            $this->coltypes['address'] = array('type' => 'textarea', 'childs' => null, 'limit' => 1, 'size' => 40);
108
109        // make sure 'required_fields' is an array
110        if (!is_array($this->prop['required_fields']))
111            $this->prop['required_fields'] = (array) $this->prop['required_fields'];
112
113        foreach ($this->prop['required_fields'] as $key => $val)
114            $this->prop['required_fields'][$key] = $this->_attr_name(strtolower($val));
115
116        $this->sort_col = is_array($p['sort']) ? $p['sort'][0] : $p['sort'];
117        $this->debug = $debug;
118        $this->mail_domain = $mail_domain;
119
120        $this->_connect();
121    }
122
123
124    /**
125    * Establish a connection to the LDAP server
126    */
127    private function _connect()
128    {
129        global $RCMAIL;
130
131        if (!function_exists('ldap_connect'))
132            raise_error(array('code' => 100, 'type' => 'ldap',
133                'file' => __FILE__, 'line' => __LINE__,
134                'message' => "No ldap support in this installation of PHP"),
135                true, true);
136
137        if (is_resource($this->conn))
138            return true;
139
140        if (!is_array($this->prop['hosts']))
141            $this->prop['hosts'] = array($this->prop['hosts']);
142
143        if (empty($this->prop['ldap_version']))
144            $this->prop['ldap_version'] = 3;
145
146        foreach ($this->prop['hosts'] as $host)
147        {
148            $host = idn_to_ascii(rcube_parse_host($host));
149            $this->_debug("C: Connect [$host".($this->prop['port'] ? ':'.$this->prop['port'] : '')."]");
150
151            if ($lc = @ldap_connect($host, $this->prop['port']))
152            {
153                if ($this->prop['use_tls']===true)
154                    if (!ldap_start_tls($lc))
155                        continue;
156
157                $this->_debug("S: OK");
158
159                ldap_set_option($lc, LDAP_OPT_PROTOCOL_VERSION, $this->prop['ldap_version']);
160                $this->prop['host'] = $host;
161                $this->conn = $lc;
162                break;
163            }
164            $this->_debug("S: NOT OK");
165        }
166
167        if (is_resource($this->conn))
168        {
169            $this->ready = true;
170
171            $bind_pass = $this->prop['bind_pass'];
172            $bind_user = $this->prop['bind_user'];
173            $bind_dn   = $this->prop['bind_dn'];
174            $this->base_dn   = $this->prop['base_dn'];
175
176            // User specific access, generate the proper values to use.
177            if ($this->prop['user_specific']) {
178                // No password set, use the session password
179                if (empty($bind_pass)) {
180                    $bind_pass = $RCMAIL->decrypt($_SESSION['password']);
181                }
182
183                // Get the pieces needed for variable replacement.
184                $fu = $RCMAIL->user->get_username();
185                list($u, $d) = explode('@', $fu);
186                $dc = 'dc='.strtr($d, array('.' => ',dc=')); // hierarchal domain string
187
188                $replaces = array('%dc' => $dc, '%d' => $d, '%fu' => $fu, '%u' => $u);
189
190                if ($this->prop['search_base_dn'] && $this->prop['search_filter']) {
191                    // Search for the dn to use to authenticate
192                    $this->prop['search_base_dn'] = strtr($this->prop['search_base_dn'], $replaces);
193                    $this->prop['search_filter'] = strtr($this->prop['search_filter'], $replaces);
194
195                    $this->_debug("S: searching with base {$this->prop['search_base_dn']} for {$this->prop['search_filter']}");
196
197                    $res = ldap_search($this->conn, $this->prop['search_base_dn'], $this->prop['search_filter'], array('uid'));
198                    if ($res && ($entry = ldap_first_entry($this->conn, $res))) {
199                        $bind_dn = ldap_get_dn($this->conn, $entry);
200
201                        $this->_debug("S: search returned dn: $bind_dn");
202
203                        if ($bind_dn) {
204                            $dn = ldap_explode_dn($bind_dn, 1);
205                            $replaces['%dn'] = $dn[0];
206                        }
207                    }
208                }
209                // Replace the bind_dn and base_dn variables.
210                $bind_dn   = strtr($bind_dn, $replaces);
211                $this->base_dn   = strtr($this->base_dn, $replaces);
212
213                if (empty($bind_user)) {
214                    $bind_user = $u;
215                }
216            }
217
218            if (!empty($bind_pass)) {
219                if (!empty($bind_dn)) {
220                    $this->ready = $this->_bind($bind_dn, $bind_pass);
221                }
222                else if (!empty($this->prop['auth_cid'])) {
223                    $this->ready = $this->_sasl_bind($this->prop['auth_cid'], $bind_pass, $bind_user);
224                }
225                else {
226                    $this->ready = $this->_sasl_bind($bind_user, $bind_pass);
227                }
228            }
229        }
230        else
231            raise_error(array('code' => 100, 'type' => 'ldap',
232                'file' => __FILE__, 'line' => __LINE__,
233                'message' => "Could not connect to any LDAP server, last tried $host:{$this->prop[port]}"), true);
234
235        // See if the directory is writeable.
236        if ($this->prop['writable']) {
237            $this->readonly = false;
238        } // end if
239    }
240
241
242    /**
243     * Bind connection with (SASL-) user and password
244     *
245     * @param string $authc Authentication user
246     * @param string $pass  Bind password
247     * @param string $authz Autorization user
248     *
249     * @return boolean True on success, False on error
250     */
251    private function _sasl_bind($authc, $pass, $authz=null)
252    {
253        if (!$this->conn) {
254            return false;
255        }
256
257        if (!function_exists('ldap_sasl_bind')) {
258            raise_error(array('code' => 100, 'type' => 'ldap',
259                'file' => __FILE__, 'line' => __LINE__,
260                'message' => "Unable to bind: ldap_sasl_bind() not exists"),
261                true, true);
262        }
263
264        if (!empty($authz)) {
265            $authz = 'u:' . $authz;
266        }
267
268        if (!empty($this->prop['auth_method'])) {
269            $method = $this->prop['auth_method'];
270        }
271        else {
272            $method = 'DIGEST-MD5';
273        }
274
275        $this->_debug("C: Bind [mech: $method, authc: $authc, authz: $authz] [pass: $pass]");
276
277        if (ldap_sasl_bind($this->conn, NULL, $pass, $method, NULL, $authc, $authz)) {
278            $this->_debug("S: OK");
279            return true;
280        }
281
282        $this->_debug("S: ".ldap_error($this->conn));
283
284        raise_error(array(
285            'code' => ldap_errno($this->conn), 'type' => 'ldap',
286            'file' => __FILE__, 'line' => __LINE__,
287            'message' => "Bind failed for authcid=$authc ".ldap_error($this->conn)),
288            true);
289
290        return false;
291    }
292
293
294    /**
295     * Bind connection with DN and password
296     *
297     * @param string Bind DN
298     * @param string Bind password
299     *
300     * @return boolean True on success, False on error
301     */
302    private function _bind($dn, $pass)
303    {
304        if (!$this->conn) {
305            return false;
306        }
307
308        $this->_debug("C: Bind [dn: $dn] [pass: $pass]");
309
310        if (@ldap_bind($this->conn, $dn, $pass)) {
311            $this->_debug("S: OK");
312            return true;
313        }
314
315        $this->_debug("S: ".ldap_error($this->conn));
316
317        raise_error(array(
318            'code' => ldap_errno($this->conn), 'type' => 'ldap',
319            'file' => __FILE__, 'line' => __LINE__,
320            'message' => "Bind failed for dn=$dn: ".ldap_error($this->conn)),
321            true);
322
323        return false;
324    }
325
326
327    /**
328     * Close connection to LDAP server
329     */
330    function close()
331    {
332        if ($this->conn)
333        {
334            $this->_debug("C: Close");
335            ldap_unbind($this->conn);
336            $this->conn = null;
337        }
338    }
339
340
341    /**
342     * Returns address book name
343     *
344     * @return string Address book name
345     */
346    function get_name()
347    {
348        return $this->prop['name'];
349    }
350
351
352    /**
353     * Set internal list page
354     *
355     * @param number $page Page number to list
356     */
357    function set_page($page)
358    {
359        $this->list_page = (int)$page;
360    }
361
362
363    /**
364     * Set internal page size
365     *
366     * @param number $size Number of messages to display on one page
367     */
368    function set_pagesize($size)
369    {
370        $this->page_size = (int)$size;
371    }
372
373
374    /**
375     * Save a search string for future listings
376     *
377     * @param string $filter Filter string
378     */
379    function set_search_set($filter)
380    {
381        $this->filter = $filter;
382    }
383
384
385    /**
386     * Getter for saved search properties
387     *
388     * @return mixed Search properties used by this class
389     */
390    function get_search_set()
391    {
392        return $this->filter;
393    }
394
395
396    /**
397     * Reset all saved results and search parameters
398     */
399    function reset()
400    {
401        $this->result = null;
402        $this->ldap_result = null;
403        $this->filter = '';
404    }
405
406
407    /**
408     * List the current set of contact records
409     *
410     * @param  array  List of cols to show
411     * @param  int    Only return this number of records
412     *
413     * @return array  Indexed list of contact records, each a hash array
414     */
415    function list_records($cols=null, $subset=0)
416    {
417        // add general filter to query
418        if (!empty($this->prop['filter']) && empty($this->filter))
419        {
420            $filter = $this->prop['filter'];
421            $this->set_search_set($filter);
422        }
423
424        // exec LDAP search if no result resource is stored
425        if ($this->conn && !$this->ldap_result)
426            $this->_exec_search();
427
428        // count contacts for this user
429        $this->result = $this->count();
430
431        // we have a search result resource
432        if ($this->ldap_result && $this->result->count > 0)
433        {
434            if ($this->sort_col && $this->prop['scope'] !== 'base' && !$this->vlv_active)
435                ldap_sort($this->conn, $this->ldap_result, $this->sort_col);
436
437            $start_row = $this->vlv_active ? 0 : $this->result->first;
438            $start_row = $subset < 0 ? $start_row + $this->page_size + $subset : $start_row;
439            $last_row = $this->result->first + $this->page_size;
440            $last_row = $subset != 0 ? $start_row + abs($subset) : $last_row;
441
442            $entries = ldap_get_entries($this->conn, $this->ldap_result);
443            for ($i = $start_row; $i < min($entries['count'], $last_row); $i++)
444                $this->result->add($this->_ldap2result($entries[$i]));
445        }
446
447        // temp hack for filtering group members
448        if ($this->groups and $this->group_id)
449        {
450            $result = new rcube_result_set();
451            while ($record = $this->result->iterate())
452            {
453                if ($this->group_members[$record['ID']])
454                {
455                    $result->add($record);
456                    $result->count++;
457                }
458            }
459            $this->result = $result;
460        }
461
462        return $this->result;
463    }
464
465
466    /**
467     * Search contacts
468     *
469     * @param mixed   $fields   The field name of array of field names to search in
470     * @param mixed   $value    Search value (or array of values when $fields is array)
471     * @param boolean $strict   True for strict, False for partial (fuzzy) matching
472     * @param boolean $select   True if results are requested, False if count only
473     * @param boolean $nocount  (Not used)
474     * @param array   $required List of fields that cannot be empty
475     *
476     * @return array  Indexed list of contact records and 'count' value
477     */
478    function search($fields, $value, $strict=false, $select=true, $nocount=false, $required=array())
479    {
480        // special treatment for ID-based search
481        if ($fields == 'ID' || $fields == $this->primary_key)
482        {
483            $ids = !is_array($value) ? explode(',', $value) : $value;
484            $result = new rcube_result_set();
485            foreach ($ids as $id)
486            {
487                if ($rec = $this->get_record($id, true))
488                {
489                    $result->add($rec);
490                    $result->count++;
491                }
492            }
493            return $result;
494        }
495
496        // use AND operator for advanced searches
497        $filter = is_array($value) ? '(&' : '(|';
498        $wc     = !$strict && $this->prop['fuzzy_search'] ? '*' : '';
499
500        if ($fields == '*')
501        {
502            // search_fields are required for fulltext search
503            if (empty($this->prop['search_fields']))
504            {
505                $this->set_error(self::ERROR_SEARCH, 'nofulltextsearch');
506                $this->result = new rcube_result_set();
507                return $this->result;
508            }
509            if (is_array($this->prop['search_fields']))
510            {
511                foreach ($this->prop['search_fields'] as $field) {
512                    $filter .= "($field=$wc" . $this->_quote_string($value) . "$wc)";
513                }
514            }
515        }
516        else
517        {
518            foreach ((array)$fields as $idx => $field) {
519                $val = is_array($value) ? $value[$idx] : $value;
520                if ($f = $this->_map_field($field)) {
521                    $filter .= "($f=$wc" . $this->_quote_string($val) . "$wc)";
522                }
523            }
524        }
525        $filter .= ')';
526
527        // add required (non empty) fields filter
528        $req_filter = '';
529        foreach ((array)$required as $field)
530            if ($f = $this->_map_field($field))
531                $req_filter .= "($f=*)";
532
533        if (!empty($req_filter))
534            $filter = '(&' . $req_filter . $filter . ')';
535
536        // avoid double-wildcard if $value is empty
537        $filter = preg_replace('/\*+/', '*', $filter);
538
539        // add general filter to query
540        if (!empty($this->prop['filter']))
541            $filter = '(&(' . preg_replace('/^\(|\)$/', '', $this->prop['filter']) . ')' . $filter . ')';
542
543        // set filter string and execute search
544        $this->set_search_set($filter);
545        $this->_exec_search();
546
547        if ($select)
548            $this->list_records();
549        else
550            $this->result = $this->count();
551
552        return $this->result;
553    }
554
555
556    /**
557     * Count number of available contacts in database
558     *
559     * @return object rcube_result_set Resultset with values for 'count' and 'first'
560     */
561    function count()
562    {
563        $count = 0;
564        if ($this->conn && $this->ldap_result) {
565            $count = $this->vlv_active ? $this->vlv_count : ldap_count_entries($this->conn, $this->ldap_result);
566        } // end if
567        elseif ($this->conn) {
568            // We have a connection but no result set, attempt to get one.
569            if (empty($this->filter)) {
570                // The filter is not set, set it.
571                $this->filter = $this->prop['filter'];
572            } // end if
573            $this->_exec_search(true);
574            if ($this->ldap_result) {
575                $count = ldap_count_entries($this->conn, $this->ldap_result);
576            } // end if
577        } // end else
578
579        return new rcube_result_set($count, ($this->list_page-1) * $this->page_size);
580    }
581
582
583    /**
584     * Return the last result set
585     *
586     * @return object rcube_result_set Current resultset or NULL if nothing selected yet
587     */
588    function get_result()
589    {
590        return $this->result;
591    }
592
593
594    /**
595     * Get a specific contact record
596     *
597     * @param mixed   Record identifier
598     * @param boolean Return as associative array
599     *
600     * @return mixed  Hash array or rcube_result_set with all record fields
601     */
602    function get_record($dn, $assoc=false)
603    {
604        $res = null;
605        if ($this->conn && $dn)
606        {
607            $dn = base64_decode($dn);
608
609            $this->_debug("C: Read [dn: $dn] [(objectclass=*)]");
610
611            if ($this->ldap_result = @ldap_read($this->conn, $dn, '(objectclass=*)', array_values($this->fieldmap)))
612                $entry = ldap_first_entry($this->conn, $this->ldap_result);
613            else
614                $this->_debug("S: ".ldap_error($this->conn));
615
616            if ($entry && ($rec = ldap_get_attributes($this->conn, $entry)))
617            {
618                $this->_debug("S: OK"/* . print_r($rec, true)*/);
619
620                $rec = array_change_key_case($rec, CASE_LOWER);
621
622                // Add in the dn for the entry.
623                $rec['dn'] = $dn;
624                $res = $this->_ldap2result($rec);
625                $this->result = new rcube_result_set(1);
626                $this->result->add($res);
627            }
628        }
629
630        return $assoc ? $res : $this->result;
631    }
632
633
634    /**
635     * Check the given data before saving.
636     * If input not valid, the message to display can be fetched using get_error()
637     *
638     * @param array Assoziative array with data to save
639     *
640     * @return boolean True if input is valid, False if not.
641     */
642    public function validate($save_data)
643    {
644        // check for name input
645        if (empty($save_data['name'])) {
646            $this->set_error('warning', 'nonamewarning');
647            return false;
648        }
649
650        // validate e-mail addresses
651        return parent::validate($save_data);
652    }
653
654
655    /**
656     * Create a new contact record
657     *
658     * @param array    Hash array with save data
659     *
660     * @return encoded record ID on success, False on error
661     */
662    function insert($save_cols)
663    {
664        // Map out the column names to their LDAP ones to build the new entry.
665        $newentry = array();
666        $newentry['objectClass'] = $this->prop['LDAP_Object_Classes'];
667        foreach ($this->fieldmap as $col => $fld) {
668            $val = $save_cols[$col];
669            if (is_array($val))
670                $val = array_filter($val);  // remove empty entries
671            if ($fld && $val) {
672                // The field does exist, add it to the entry.
673                $newentry[$fld] = $val;
674            } // end if
675        } // end foreach
676
677        // Verify that the required fields are set.
678        foreach ($this->prop['required_fields'] as $fld) {
679            $missing = null;
680            if (!isset($newentry[$fld])) {
681                $missing[] = $fld;
682            }
683        }
684
685        // abort process if requiered fields are missing
686        // TODO: generate message saying which fields are missing
687        if ($missing) {
688            $this->set_error(self::ERROR_INCOMPLETE, 'formincomplete');
689            return false;
690        }
691
692        // Build the new entries DN.
693        $dn = $this->prop['LDAP_rdn'].'='.$this->_quote_string($newentry[$this->prop['LDAP_rdn']], true).','.$this->base_dn;
694
695        $this->_debug("C: Add [dn: $dn]: ".print_r($newentry, true));
696
697        $res = ldap_add($this->conn, $dn, $newentry);
698        if ($res === FALSE) {
699            $this->_debug("S: ".ldap_error($this->conn));
700            $this->set_error(self::ERROR_SAVING, 'errorsaving');
701            return false;
702        } // end if
703
704        $this->_debug("S: OK");
705
706        // add new contact to the selected group
707        if ($this->groups)
708            $this->add_to_group($this->group_id, base64_encode($dn));
709
710        return base64_encode($dn);
711    }
712
713
714    /**
715     * Update a specific contact record
716     *
717     * @param mixed Record identifier
718     * @param array Hash array with save data
719     *
720     * @return boolean True on success, False on error
721     */
722    function update($id, $save_cols)
723    {
724        $record = $this->get_record($id, true);
725        $result = $this->get_result();
726        $record = $result->first();
727
728        $newdata = array();
729        $replacedata = array();
730        $deletedata = array();
731
732        foreach ($this->fieldmap as $col => $fld) {
733            $val = $save_cols[$col];
734            if ($fld) {
735                // remove empty array values
736                if (is_array($val))
737                    $val = array_filter($val);
738                // The field does exist compare it to the ldap record.
739                if ($record[$col] != $val) {
740                    // Changed, but find out how.
741                    if (!isset($record[$col])) {
742                        // Field was not set prior, need to add it.
743                        $newdata[$fld] = $val;
744                    } // end if
745                    elseif ($val == '') {
746                        // Field supplied is empty, verify that it is not required.
747                        if (!in_array($fld, $this->prop['required_fields'])) {
748                            // It is not, safe to clear.
749                            $deletedata[$fld] = $record[$col];
750                        } // end if
751                    } // end elseif
752                    else {
753                        // The data was modified, save it out.
754                        $replacedata[$fld] = $val;
755                    } // end else
756                } // end if
757            } // end if
758        } // end foreach
759
760        $dn = base64_decode($id);
761
762        // Update the entry as required.
763        if (!empty($deletedata)) {
764            // Delete the fields.
765            $this->_debug("C: Delete [dn: $dn]: ".print_r($deletedata, true));
766            if (!ldap_mod_del($this->conn, $dn, $deletedata)) {
767                $this->_debug("S: ".ldap_error($this->conn));
768                $this->set_error(self::ERROR_SAVING, 'errorsaving');
769                return false;
770            }
771            $this->_debug("S: OK");
772        } // end if
773
774        if (!empty($replacedata)) {
775            // Handle RDN change
776            if ($replacedata[$this->prop['LDAP_rdn']]) {
777                $newdn = $this->prop['LDAP_rdn'].'='
778                    .$this->_quote_string($replacedata[$this->prop['LDAP_rdn']], true)
779                    .','.$this->base_dn;
780                if ($dn != $newdn) {
781                    $newrdn = $this->prop['LDAP_rdn'].'='
782                    .$this->_quote_string($replacedata[$this->prop['LDAP_rdn']], true);
783                    unset($replacedata[$this->prop['LDAP_rdn']]);
784                }
785            }
786            // Replace the fields.
787            if (!empty($replacedata)) {
788                $this->_debug("C: Replace [dn: $dn]: ".print_r($replacedata, true));
789                if (!ldap_mod_replace($this->conn, $dn, $replacedata)) {
790                    $this->_debug("S: ".ldap_error($this->conn));
791                    return false;
792                }
793                $this->_debug("S: OK");
794            } // end if
795        } // end if
796
797        if (!empty($newdata)) {
798            // Add the fields.
799            $this->_debug("C: Add [dn: $dn]: ".print_r($newdata, true));
800            if (!ldap_mod_add($this->conn, $dn, $newdata)) {
801                $this->_debug("S: ".ldap_error($this->conn));
802                $this->set_error(self::ERROR_SAVING, 'errorsaving');
803                return false;
804            }
805            $this->_debug("S: OK");
806        } // end if
807
808        // Handle RDN change
809        if (!empty($newrdn)) {
810            $this->_debug("C: Rename [dn: $dn] [dn: $newrdn]");
811            if (!ldap_rename($this->conn, $dn, $newrdn, NULL, TRUE)) {
812                $this->_debug("S: ".ldap_error($this->conn));
813                return false;
814            }
815            $this->_debug("S: OK");
816
817            // change the group membership of the contact
818            if ($this->groups)
819            {
820                $group_ids = $this->get_record_groups(base64_encode($dn));
821                foreach ($group_ids as $group_id)
822                {
823                    $this->remove_from_group($group_id, base64_encode($dn));
824                    $this->add_to_group($group_id, base64_encode($newdn));
825                }
826            }
827            return base64_encode($newdn);
828        }
829
830        return true;
831    }
832
833
834    /**
835     * Mark one or more contact records as deleted
836     *
837     * @param array  Record identifiers
838     *
839     * @return boolean True on success, False on error
840     */
841    function delete($ids)
842    {
843        if (!is_array($ids)) {
844            // Not an array, break apart the encoded DNs.
845            $dns = explode(',', $ids);
846        } // end if
847
848        foreach ($dns as $id) {
849            $dn = base64_decode($id);
850            $this->_debug("C: Delete [dn: $dn]");
851            // Delete the record.
852            $res = ldap_delete($this->conn, $dn);
853            if ($res === FALSE) {
854                $this->_debug("S: ".ldap_error($this->conn));
855                $this->set_error(self::ERROR_SAVING, 'errorsaving');
856                return false;
857            } // end if
858            $this->_debug("S: OK");
859
860            // remove contact from all groups where he was member
861            if ($this->groups)
862            {
863                $group_ids = $this->get_record_groups(base64_encode($dn));
864                foreach ($group_ids as $group_id)
865                {
866                    $this->remove_from_group($group_id, base64_encode($dn));
867                }
868            }
869        } // end foreach
870
871        return count($dns);
872    }
873
874
875    /**
876     * Execute the LDAP search based on the stored credentials
877     */
878    private function _exec_search($count = false)
879    {
880        if ($this->ready)
881        {
882            $filter = $this->filter ? $this->filter : '(objectclass=*)';
883            $function = $this->prop['scope'] == 'sub' ? 'ldap_search' : ($this->prop['scope'] == 'base' ? 'ldap_read' : 'ldap_list');
884
885            $this->_debug("C: Search [".$filter."]");
886
887            // when using VLV, we need to issue listing command first in order to get the full count
888            if (!$count && $function != 'ldap_read' && $this->prop['vlv']) {
889                if ($this->_exec_search(true))
890                    $this->vlv_count = ldap_count_entries($this->conn, $this->ldap_result);
891                $this->vlv_active = $this->_vlv_set_controls();
892            }
893
894            // only fetch dn for count (should keep the payload low)
895            $attrs = $count ? array('dn') : array_values($this->fieldmap);
896            if ($this->ldap_result = @$function($this->conn, $this->base_dn, $filter,
897                $attrs, 0, (int)$this->prop['sizelimit'], (int)$this->prop['timelimit']))
898            {
899                $this->_debug("S: ".ldap_count_entries($this->conn, $this->ldap_result)." record(s)");
900                if ($err = ldap_errno($this->conn))
901                    $this->_debug("S: Error: " .ldap_err2str($err));
902                return true;
903            }
904            else
905            {
906                $this->_debug("S: ".ldap_error($this->conn));
907            }
908        }
909
910        return false;
911    }
912
913    /**
914     * Set server controls for Virtual List View (paginated listing)
915     */
916    private function _vlv_set_controls()
917    {
918        $sort_ctrl = array('oid' => "1.2.840.113556.1.4.473",  'value' => $this->_sort_ber_encode((array)$this->prop['sort']));
919        $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);
920
921        $this->_debug("C: set controls sort=" . join(' ', unpack('H'.(strlen($sort_ctrl['value'])*2), $sort_ctrl['value'])) . " ({$this->sort_col});"
922            . " vlv=" . join(' ', (unpack('H'.(strlen($vlv_ctrl['value'])*2), $vlv_ctrl['value']))) . " ($offset)");
923
924        if (!ldap_set_option($this->conn, LDAP_OPT_SERVER_CONTROLS, array($sort_ctrl, $vlv_ctrl))) {
925            $this->_debug("S: ".ldap_error($this->conn));
926            $this->set_error(self::ERROR_SEARCH, 'vlvnotsupported');
927            return false;
928        }
929
930        return true;
931    }
932
933
934    /**
935     * Converts LDAP entry into an array
936     */
937    private function _ldap2result($rec)
938    {
939        $out = array();
940
941        if ($rec['dn'])
942            $out[$this->primary_key] = base64_encode($rec['dn']);
943
944        foreach ($this->fieldmap as $rf => $lf)
945        {
946            for ($i=0; $i < $rec[$lf]['count']; $i++) {
947                if (!($value = $rec[$lf][$i]))
948                    continue;
949                if ($rf == 'email' && $this->mail_domain && !strpos($value, '@'))
950                    $out[$rf][] = sprintf('%s@%s', $value, $this->mail_domain);
951                else if (in_array($rf, array('street','zipcode','locality','country','region')))
952                    $out['address'][$i][$rf] = $value;
953                else if ($rec[$lf]['count'] > 1)
954                    $out[$rf][] = $value;
955                else
956                    $out[$rf] = $value;
957            }
958        }
959
960        return $out;
961    }
962
963
964    /**
965     * Return real field name (from fields map)
966     */
967    private function _map_field($field)
968    {
969        return $this->fieldmap[$field];
970    }
971
972
973    /**
974     * Returns unified attribute name (resolving aliases)
975     */
976    private static function _attr_name($name)
977    {
978        // list of known attribute aliases
979        $aliases = array(
980            'gn' => 'givenname',
981            'rfc822mailbox' => 'email',
982            'userid' => 'uid',
983            'emailaddress' => 'email',
984            'pkcs9email' => 'email',
985        );
986        return isset($aliases[$name]) ? $aliases[$name] : $name;
987    }
988
989
990    /**
991     * Prints debug info to the log
992     */
993    private function _debug($str)
994    {
995        if ($this->debug)
996            write_log('ldap', $str);
997    }
998
999
1000    /**
1001     * Quotes attribute value string
1002     *
1003     * @param string $str Attribute value
1004     * @param bool   $dn  True if the attribute is a DN
1005     *
1006     * @return string Quoted string
1007     */
1008    private static function _quote_string($str, $dn=false)
1009    {
1010        // take firt entry if array given
1011        if (is_array($str))
1012            $str = reset($str);
1013
1014        if ($dn)
1015            $replace = array(','=>'\2c', '='=>'\3d', '+'=>'\2b', '<'=>'\3c',
1016                '>'=>'\3e', ';'=>'\3b', '\\'=>'\5c', '"'=>'\22', '#'=>'\23');
1017        else
1018            $replace = array('*'=>'\2a', '('=>'\28', ')'=>'\29', '\\'=>'\5c',
1019                '/'=>'\2f');
1020
1021        return strtr($str, $replace);
1022    }
1023
1024
1025    /**
1026     * Setter for the current group
1027     * (empty, has to be re-implemented by extending class)
1028     */
1029    function set_group($group_id)
1030    {
1031        if ($group_id)
1032        {
1033            if (!$this->group_cache)
1034                $this->list_groups();
1035
1036            $cache_members = $this->group_cache[$group_id]['members'];
1037
1038            $members = array();
1039            for ($i=1; $i<$cache_members["count"]; $i++)
1040            {
1041                $members[base64_encode($cache_members[$i])] = 1;
1042            }
1043            $this->group_members = $members;
1044            $this->group_id = $group_id;
1045        }
1046        else
1047            $this->group_id = 0;
1048    }
1049
1050    /**
1051     * List all active contact groups of this source
1052     *
1053     * @param string  Optional search string to match group name
1054     * @return array  Indexed list of contact groups, each a hash array
1055     */
1056    function list_groups($search = null)
1057    {
1058        global $RCMAIL;
1059
1060        if (!$this->groups)
1061            return array();
1062
1063        $this->groups_base_dn = ($this->prop['groups']['base_dn']) ?
1064                $this->prop['groups']['base_dn'] : $this->base_dn;
1065
1066        // replace user specific dn
1067        if ($this->prop['user_specific'])
1068        {
1069            $fu = $RCMAIL->user->get_username();
1070            list($u, $d) = explode('@', $fu);
1071            $dc = 'dc='.strtr($d, array('.' => ',dc='));
1072            $replaces = array('%dc' => $dc, '%d' => $d, '%fu' => $fu, '%u' => $u);
1073
1074            $this->groups_base_dn = strtr($this->groups_base_dn, $replaces);;
1075        }
1076
1077        $base_dn = $this->groups_base_dn;
1078        $filter = $this->prop['groups']['filter'];
1079
1080        $res = ldap_search($this->conn, $base_dn, $filter, array('cn','member'));
1081        if ($res === false)
1082        {
1083            $this->_debug("S: ".ldap_error($this->conn));
1084            $this->set_error(self::ERROR_SAVING, 'errorsaving');
1085            return array();
1086        }
1087        $ldap_data = ldap_get_entries($this->conn, $res);
1088
1089        $groups = array();
1090        $group_sortnames = array();
1091        for ($i=0; $i<$ldap_data["count"]; $i++)
1092        {
1093            $group_name = $ldap_data[$i]['cn'][0];
1094            $group_id = base64_encode($group_name);
1095            $groups[$group_id]['ID'] = $group_id;
1096            $groups[$group_id]['name'] = $group_name;
1097            $groups[$group_id]['members'] = $ldap_data[$i]['member'];
1098            $group_sortnames[] = strtolower($group_name);
1099        }
1100        array_multisort($group_sortnames, SORT_ASC, SORT_STRING, $groups);
1101        $this->group_cache = $groups;
1102
1103        return $groups;
1104    }
1105
1106    /**
1107     * Create a contact group with the given name
1108     *
1109     * @param string The group name
1110     * @return mixed False on error, array with record props in success
1111     */
1112    function create_group($group_name)
1113    {
1114        if (!$this->group_cache)
1115            $this->list_groups();
1116
1117        $base_dn = $this->groups_base_dn;
1118        $new_dn = "cn=$group_name,$base_dn";
1119        $new_gid = base64_encode($group_name);
1120
1121        $new_entry = array(
1122            'objectClass' => $this->prop['groups']['object_classes'],
1123            'cn' => $group_name,
1124            'member' => '',
1125        );
1126
1127        $res = ldap_add($this->conn, $new_dn, $new_entry);
1128        if ($res === false)
1129        {
1130            $this->_debug("S: ".ldap_error($this->conn));
1131            $this->set_error(self::ERROR_SAVING, 'errorsaving');
1132            return false;
1133        }
1134        return array('id' => $new_gid, 'name' => $group_name);
1135    }
1136
1137    /**
1138     * Delete the given group and all linked group members
1139     *
1140     * @param string Group identifier
1141     * @return boolean True on success, false if no data was changed
1142     */
1143    function delete_group($group_id)
1144    {
1145        if (!$this->group_cache)
1146            $this->list_groups();
1147
1148        $base_dn = $this->groups_base_dn;
1149        $group_name = $this->group_cache[$group_id]['name'];
1150
1151        $del_dn = "cn=$group_name,$base_dn";
1152        $res = ldap_delete($this->conn, $del_dn);
1153        if ($res === false)
1154        {
1155            $this->_debug("S: ".ldap_error($this->conn));
1156            $this->set_error(self::ERROR_SAVING, 'errorsaving');
1157            return false;
1158        }
1159        return true;
1160    }
1161
1162    /**
1163     * Rename a specific contact group
1164     *
1165     * @param string Group identifier
1166     * @param string New name to set for this group
1167     * @param string New group identifier (if changed, otherwise don't set)
1168     * @return boolean New name on success, false if no data was changed
1169     */
1170    function rename_group($group_id, $new_name, &$new_gid)
1171    {
1172        if (!$this->group_cache)
1173            $this->list_groups();
1174
1175        $base_dn = $this->groups_base_dn;
1176        $group_name = $this->group_cache[$group_id]['name'];
1177        $old_dn = "cn=$group_name,$base_dn";
1178        $new_rdn = "cn=$new_name";
1179        $new_gid = base64_encode($new_name);
1180
1181        $res = ldap_rename($this->conn, $old_dn, $new_rdn, NULL, TRUE);
1182        if ($res === false)
1183        {
1184            $this->_debug("S: ".ldap_error($this->conn));
1185            $this->set_error(self::ERROR_SAVING, 'errorsaving');
1186            return false;
1187        }
1188        return $new_name;
1189    }
1190
1191    /**
1192     * Add the given contact records the a certain group
1193     *
1194     * @param string  Group identifier
1195     * @param array   List of contact identifiers to be added
1196     * @return int    Number of contacts added
1197     */
1198    function add_to_group($group_id, $contact_ids)
1199    {
1200        if (!$this->group_cache)
1201            $this->list_groups();
1202
1203        $base_dn = $this->groups_base_dn;
1204        $group_name = $this->group_cache[$group_id]['name'];
1205        $group_dn = "cn=$group_name,$base_dn";
1206
1207        $new_attrs = array();
1208        foreach (explode(",", $contact_ids) as $id)
1209            $new_attrs['member'][] = base64_decode($id);
1210
1211        $res = ldap_mod_add($this->conn, $group_dn, $new_attrs);
1212        if ($res === false)
1213        {
1214            $this->_debug("S: ".ldap_error($this->conn));
1215            $this->set_error(self::ERROR_SAVING, 'errorsaving');
1216            return 0;
1217        }
1218        return count($new_attrs['member']);
1219    }
1220
1221    /**
1222     * Remove the given contact records from a certain group
1223     *
1224     * @param string  Group identifier
1225     * @param array   List of contact identifiers to be removed
1226     * @return int    Number of deleted group members
1227     */
1228    function remove_from_group($group_id, $contact_ids)
1229    {
1230        if (!$this->group_cache)
1231            $this->list_groups();
1232
1233        $base_dn = $this->groups_base_dn;
1234        $group_name = $this->group_cache[$group_id]['name'];
1235        $group_dn = "cn=$group_name,$base_dn";
1236
1237        $del_attrs = array();
1238        foreach (explode(",", $contact_ids) as $id)
1239            $del_attrs['member'][] = base64_decode($id);
1240
1241        $res = ldap_mod_del($this->conn, $group_dn, $del_attrs);
1242        if ($res === false)
1243        {
1244            $this->_debug("S: ".ldap_error($this->conn));
1245            $this->set_error(self::ERROR_SAVING, 'errorsaving');
1246            return 0;
1247        }
1248        return count($del_attrs['member']);
1249    }
1250
1251    /**
1252     * Get group assignments of a specific contact record
1253     *
1254     * @param mixed Record identifier
1255     *
1256     * @return array List of assigned groups as ID=>Name pairs
1257     * @since 0.5-beta
1258     */
1259    function get_record_groups($contact_id)
1260    {
1261        if (!$this->groups)
1262            return array();
1263
1264        $base_dn = $this->groups_base_dn;
1265        $contact_dn = base64_decode($contact_id);
1266        $filter = "(member=$contact_dn)";
1267
1268        $res = ldap_search($this->conn, $base_dn, $filter, array('cn'));
1269        if ($res === false)
1270        {
1271            $this->_debug("S: ".ldap_error($this->conn));
1272            $this->set_error(self::ERROR_SAVING, 'errorsaving');
1273            return array();
1274        }
1275        $ldap_data = ldap_get_entries($this->conn, $res);
1276
1277        $groups = array();
1278        for ($i=0; $i<$ldap_data["count"]; $i++)
1279        {
1280            $group_name = $ldap_data[$i]['cn'][0];
1281            $group_id = base64_encode($group_name);
1282            $groups[$group_id] = $group_id;
1283        }
1284        return $groups;
1285    }
1286
1287
1288    /**
1289     * Generate BER encoded string for Virtual List View option
1290     *
1291     * @param integer List offset (first record)
1292     * @param integer Records per page
1293     * @return string BER encoded option value
1294     */
1295    private function _vlv_ber_encode($offset, $rpp)
1296    {
1297        # this string is ber-encoded, php will prefix this value with:
1298        # 04 (octet string) and 10 (length of 16 bytes)
1299        # the code behind this string is broken down as follows:
1300        # 30 = ber sequence with a length of 0e (14) bytes following
1301        # 20 = type integer (in two's complement form) with 2 bytes following (beforeCount): 01 00 (ie 0)
1302        # 20 = type integer (in two's complement form) with 2 bytes following (afterCount):  01 18 (ie 25-1=24)
1303        # a0 = type context-specific/constructed with a length of 06 (6) bytes following
1304        # 20 = type integer with 2 bytes following (offset): 01 01 (ie 1)
1305        # 20 = type integer with 2 bytes following (contentCount):  01 00
1306        # the following info was taken from the ISO/IEC 8825-1:2003 x.690 standard re: the
1307        # encoding of integer values (note: these values are in
1308        # two-complement form so since offset will never be negative bit 8 of the
1309        # leftmost octet should never by set to 1):
1310        # 8.3.2: If the contents octets of an integer value encoding consist
1311        # of more than one octet, then the bits of the first octet (rightmost) and bit 8
1312        # of the second (to the left of first octet) octet:
1313        # a) shall not all be ones; and
1314        # b) shall not all be zero
1315
1316        # construct the string from right to left
1317        $str = "020100"; # contentCount
1318
1319        $ber_val = self::_ber_encode_int($offset);  // returns encoded integer value in hex format
1320
1321        // calculate octet length of $ber_val
1322        $str = self::_ber_addseq($ber_val, '02') . $str;
1323
1324        // now compute length over $str
1325        $str = self::_ber_addseq($str, 'a0');
1326
1327        // now tack on records per page
1328        $str = sprintf("0201000201%02x", min(255, $rpp)-1) . $str;
1329
1330        // now tack on sequence identifier and length
1331        $str = self::_ber_addseq($str, '30');
1332
1333        return pack('H'.strlen($str), $str);
1334    }
1335
1336
1337    /**
1338     * create ber encoding for sort control
1339     *
1340     * @pararm array List of cols to sort by
1341     * @return string BER encoded option value
1342     */
1343    private function _sort_ber_encode($sortcols)
1344    {
1345        $str = '';
1346        foreach (array_reverse((array)$sortcols) as $col) {
1347            $ber_val = self::_string2hex($col);
1348
1349            # 30 = ber sequence with a length of octet value
1350            # 04 = octet string with a length of the ascii value
1351            $oct = self::_ber_addseq($ber_val, '04');
1352            $str = self::_ber_addseq($oct, '30') . $str;
1353        }
1354
1355        // now tack on sequence identifier and length
1356        $str = self::_ber_addseq($str, '30');
1357
1358        return pack('H'.strlen($str), $str);
1359    }
1360
1361    /**
1362     * Add BER sequence with correct length and the given identifier
1363     */
1364    private static function _ber_addseq($str, $identifier)
1365    {
1366        $len = dechex(strlen($str)/2);
1367        if (strlen($len) % 2 != 0)
1368            $len = '0'.$len;
1369
1370        return $identifier . $len . $str;
1371    }
1372
1373    /**
1374     * Returns BER encoded integer value in hex format
1375     */
1376    private static function _ber_encode_int($offset)
1377    {
1378        $val = dechex($offset);
1379        $prefix = '';
1380
1381        // check if bit 8 of high byte is 1
1382        if (preg_match('/^[89abcdef]/', $val))
1383            $prefix = '00';
1384
1385        if (strlen($val)%2 != 0)
1386            $prefix .= '0';
1387
1388        return $prefix . $val;
1389    }
1390
1391    /**
1392     * Returns ascii string encoded in hex
1393     */
1394    private static function _string2hex($str) {
1395        $hex = '';
1396        for ($i=0; $i < strlen($str); $i++)
1397            $hex .= dechex(ord($str[$i]));
1398        return $hex;
1399    }
1400
1401}
Note: See TracBrowser for help on using the repository browser.