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