3 +-----------------------------------------------------------------------+
4 | program/include/rcube_ldap.php |
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 |
12 | Interface to an LDAP address directory |
14 +-----------------------------------------------------------------------+
15 | Author: Thomas Bruederli <roundcube@gmail.com> |
16 | Andreas Dick <andudi (at) gmx (dot) ch> |
17 | Aleksander Machniak <machniak@kolabsys.com> |
18 +-----------------------------------------------------------------------+
20 $Id: rcube_ldap.php 5541 2011-12-04 17:05:42Z thomasb $
26 * Model class to access an LDAP address directory
28 * @package Addressbook
30 class rcube_ldap extends rcube_addressbook
32 /** public properties */
33 public $primary_key = 'ID';
34 public $groups = false;
35 public $readonly = true;
36 public $ready = false;
38 public $list_page = 1;
39 public $page_size = 10;
40 public $coltypes = array();
42 /** private properties */
44 protected $prop = array();
45 protected $fieldmap = array();
47 protected $filter = '';
48 protected $result = null;
49 protected $ldap_result = null;
50 protected $sort_col = '';
51 protected $mail_domain = '';
52 protected $debug = false;
54 private $base_dn = '';
55 private $groups_base_dn = '';
56 private $group_url = null;
59 private $vlv_active = false;
60 private $vlv_count = 0;
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
71 function __construct($p, $debug=false, $mail_domain=NULL)
75 if (isset($p['searchonly']))
76 $this->searchonly = $p['searchonly'];
78 // check if groups are configured
79 if (is_array($p['groups']) && count($p['groups'])) {
82 if (!empty($p['groups']['member_attr']))
83 $this->prop['member_attr'] = strtolower($p['groups']['member_attr']);
84 else if (empty($p['member_attr']))
85 $this->prop['member_attr'] = 'member';
86 // set default name attribute to cn
87 if (empty($this->prop['groups']['name_attr']))
88 $this->prop['groups']['name_attr'] = 'cn';
89 if (empty($this->prop['groups']['scope']))
90 $this->prop['groups']['scope'] = 'sub';
93 // fieldmap property is given
94 if (is_array($p['fieldmap'])) {
95 foreach ($p['fieldmap'] as $rf => $lf)
96 $this->fieldmap[$rf] = $this->_attr_name(strtolower($lf));
99 // read deprecated *_field properties to remain backwards compatible
100 foreach ($p as $prop => $value)
101 if (preg_match('/^(.+)_field$/', $prop, $matches))
102 $this->fieldmap[$matches[1]] = $this->_attr_name(strtolower($value));
105 // use fieldmap to advertise supported coltypes to the application
106 foreach ($this->fieldmap as $col => $lf) {
107 list($col, $type) = explode(':', $col);
108 if (!is_array($this->coltypes[$col])) {
109 $subtypes = $type ? array($type) : null;
110 $this->coltypes[$col] = array('limit' => 2, 'subtypes' => $subtypes);
113 $this->coltypes[$col]['subtypes'][] = $type;
114 $this->coltypes[$col]['limit']++;
116 if ($type && !$this->fieldmap[$col])
117 $this->fieldmap[$col] = $lf;
120 if ($this->fieldmap['street'] && $this->fieldmap['locality'])
121 $this->coltypes['address'] = array('limit' => 1);
122 else if ($this->coltypes['address'])
123 $this->coltypes['address'] = array('type' => 'textarea', 'childs' => null, 'limit' => 1, 'size' => 40);
125 // make sure 'required_fields' is an array
126 if (!is_array($this->prop['required_fields']))
127 $this->prop['required_fields'] = (array) $this->prop['required_fields'];
129 foreach ($this->prop['required_fields'] as $key => $val)
130 $this->prop['required_fields'][$key] = $this->_attr_name(strtolower($val));
132 $this->sort_col = is_array($p['sort']) ? $p['sort'][0] : $p['sort'];
133 $this->debug = $debug;
134 $this->mail_domain = $mail_domain;
137 $rcmail = rcmail::get_instance();
138 $this->cache = $rcmail->get_cache('LDAP.' . asciiwords($this->prop['name']), 'db', 600);
145 * Establish a connection to the LDAP server
147 private function _connect()
151 if (!function_exists('ldap_connect'))
152 raise_error(array('code' => 100, 'type' => 'ldap',
153 'file' => __FILE__, 'line' => __LINE__,
154 'message' => "No ldap support in this installation of PHP"),
157 if (is_resource($this->conn))
160 if (!is_array($this->prop['hosts']))
161 $this->prop['hosts'] = array($this->prop['hosts']);
163 if (empty($this->prop['ldap_version']))
164 $this->prop['ldap_version'] = 3;
166 foreach ($this->prop['hosts'] as $host)
168 $host = idn_to_ascii(rcube_parse_host($host));
169 $hostname = $host.($this->prop['port'] ? ':'.$this->prop['port'] : '');
171 $this->_debug("C: Connect [$hostname]");
173 if ($lc = @ldap_connect($host, $this->prop['port']))
175 if ($this->prop['use_tls'] === true)
176 if (!ldap_start_tls($lc))
179 $this->_debug("S: OK");
181 ldap_set_option($lc, LDAP_OPT_PROTOCOL_VERSION, $this->prop['ldap_version']);
182 $this->prop['host'] = $host;
185 if (isset($this->prop['referrals']))
186 ldap_set_option($lc, LDAP_OPT_REFERRALS, $this->prop['referrals']);
189 $this->_debug("S: NOT OK");
192 // See if the directory is writeable.
193 if ($this->prop['writable']) {
194 $this->readonly = false;
197 if (!is_resource($this->conn)) {
198 raise_error(array('code' => 100, 'type' => 'ldap',
199 'file' => __FILE__, 'line' => __LINE__,
200 'message' => "Could not connect to any LDAP server, last tried $hostname"), true);
205 $bind_pass = $this->prop['bind_pass'];
206 $bind_user = $this->prop['bind_user'];
207 $bind_dn = $this->prop['bind_dn'];
209 $this->base_dn = $this->prop['base_dn'];
210 $this->groups_base_dn = ($this->prop['groups']['base_dn']) ?
211 $this->prop['groups']['base_dn'] : $this->base_dn;
213 // User specific access, generate the proper values to use.
214 if ($this->prop['user_specific']) {
215 // No password set, use the session password
216 if (empty($bind_pass)) {
217 $bind_pass = $RCMAIL->decrypt($_SESSION['password']);
220 // Get the pieces needed for variable replacement.
221 if ($fu = $RCMAIL->user->get_username())
222 list($u, $d) = explode('@', $fu);
224 $d = $this->mail_domain;
226 $dc = 'dc='.strtr($d, array('.' => ',dc=')); // hierarchal domain string
228 $replaces = array('%dn' => '', '%dc' => $dc, '%d' => $d, '%fu' => $fu, '%u' => $u);
230 if ($this->prop['search_base_dn'] && $this->prop['search_filter']) {
231 if (!empty($this->prop['search_bind_dn']) && !empty($this->prop['search_bind_pw'])) {
232 $this->bind($this->prop['search_bind_dn'], $this->prop['search_bind_pw']);
235 // Search for the dn to use to authenticate
236 $this->prop['search_base_dn'] = strtr($this->prop['search_base_dn'], $replaces);
237 $this->prop['search_filter'] = strtr($this->prop['search_filter'], $replaces);
239 $this->_debug("S: searching with base {$this->prop['search_base_dn']} for {$this->prop['search_filter']}");
241 $res = @ldap_search($this->conn, $this->prop['search_base_dn'], $this->prop['search_filter'], array('uid'));
243 if (($entry = ldap_first_entry($this->conn, $res))
244 && ($bind_dn = ldap_get_dn($this->conn, $entry))
246 $this->_debug("S: search returned dn: $bind_dn");
247 $dn = ldap_explode_dn($bind_dn, 1);
248 $replaces['%dn'] = $dn[0];
252 $this->_debug("S: ".ldap_error($this->conn));
256 if (empty($replaces['%dn'])) {
257 if (!empty($this->prop['search_dn_default']))
258 $replaces['%dn'] = $this->prop['search_dn_default'];
261 'code' => 100, 'type' => 'ldap',
262 'file' => __FILE__, 'line' => __LINE__,
263 'message' => "DN not found using LDAP search."), true);
269 // Replace the bind_dn and base_dn variables.
270 $bind_dn = strtr($bind_dn, $replaces);
271 $this->base_dn = strtr($this->base_dn, $replaces);
272 $this->groups_base_dn = strtr($this->groups_base_dn, $replaces);
274 if (empty($bind_user)) {
279 if (empty($bind_pass)) {
283 if (!empty($bind_dn)) {
284 $this->ready = $this->bind($bind_dn, $bind_pass);
286 else if (!empty($this->prop['auth_cid'])) {
287 $this->ready = $this->sasl_bind($this->prop['auth_cid'], $bind_pass, $bind_user);
290 $this->ready = $this->sasl_bind($bind_user, $bind_pass);
299 * Bind connection with (SASL-) user and password
301 * @param string $authc Authentication user
302 * @param string $pass Bind password
303 * @param string $authz Autorization user
305 * @return boolean True on success, False on error
307 public function sasl_bind($authc, $pass, $authz=null)
313 if (!function_exists('ldap_sasl_bind')) {
314 raise_error(array('code' => 100, 'type' => 'ldap',
315 'file' => __FILE__, 'line' => __LINE__,
316 'message' => "Unable to bind: ldap_sasl_bind() not exists"),
320 if (!empty($authz)) {
321 $authz = 'u:' . $authz;
324 if (!empty($this->prop['auth_method'])) {
325 $method = $this->prop['auth_method'];
328 $method = 'DIGEST-MD5';
331 $this->_debug("C: Bind [mech: $method, authc: $authc, authz: $authz] [pass: $pass]");
333 if (ldap_sasl_bind($this->conn, NULL, $pass, $method, NULL, $authc, $authz)) {
334 $this->_debug("S: OK");
338 $this->_debug("S: ".ldap_error($this->conn));
341 'code' => ldap_errno($this->conn), 'type' => 'ldap',
342 'file' => __FILE__, 'line' => __LINE__,
343 'message' => "Bind failed for authcid=$authc ".ldap_error($this->conn)),
351 * Bind connection with DN and password
353 * @param string Bind DN
354 * @param string Bind password
356 * @return boolean True on success, False on error
358 public function bind($dn, $pass)
364 $this->_debug("C: Bind [dn: $dn] [pass: $pass]");
366 if (@ldap_bind($this->conn, $dn, $pass)) {
367 $this->_debug("S: OK");
371 $this->_debug("S: ".ldap_error($this->conn));
374 'code' => ldap_errno($this->conn), 'type' => 'ldap',
375 'file' => __FILE__, 'line' => __LINE__,
376 'message' => "Bind failed for dn=$dn: ".ldap_error($this->conn)),
384 * Close connection to LDAP server
390 $this->_debug("C: Close");
391 ldap_unbind($this->conn);
398 * Returns address book name
400 * @return string Address book name
404 return $this->prop['name'];
409 * Set internal list page
411 * @param number $page Page number to list
413 function set_page($page)
415 $this->list_page = (int)$page;
420 * Set internal page size
422 * @param number $size Number of messages to display on one page
424 function set_pagesize($size)
426 $this->page_size = (int)$size;
431 * Save a search string for future listings
433 * @param string $filter Filter string
435 function set_search_set($filter)
437 $this->filter = $filter;
442 * Getter for saved search properties
444 * @return mixed Search properties used by this class
446 function get_search_set()
448 return $this->filter;
453 * Reset all saved results and search parameters
457 $this->result = null;
458 $this->ldap_result = null;
464 * List the current set of contact records
466 * @param array List of cols to show
467 * @param int Only return this number of records
469 * @return array Indexed list of contact records, each a hash array
471 function list_records($cols=null, $subset=0)
473 if ($this->prop['searchonly'] && empty($this->filter) && !$this->group_id)
475 $this->result = new rcube_result_set(0);
476 $this->result->searchonly = true;
477 return $this->result;
480 // fetch group members recursively
481 if ($this->group_id && $this->group_data['dn'])
483 $entries = $this->list_group_members($this->group_data['dn']);
485 // make list of entries unique and sort it
487 foreach ($entries as $i => $rec) {
488 if ($seen[$rec['dn']]++)
491 usort($entries, array($this, '_entry_sort_cmp'));
493 $entries['count'] = count($entries);
494 $this->result = new rcube_result_set($entries['count'], ($this->list_page-1) * $this->page_size);
498 // add general filter to query
499 if (!empty($this->prop['filter']) && empty($this->filter))
500 $this->set_search_set($this->prop['filter']);
502 // exec LDAP search if no result resource is stored
503 if ($this->conn && !$this->ldap_result)
504 $this->_exec_search();
506 // count contacts for this user
507 $this->result = $this->count();
509 // we have a search result resource
510 if ($this->ldap_result && $this->result->count > 0)
512 // sorting still on the ldap server
513 if ($this->sort_col && $this->prop['scope'] !== 'base' && !$this->vlv_active)
514 ldap_sort($this->conn, $this->ldap_result, $this->sort_col);
516 // get all entries from the ldap server
517 $entries = ldap_get_entries($this->conn, $this->ldap_result);
522 // start and end of the page
523 $start_row = $this->vlv_active ? 0 : $this->result->first;
524 $start_row = $subset < 0 ? $start_row + $this->page_size + $subset : $start_row;
525 $last_row = $this->result->first + $this->page_size;
526 $last_row = $subset != 0 ? $start_row + abs($subset) : $last_row;
528 // filter entries for this page
529 for ($i = $start_row; $i < min($entries['count'], $last_row); $i++)
530 $this->result->add($this->_ldap2result($entries[$i]));
532 return $this->result;
536 * Get all members of the given group
538 * @param string Group DN
539 * @param array Group entries (if called recursively)
540 * @return array Accumulated group members
542 function list_group_members($dn, $count = false, $entries = null)
544 $group_members = array();
546 // fetch group object
547 if (empty($entries)) {
548 $result = @ldap_read($this->conn, $dn, '(objectClass=*)', array('dn','objectClass','member','uniqueMember','memberURL'));
549 if ($result === false)
551 $this->_debug("S: ".ldap_error($this->conn));
552 return $group_members;
555 $entries = @ldap_get_entries($this->conn, $result);
558 for ($i=0; $i < $entries["count"]; $i++)
560 $entry = $entries[$i];
562 if (empty($entry['objectclass']))
565 foreach ((array)$entry['objectclass'] as $objectclass)
567 switch (strtolower($objectclass)) {
569 case "kolabgroupofnames":
570 $group_members = array_merge($group_members, $this->_list_group_members($dn, $entry, 'member', $count));
572 case "groupofuniquenames":
573 case "kolabgroupofuniquenames":
574 $group_members = array_merge($group_members, $this->_list_group_members($dn, $entry, 'uniquemember', $count));
577 $group_members = array_merge($group_members, $this->_list_group_memberurl($dn, $entry, $count));
582 if ($this->prop['sizelimit'] && count($group_members) > $this->prop['sizelimit'])
586 return array_filter($group_members);
590 * Fetch members of the given group entry from server
592 * @param string Group DN
593 * @param array Group entry
594 * @param string Member attribute to use
595 * @return array Accumulated group members
597 private function _list_group_members($dn, $entry, $attr, $count)
599 // Use the member attributes to return an array of member ldap objects
600 // NOTE that the member attribute is supposed to contain a DN
601 $group_members = array();
602 if (empty($entry[$attr]))
603 return $group_members;
605 // read these attributes for all members
606 $attrib = $count ? array('dn') : array_values($this->fieldmap);
607 $attrib[] = 'objectClass';
608 $attrib[] = 'member';
609 $attrib[] = 'uniqueMember';
610 $attrib[] = 'memberURL';
612 for ($i=0; $i < $entry[$attr]['count']; $i++)
614 $result = @ldap_read($this->conn, $entry[$attr][$i], '(objectclass=*)',
615 $attrib, 0, (int)$this->prop['sizelimit'], (int)$this->prop['timelimit']);
617 $members = @ldap_get_entries($this->conn, $result);
618 if ($members == false)
620 $this->_debug("S: ".ldap_error($this->conn));
624 // for nested groups, call recursively
625 $nested_group_members = $this->list_group_members($entry[$attr][$i], $count, $members);
627 unset($members['count']);
628 $group_members = array_merge($group_members, array_filter($members), $nested_group_members);
631 return $group_members;
635 * List members of group class groupOfUrls
637 * @param string Group DN
638 * @param array Group entry
639 * @param boolean True if only used for counting
640 * @return array Accumulated group members
642 private function _list_group_memberurl($dn, $entry, $count)
644 $group_members = array();
646 for ($i=0; $i < $entry['memberurl']['count']; $i++)
648 // extract components from url
649 if (!preg_match('!ldap:///([^\?]+)\?\?(\w+)\?(.*)$!', $entry['memberurl'][$i], $m))
652 // add search filter if any
653 $filter = $this->filter ? '(&(' . $m[3] . ')(' . $this->filter . '))' : $m[3];
654 $func = $m[2] == 'sub' ? 'ldap_search' : ($m[2] == 'base' ? 'ldap_read' : 'ldap_list');
656 $attrib = $count ? array('dn') : array_values($this->fieldmap);
657 if ($result = @$func($this->conn, $m[1], $filter,
658 $attrib, 0, (int)$this->prop['sizelimit'], (int)$this->prop['timelimit'])
660 $this->_debug("S: ".ldap_count_entries($this->conn, $result)." record(s) for ".$m[1]);
663 $this->_debug("S: ".ldap_error($this->conn));
664 return $group_members;
667 $entries = @ldap_get_entries($this->conn, $result);
668 for ($j = 0; $j < $entries['count']; $j++)
670 if ($nested_group_members = $this->list_group_members($entries[$j]['dn'], $count))
671 $group_members = array_merge($group_members, $nested_group_members);
673 $group_members[] = $entries[$j];
677 return $group_members;
681 * Callback for sorting entries
683 function _entry_sort_cmp($a, $b)
685 return strcmp($a[$this->sort_col][0], $b[$this->sort_col][0]);
692 * @param mixed $fields The field name of array of field names to search in
693 * @param mixed $value Search value (or array of values when $fields is array)
694 * @param int $mode Matching mode:
695 * 0 - partial (*abc*),
698 * @param boolean $select True if results are requested, False if count only
699 * @param boolean $nocount (Not used)
700 * @param array $required List of fields that cannot be empty
702 * @return array Indexed list of contact records and 'count' value
704 function search($fields, $value, $mode=0, $select=true, $nocount=false, $required=array())
706 $mode = intval($mode);
708 // special treatment for ID-based search
709 if ($fields == 'ID' || $fields == $this->primary_key)
711 $ids = !is_array($value) ? explode(',', $value) : $value;
712 $result = new rcube_result_set();
713 foreach ($ids as $id)
715 if ($rec = $this->get_record($id, true))
724 // use VLV pseudo-search for autocompletion
725 if ($this->prop['vlv_search'] && $this->conn && join(',', (array)$fields) == 'email,name')
727 // add general filter to query
728 if (!empty($this->prop['filter']) && empty($this->filter))
729 $this->set_search_set($this->prop['filter']);
731 // set VLV controls with encoded search string
732 $this->_vlv_set_controls($this->prop, $this->list_page, $this->page_size, $value);
734 $function = $this->_scope2func($this->prop['scope']);
735 $this->ldap_result = @$function($this->conn, $this->base_dn, $this->filter ? $this->filter : '(objectclass=*)',
736 array_values($this->fieldmap), 0, $this->page_size, (int)$this->prop['timelimit']);
738 $this->result = new rcube_result_set(0);
740 if (!$this->ldap_result) {
741 $this->_debug("S: ".ldap_error($this->conn));
742 return $this->result;
745 $this->_debug("S: ".ldap_count_entries($this->conn, $this->ldap_result)." record(s)");
747 // get all entries of this page and post-filter those that really match the query
748 $search = mb_strtolower($value);
749 $entries = ldap_get_entries($this->conn, $this->ldap_result);
751 for ($i = 0; $i < $entries['count']; $i++) {
752 $rec = $this->_ldap2result($entries[$i]);
753 foreach (array('email', 'name') as $f) {
754 $val = mb_strtolower($rec[$f]);
757 $got = ($val == $search);
760 $got = ($search == substr($val, 0, strlen($search)));
763 $got = (strpos($val, $search) !== false);
768 $this->result->add($rec);
769 $this->result->count++;
775 return $this->result;
778 // use AND operator for advanced searches
779 $filter = is_array($value) ? '(&' : '(|';
782 if (!empty($this->prop['fuzzy_search']) && $mode != 1) {
791 // search_fields are required for fulltext search
792 if (empty($this->prop['search_fields']))
794 $this->set_error(self::ERROR_SEARCH, 'nofulltextsearch');
795 $this->result = new rcube_result_set();
796 return $this->result;
798 if (is_array($this->prop['search_fields']))
800 foreach ($this->prop['search_fields'] as $field) {
801 $filter .= "($field=$wp" . $this->_quote_string($value) . "$ws)";
807 foreach ((array)$fields as $idx => $field) {
808 $val = is_array($value) ? $value[$idx] : $value;
809 if ($f = $this->_map_field($field)) {
810 $filter .= "($f=$wp" . $this->_quote_string($val) . "$ws)";
816 // add required (non empty) fields filter
818 foreach ((array)$required as $field)
819 if ($f = $this->_map_field($field))
820 $req_filter .= "($f=*)";
822 if (!empty($req_filter))
823 $filter = '(&' . $req_filter . $filter . ')';
825 // avoid double-wildcard if $value is empty
826 $filter = preg_replace('/\*+/', '*', $filter);
828 // add general filter to query
829 if (!empty($this->prop['filter']))
830 $filter = '(&(' . preg_replace('/^\(|\)$/', '', $this->prop['filter']) . ')' . $filter . ')';
832 // set filter string and execute search
833 $this->set_search_set($filter);
834 $this->_exec_search();
837 $this->list_records();
839 $this->result = $this->count();
841 return $this->result;
846 * Count number of available contacts in database
848 * @return object rcube_result_set Resultset with values for 'count' and 'first'
853 if ($this->conn && $this->ldap_result) {
854 $count = $this->vlv_active ? $this->vlv_count : ldap_count_entries($this->conn, $this->ldap_result);
856 else if ($this->group_id && $this->group_data['dn']) {
857 $count = count($this->list_group_members($this->group_data['dn'], true));
859 else if ($this->conn) {
860 // We have a connection but no result set, attempt to get one.
861 if (empty($this->filter)) {
862 // The filter is not set, set it.
863 $this->filter = $this->prop['filter'];
865 $this->_exec_search(true);
866 if ($this->ldap_result) {
867 $count = ldap_count_entries($this->conn, $this->ldap_result);
871 return new rcube_result_set($count, ($this->list_page-1) * $this->page_size);
876 * Return the last result set
878 * @return object rcube_result_set Current resultset or NULL if nothing selected yet
880 function get_result()
882 return $this->result;
887 * Get a specific contact record
889 * @param mixed Record identifier
890 * @param boolean Return as associative array
892 * @return mixed Hash array or rcube_result_set with all record fields
894 function get_record($dn, $assoc=false)
897 if ($this->conn && $dn)
899 $dn = self::dn_decode($dn);
901 $this->_debug("C: Read [dn: $dn] [(objectclass=*)]");
903 if ($this->ldap_result = @ldap_read($this->conn, $dn, '(objectclass=*)', array_values($this->fieldmap)))
904 $entry = ldap_first_entry($this->conn, $this->ldap_result);
906 $this->_debug("S: ".ldap_error($this->conn));
908 if ($entry && ($rec = ldap_get_attributes($this->conn, $entry)))
910 $this->_debug("S: OK"/* . print_r($rec, true)*/);
912 $rec = array_change_key_case($rec, CASE_LOWER);
914 // Add in the dn for the entry.
916 $res = $this->_ldap2result($rec);
917 $this->result = new rcube_result_set(1);
918 $this->result->add($res);
922 return $assoc ? $res : $this->result;
927 * Check the given data before saving.
928 * If input not valid, the message to display can be fetched using get_error()
930 * @param array Assoziative array with data to save
931 * @param boolean Try to fix/complete record automatically
932 * @return boolean True if input is valid, False if not.
934 public function validate(&$save_data, $autofix = false)
936 // check for name input
937 if (empty($save_data['name'])) {
938 $this->set_error(self::ERROR_VALIDATE, 'nonamewarning');
942 // Verify that the required fields are set.
944 $ldap_data = $this->_map_data($save_data);
945 foreach ($this->prop['required_fields'] as $fld) {
946 if (!isset($ldap_data[$fld])) {
952 // try to complete record automatically
954 $reverse_map = array_flip($this->fieldmap);
955 $name_parts = preg_split('/[\s,.]+/', $save_data['name']);
956 if ($missing['sn']) {
957 $sn_field = $reverse_map['sn'];
958 $save_data[$sn_field] = array_pop ($name_parts);
960 if ($missing[($fn_field = $this->fieldmap['firstname'])]) {
961 $save_data['firstname'] = array_shift($name_parts);
964 return $this->validate($save_data, false);
967 // TODO: generate message saying which fields are missing
968 $this->set_error(self::ERROR_VALIDATE, 'formincomplete');
972 // validate e-mail addresses
973 return parent::validate($save_data, $autofix);
978 * Create a new contact record
980 * @param array Hash array with save data
982 * @return encoded record ID on success, False on error
984 function insert($save_cols)
986 // Map out the column names to their LDAP ones to build the new entry.
987 $newentry = $this->_map_data($save_cols);
988 $newentry['objectClass'] = $this->prop['LDAP_Object_Classes'];
990 // Verify that the required fields are set.
992 foreach ($this->prop['required_fields'] as $fld) {
993 if (!isset($newentry[$fld])) {
998 // abort process if requiered fields are missing
999 // TODO: generate message saying which fields are missing
1001 $this->set_error(self::ERROR_VALIDATE, 'formincomplete');
1005 // Build the new entries DN.
1006 $dn = $this->prop['LDAP_rdn'].'='.$this->_quote_string($newentry[$this->prop['LDAP_rdn']], true).','.$this->base_dn;
1008 $this->_debug("C: Add [dn: $dn]: ".print_r($newentry, true));
1010 $res = ldap_add($this->conn, $dn, $newentry);
1011 if ($res === FALSE) {
1012 $this->_debug("S: ".ldap_error($this->conn));
1013 $this->set_error(self::ERROR_SAVING, 'errorsaving');
1017 $this->_debug("S: OK");
1019 $dn = self::dn_encode($dn);
1021 // add new contact to the selected group
1022 if ($this->group_id)
1023 $this->add_to_group($this->group_id, $dn);
1030 * Update a specific contact record
1032 * @param mixed Record identifier
1033 * @param array Hash array with save data
1035 * @return boolean True on success, False on error
1037 function update($id, $save_cols)
1039 $record = $this->get_record($id, true);
1040 $result = $this->get_result();
1041 $record = $result->first();
1044 $replacedata = array();
1045 $deletedata = array();
1047 $ldap_data = $this->_map_data($save_cols);
1048 $old_data = $record['_raw_attrib'];
1050 foreach ($this->fieldmap as $col => $fld) {
1051 $val = $ldap_data[$fld];
1053 // remove empty array values
1055 $val = array_filter($val);
1056 // The field does exist compare it to the ldap record.
1057 if ($old_data[$fld] != $val) {
1058 // Changed, but find out how.
1059 if (!isset($old_data[$fld])) {
1060 // Field was not set prior, need to add it.
1061 $newdata[$fld] = $val;
1063 else if ($val == '') {
1064 // Field supplied is empty, verify that it is not required.
1065 if (!in_array($fld, $this->prop['required_fields'])) {
1066 // It is not, safe to clear.
1067 $deletedata[$fld] = $old_data[$fld];
1071 // The data was modified, save it out.
1072 $replacedata[$fld] = $val;
1078 $dn = self::dn_decode($id);
1080 // Update the entry as required.
1081 if (!empty($deletedata)) {
1082 // Delete the fields.
1083 $this->_debug("C: Delete [dn: $dn]: ".print_r($deletedata, true));
1084 if (!ldap_mod_del($this->conn, $dn, $deletedata)) {
1085 $this->_debug("S: ".ldap_error($this->conn));
1086 $this->set_error(self::ERROR_SAVING, 'errorsaving');
1089 $this->_debug("S: OK");
1092 if (!empty($replacedata)) {
1093 // Handle RDN change
1094 if ($replacedata[$this->prop['LDAP_rdn']]) {
1095 $newdn = $this->prop['LDAP_rdn'].'='
1096 .$this->_quote_string($replacedata[$this->prop['LDAP_rdn']], true)
1097 .','.$this->base_dn;
1098 if ($dn != $newdn) {
1099 $newrdn = $this->prop['LDAP_rdn'].'='
1100 .$this->_quote_string($replacedata[$this->prop['LDAP_rdn']], true);
1101 unset($replacedata[$this->prop['LDAP_rdn']]);
1104 // Replace the fields.
1105 if (!empty($replacedata)) {
1106 $this->_debug("C: Replace [dn: $dn]: ".print_r($replacedata, true));
1107 if (!ldap_mod_replace($this->conn, $dn, $replacedata)) {
1108 $this->_debug("S: ".ldap_error($this->conn));
1111 $this->_debug("S: OK");
1115 if (!empty($newdata)) {
1117 $this->_debug("C: Add [dn: $dn]: ".print_r($newdata, true));
1118 if (!ldap_mod_add($this->conn, $dn, $newdata)) {
1119 $this->_debug("S: ".ldap_error($this->conn));
1120 $this->set_error(self::ERROR_SAVING, 'errorsaving');
1123 $this->_debug("S: OK");
1126 // Handle RDN change
1127 if (!empty($newrdn)) {
1128 $this->_debug("C: Rename [dn: $dn] [dn: $newrdn]");
1129 if (!ldap_rename($this->conn, $dn, $newrdn, NULL, TRUE)) {
1130 $this->_debug("S: ".ldap_error($this->conn));
1133 $this->_debug("S: OK");
1135 $dn = self::dn_encode($dn);
1136 $newdn = self::dn_encode($newdn);
1138 // change the group membership of the contact
1141 $group_ids = $this->get_record_groups($dn);
1142 foreach ($group_ids as $group_id)
1144 $this->remove_from_group($group_id, $dn);
1145 $this->add_to_group($group_id, $newdn);
1157 * Mark one or more contact records as deleted
1159 * @param array Record identifiers
1160 * @param boolean Remove record(s) irreversible (unsupported)
1162 * @return boolean True on success, False on error
1164 function delete($ids, $force=true)
1166 if (!is_array($ids)) {
1167 // Not an array, break apart the encoded DNs.
1168 $ids = explode(',', $ids);
1171 foreach ($ids as $id) {
1172 $dn = self::dn_decode($id);
1173 $this->_debug("C: Delete [dn: $dn]");
1174 // Delete the record.
1175 $res = ldap_delete($this->conn, $dn);
1176 if ($res === FALSE) {
1177 $this->_debug("S: ".ldap_error($this->conn));
1178 $this->set_error(self::ERROR_SAVING, 'errorsaving');
1181 $this->_debug("S: OK");
1183 // remove contact from all groups where he was member
1184 if ($this->groups) {
1185 $dn = self::dn_encode($dn);
1186 $group_ids = $this->get_record_groups($dn);
1187 foreach ($group_ids as $group_id) {
1188 $this->remove_from_group($group_id, $dn);
1198 * Execute the LDAP search based on the stored credentials
1200 private function _exec_search($count = false)
1204 $filter = $this->filter ? $this->filter : '(objectclass=*)';
1205 $function = $this->_scope2func($this->prop['scope'], $ns_function);
1207 $this->_debug("C: Search [$filter][dn: $this->base_dn]");
1209 // when using VLV, we get the total count by...
1210 if (!$count && $function != 'ldap_read' && $this->prop['vlv'] && !$this->group_id) {
1211 // ...either reading numSubOrdinates attribute
1212 if ($this->prop['numsub_filter'] && ($result_count = @$ns_function($this->conn, $this->base_dn, $this->prop['numsub_filter'], array('numSubOrdinates'), 0, 0, 0))) {
1213 $counts = ldap_get_entries($this->conn, $result_count);
1214 for ($this->vlv_count = $j = 0; $j < $counts['count']; $j++)
1215 $this->vlv_count += $counts[$j]['numsubordinates'][0];
1216 $this->_debug("D: total numsubordinates = " . $this->vlv_count);
1218 else // ...or by fetching all records dn and count them
1219 $this->vlv_count = $this->_exec_search(true);
1221 $this->vlv_active = $this->_vlv_set_controls($this->prop, $this->list_page, $this->page_size);
1224 // only fetch dn for count (should keep the payload low)
1225 $attrs = $count ? array('dn') : array_values($this->fieldmap);
1226 if ($this->ldap_result = @$function($this->conn, $this->base_dn, $filter,
1227 $attrs, 0, (int)$this->prop['sizelimit'], (int)$this->prop['timelimit'])
1229 $entries_count = ldap_count_entries($this->conn, $this->ldap_result);
1230 $this->_debug("S: $count_entries record(s)");
1232 return $count ? $count_entries : true;
1235 $this->_debug("S: ".ldap_error($this->conn));
1243 * Choose the right PHP function according to scope property
1245 private function _scope2func($scope, &$ns_function = null)
1249 $function = $ns_function = 'ldap_search';
1252 $function = $ns_function = 'ldap_read';
1255 $function = 'ldap_list';
1256 $ns_function = 'ldap_read';
1264 * Set server controls for Virtual List View (paginated listing)
1266 private function _vlv_set_controls($prop, $list_page, $page_size, $search = null)
1268 $sort_ctrl = array('oid' => "1.2.840.113556.1.4.473", 'value' => $this->_sort_ber_encode((array)$prop['sort']));
1269 $vlv_ctrl = array('oid' => "2.16.840.1.113730.3.4.9", 'value' => $this->_vlv_ber_encode(($offset = ($list_page-1) * $page_size + 1), $page_size, $search), 'iscritical' => true);
1271 $sort = (array)$prop['sort'];
1272 $this->_debug("C: set controls sort=" . join(' ', unpack('H'.(strlen($sort_ctrl['value'])*2), $sort_ctrl['value'])) . " ($sort[0]);"
1273 . " vlv=" . join(' ', (unpack('H'.(strlen($vlv_ctrl['value'])*2), $vlv_ctrl['value']))) . " ($offset/$page_size)");
1275 if (!ldap_set_option($this->conn, LDAP_OPT_SERVER_CONTROLS, array($sort_ctrl, $vlv_ctrl))) {
1276 $this->_debug("S: ".ldap_error($this->conn));
1277 $this->set_error(self::ERROR_SEARCH, 'vlvnotsupported');
1286 * Converts LDAP entry into an array
1288 private function _ldap2result($rec)
1293 $out[$this->primary_key] = self::dn_encode($rec['dn']);
1295 foreach ($this->fieldmap as $rf => $lf)
1297 for ($i=0; $i < $rec[$lf]['count']; $i++) {
1298 if (!($value = $rec[$lf][$i]))
1301 $out['_raw_attrib'][$lf][$i] = $value;
1303 if ($rf == 'email' && $this->mail_domain && !strpos($value, '@'))
1304 $out[$rf][] = sprintf('%s@%s', $value, $this->mail_domain);
1305 else if (in_array($rf, array('street','zipcode','locality','country','region')))
1306 $out['address'][$i][$rf] = $value;
1307 else if ($rec[$lf]['count'] > 1)
1308 $out[$rf][] = $value;
1313 // Make sure name fields aren't arrays (#1488108)
1314 if (is_array($out[$rf]) && in_array($rf, array('name', 'surname', 'firstname', 'middlename', 'nickname'))) {
1315 $out[$rf] = $out['_raw_attrib'][$lf] = $out[$rf][0];
1324 * Return real field name (from fields map)
1326 private function _map_field($field)
1328 return $this->fieldmap[$field];
1333 * Convert a record data set into LDAP field attributes
1335 private function _map_data($save_cols)
1337 // flatten composite fields first
1338 foreach ($this->coltypes as $col => $colprop) {
1339 if (is_array($colprop['childs']) && ($values = $this->get_col_values($col, $save_cols, false))) {
1340 foreach ($values as $subtype => $childs) {
1341 $subtype = $subtype ? ':'.$subtype : '';
1342 foreach ($childs as $i => $child_values) {
1343 foreach ((array)$child_values as $childcol => $value) {
1344 $save_cols[$childcol.$subtype][$i] = $value;
1351 $ldap_data = array();
1352 foreach ($this->fieldmap as $col => $fld) {
1353 $val = $save_cols[$col];
1355 $val = array_filter($val); // remove empty entries
1357 // The field does exist, add it to the entry.
1358 $ldap_data[$fld] = $val;
1367 * Returns unified attribute name (resolving aliases)
1369 private static function _attr_name($name)
1371 // list of known attribute aliases
1373 'gn' => 'givenname',
1374 'rfc822mailbox' => 'email',
1376 'emailaddress' => 'email',
1377 'pkcs9email' => 'email',
1379 return isset($aliases[$name]) ? $aliases[$name] : $name;
1384 * Prints debug info to the log
1386 private function _debug($str)
1389 write_log('ldap', $str);
1394 * Activate/deactivate debug mode
1396 * @param boolean $dbg True if LDAP commands should be logged
1399 function set_debug($dbg = true)
1401 $this->debug = $dbg;
1406 * Quotes attribute value string
1408 * @param string $str Attribute value
1409 * @param bool $dn True if the attribute is a DN
1411 * @return string Quoted string
1413 private static function _quote_string($str, $dn=false)
1415 // take firt entry if array given
1420 $replace = array(','=>'\2c', '='=>'\3d', '+'=>'\2b', '<'=>'\3c',
1421 '>'=>'\3e', ';'=>'\3b', '\\'=>'\5c', '"'=>'\22', '#'=>'\23');
1423 $replace = array('*'=>'\2a', '('=>'\28', ')'=>'\29', '\\'=>'\5c',
1426 return strtr($str, $replace);
1431 * Setter for the current group
1432 * (empty, has to be re-implemented by extending class)
1434 function set_group($group_id)
1438 if (($group_cache = $this->cache->get('groups')) === null)
1439 $group_cache = $this->_fetch_groups();
1441 $this->group_id = $group_id;
1442 $this->group_data = $group_cache[$group_id];
1446 $this->group_id = 0;
1447 $this->group_data = null;
1452 * List all active contact groups of this source
1454 * @param string Optional search string to match group name
1455 * @return array Indexed list of contact groups, each a hash array
1457 function list_groups($search = null)
1462 // use cached list for searching
1463 $this->cache->expunge();
1464 if (!$search || ($group_cache = $this->cache->get('groups')) === null)
1465 $group_cache = $this->_fetch_groups();
1469 $search = mb_strtolower($search);
1470 foreach ($group_cache as $group) {
1471 if (strpos(mb_strtolower($group['name']), $search) !== false)
1476 $groups = $group_cache;
1478 return array_values($groups);
1482 * Fetch groups from server
1484 private function _fetch_groups($vlv_page = 0)
1486 $base_dn = $this->groups_base_dn;
1487 $filter = $this->prop['groups']['filter'];
1488 $name_attr = $this->prop['groups']['name_attr'];
1489 $email_attr = $this->prop['groups']['email_attr'] ? $this->prop['groups']['email_attr'] : 'mail';
1490 $sort_attrs = $this->prop['groups']['sort'] ? (array)$this->prop['groups']['sort'] : array($name_attr);
1491 $sort_attr = $sort_attrs[0];
1493 $this->_debug("C: Search [$filter][dn: $base_dn]");
1495 // use vlv to list groups
1496 if ($this->prop['groups']['vlv']) {
1498 if (!$this->prop['groups']['sort'])
1499 $this->prop['groups']['sort'] = $sort_attrs;
1500 $vlv_active = $this->_vlv_set_controls($this->prop['groups'], $vlv_page+1, $page_size);
1503 $function = $this->_scope2func($this->prop['groups']['scope'], $ns_function);
1504 $res = @$function($this->conn, $base_dn, $filter, array_unique(array('dn', 'objectClass', $name_attr, $email_attr, $sort_attr)));
1507 $this->_debug("S: ".ldap_error($this->conn));
1511 $ldap_data = ldap_get_entries($this->conn, $res);
1512 $this->_debug("S: ".ldap_count_entries($this->conn, $res)." record(s)");
1515 $group_sortnames = array();
1516 $group_count = $ldap_data["count"];
1517 for ($i=0; $i < $group_count; $i++)
1519 $group_name = is_array($ldap_data[$i][$name_attr]) ? $ldap_data[$i][$name_attr][0] : $ldap_data[$i][$name_attr];
1520 $group_id = self::dn_encode($group_name);
1521 $groups[$group_id]['ID'] = $group_id;
1522 $groups[$group_id]['dn'] = $ldap_data[$i]['dn'];
1523 $groups[$group_id]['name'] = $group_name;
1524 $groups[$group_id]['member_attr'] = $this->prop['member_attr'];
1526 // check objectClass attributes of group and act accordingly
1527 for ($j=0; $j < $ldap_data[$i]['objectclass']['count']; $j++) {
1528 switch (strtolower($ldap_data[$i]['objectclass'][$j])) {
1529 case 'groupofnames':
1530 case 'kolabgroupofnames':
1531 $groups[$group_id]['member_attr'] = 'member';
1534 case 'groupofuniquenames':
1535 case 'kolabgroupofuniquenames':
1536 $groups[$group_id]['member_attr'] = 'uniqueMember';
1541 // list email attributes of a group
1542 for ($j=0; $ldap_data[$i][$email_attr] && $j < $ldap_data[$i][$email_attr]['count']; $j++) {
1543 if (strpos($ldap_data[$i][$email_attr][$j], '@') > 0)
1544 $groups[$group_id]['email'][] = $ldap_data[$i][$email_attr][$j];
1547 $group_sortnames[] = mb_strtolower($ldap_data[$i][$sort_attr][0]);
1550 // recursive call can exit here
1554 // call recursively until we have fetched all groups
1555 while ($vlv_active && $group_count == $page_size)
1557 $next_page = $this->_fetch_groups(++$vlv_page);
1558 $groups = array_merge($groups, $next_page);
1559 $group_count = count($next_page);
1562 // when using VLV the list of groups is already sorted
1563 if (!$this->prop['groups']['vlv'])
1564 array_multisort($group_sortnames, SORT_ASC, SORT_STRING, $groups);
1567 $this->cache->set('groups', $groups);
1573 * Get group properties such as name and email address(es)
1575 * @param string Group identifier
1576 * @return array Group properties as hash array
1578 function get_group($group_id)
1580 if (($group_cache = $this->cache->get('groups')) === null)
1581 $group_cache = $this->_fetch_groups();
1583 $group_data = $group_cache[$group_id];
1584 unset($group_data['dn'], $group_data['member_attr']);
1590 * Create a contact group with the given name
1592 * @param string The group name
1593 * @return mixed False on error, array with record props in success
1595 function create_group($group_name)
1597 $base_dn = $this->groups_base_dn;
1598 $new_dn = "cn=$group_name,$base_dn";
1599 $new_gid = self::dn_encode($group_name);
1600 $member_attr = $this->prop['groups']['member_attr'];
1601 $name_attr = $this->prop['groups']['name_attr'];
1604 'objectClass' => $this->prop['groups']['object_classes'],
1605 $name_attr => $group_name,
1609 $this->_debug("C: Add [dn: $new_dn]: ".print_r($new_entry, true));
1611 $res = ldap_add($this->conn, $new_dn, $new_entry);
1614 $this->_debug("S: ".ldap_error($this->conn));
1615 $this->set_error(self::ERROR_SAVING, 'errorsaving');
1619 $this->_debug("S: OK");
1620 $this->cache->remove('groups');
1622 return array('id' => $new_gid, 'name' => $group_name);
1626 * Delete the given group and all linked group members
1628 * @param string Group identifier
1629 * @return boolean True on success, false if no data was changed
1631 function delete_group($group_id)
1633 if (($group_cache = $this->cache->get('groups')) === null)
1634 $group_cache = $this->_fetch_groups();
1636 $base_dn = $this->groups_base_dn;
1637 $group_name = $group_cache[$group_id]['name'];
1638 $del_dn = "cn=$group_name,$base_dn";
1640 $this->_debug("C: Delete [dn: $del_dn]");
1642 $res = ldap_delete($this->conn, $del_dn);
1645 $this->_debug("S: ".ldap_error($this->conn));
1646 $this->set_error(self::ERROR_SAVING, 'errorsaving');
1650 $this->_debug("S: OK");
1651 $this->cache->remove('groups');
1657 * Rename a specific contact group
1659 * @param string Group identifier
1660 * @param string New name to set for this group
1661 * @param string New group identifier (if changed, otherwise don't set)
1662 * @return boolean New name on success, false if no data was changed
1664 function rename_group($group_id, $new_name, &$new_gid)
1666 if (($group_cache = $this->cache->get('groups')) === null)
1667 $group_cache = $this->_fetch_groups();
1669 $base_dn = $this->groups_base_dn;
1670 $group_name = $group_cache[$group_id]['name'];
1671 $old_dn = "cn=$group_name,$base_dn";
1672 $new_rdn = "cn=$new_name";
1673 $new_gid = self::dn_encode($new_name);
1675 $this->_debug("C: Rename [dn: $old_dn] [dn: $new_rdn]");
1677 $res = ldap_rename($this->conn, $old_dn, $new_rdn, NULL, TRUE);
1680 $this->_debug("S: ".ldap_error($this->conn));
1681 $this->set_error(self::ERROR_SAVING, 'errorsaving');
1685 $this->_debug("S: OK");
1686 $this->cache->remove('groups');
1692 * Add the given contact records the a certain group
1694 * @param string Group identifier
1695 * @param array List of contact identifiers to be added
1696 * @return int Number of contacts added
1698 function add_to_group($group_id, $contact_ids)
1700 if (($group_cache = $this->cache->get('groups')) === null)
1701 $group_cache = $this->_fetch_groups();
1703 if (!is_array($contact_ids))
1704 $contact_ids = explode(',', $contact_ids);
1706 $base_dn = $this->groups_base_dn;
1707 $group_name = $group_cache[$group_id]['name'];
1708 $member_attr = $group_cache[$group_id]['member_attr'];
1709 $group_dn = "cn=$group_name,$base_dn";
1711 $new_attrs = array();
1712 foreach ($contact_ids as $id)
1713 $new_attrs[$member_attr][] = self::dn_decode($id);
1715 $this->_debug("C: Add [dn: $group_dn]: ".print_r($new_attrs, true));
1717 $res = ldap_mod_add($this->conn, $group_dn, $new_attrs);
1720 $this->_debug("S: ".ldap_error($this->conn));
1721 $this->set_error(self::ERROR_SAVING, 'errorsaving');
1725 $this->_debug("S: OK");
1726 $this->cache->remove('groups');
1728 return count($new_attrs['member']);
1732 * Remove the given contact records from a certain group
1734 * @param string Group identifier
1735 * @param array List of contact identifiers to be removed
1736 * @return int Number of deleted group members
1738 function remove_from_group($group_id, $contact_ids)
1740 if (($group_cache = $this->cache->get('groups')) === null)
1741 $group_cache = $this->_fetch_groups();
1743 $base_dn = $this->groups_base_dn;
1744 $group_name = $group_cache[$group_id]['name'];
1745 $member_attr = $group_cache[$group_id]['member_attr'];
1746 $group_dn = "cn=$group_name,$base_dn";
1748 $del_attrs = array();
1749 foreach (explode(",", $contact_ids) as $id)
1750 $del_attrs[$member_attr][] = self::dn_decode($id);
1752 $this->_debug("C: Delete [dn: $group_dn]: ".print_r($del_attrs, true));
1754 $res = ldap_mod_del($this->conn, $group_dn, $del_attrs);
1757 $this->_debug("S: ".ldap_error($this->conn));
1758 $this->set_error(self::ERROR_SAVING, 'errorsaving');
1762 $this->_debug("S: OK");
1763 $this->cache->remove('groups');
1765 return count($del_attrs['member']);
1769 * Get group assignments of a specific contact record
1771 * @param mixed Record identifier
1773 * @return array List of assigned groups as ID=>Name pairs
1776 function get_record_groups($contact_id)
1781 $base_dn = $this->groups_base_dn;
1782 $contact_dn = self::dn_decode($contact_id);
1783 $name_attr = $this->prop['groups']['name_attr'];
1784 $member_attr = $this->prop['member_attr'];
1786 if ($member_attr != 'member' && $member_attr != 'uniqueMember')
1787 $add_filter = "($member_attr=$contact_dn)";
1788 $filter = strtr("(|(member=$contact_dn)(uniqueMember=$contact_dn)$add_filter)", array('\\' => '\\\\'));
1790 $this->_debug("C: Search [$filter][dn: $base_dn]");
1792 $res = @ldap_search($this->conn, $base_dn, $filter, array($name_attr));
1795 $this->_debug("S: ".ldap_error($this->conn));
1798 $ldap_data = ldap_get_entries($this->conn, $res);
1799 $this->_debug("S: ".ldap_count_entries($this->conn, $res)." record(s)");
1802 for ($i=0; $i<$ldap_data["count"]; $i++)
1804 $group_name = $ldap_data[$i][$name_attr][0];
1805 $group_id = self::dn_encode($group_name);
1806 $groups[$group_id] = $group_id;
1813 * Generate BER encoded string for Virtual List View option
1815 * @param integer List offset (first record)
1816 * @param integer Records per page
1817 * @return string BER encoded option value
1819 private function _vlv_ber_encode($offset, $rpp, $search = '')
1821 # this string is ber-encoded, php will prefix this value with:
1822 # 04 (octet string) and 10 (length of 16 bytes)
1823 # the code behind this string is broken down as follows:
1824 # 30 = ber sequence with a length of 0e (14) bytes following
1825 # 02 = type integer (in two's complement form) with 2 bytes following (beforeCount): 01 00 (ie 0)
1826 # 02 = type integer (in two's complement form) with 2 bytes following (afterCount): 01 18 (ie 25-1=24)
1827 # a0 = type context-specific/constructed with a length of 06 (6) bytes following
1828 # 02 = type integer with 2 bytes following (offset): 01 01 (ie 1)
1829 # 02 = type integer with 2 bytes following (contentCount): 01 00
1831 # whith a search string present:
1832 # 81 = type context-specific/constructed with a length of 04 (4) bytes following (the length will change here)
1833 # 81 indicates a user string is present where as a a0 indicates just a offset search
1834 # 81 = type context-specific/constructed with a length of 06 (6) bytes following
1836 # the following info was taken from the ISO/IEC 8825-1:2003 x.690 standard re: the
1837 # encoding of integer values (note: these values are in
1838 # two-complement form so since offset will never be negative bit 8 of the
1839 # leftmost octet should never by set to 1):
1840 # 8.3.2: If the contents octets of an integer value encoding consist
1841 # of more than one octet, then the bits of the first octet (rightmost) and bit 8
1842 # of the second (to the left of first octet) octet:
1843 # a) shall not all be ones; and
1844 # b) shall not all be zero
1848 $search = preg_replace('/[^-[:alpha:] ,.()0-9]+/', '', $search);
1849 $ber_val = self::_string2hex($search);
1850 $str = self::_ber_addseq($ber_val, '81');
1854 # construct the string from right to left
1855 $str = "020100"; # contentCount
1857 $ber_val = self::_ber_encode_int($offset); // returns encoded integer value in hex format
1859 // calculate octet length of $ber_val
1860 $str = self::_ber_addseq($ber_val, '02') . $str;
1862 // now compute length over $str
1863 $str = self::_ber_addseq($str, 'a0');
1866 // now tack on records per page
1867 $str = "020100" . self::_ber_addseq(self::_ber_encode_int($rpp-1), '02') . $str;
1869 // now tack on sequence identifier and length
1870 $str = self::_ber_addseq($str, '30');
1872 return pack('H'.strlen($str), $str);
1877 * create ber encoding for sort control
1879 * @param array List of cols to sort by
1880 * @return string BER encoded option value
1882 private function _sort_ber_encode($sortcols)
1885 foreach (array_reverse((array)$sortcols) as $col) {
1886 $ber_val = self::_string2hex($col);
1888 # 30 = ber sequence with a length of octet value
1889 # 04 = octet string with a length of the ascii value
1890 $oct = self::_ber_addseq($ber_val, '04');
1891 $str = self::_ber_addseq($oct, '30') . $str;
1894 // now tack on sequence identifier and length
1895 $str = self::_ber_addseq($str, '30');
1897 return pack('H'.strlen($str), $str);
1901 * Add BER sequence with correct length and the given identifier
1903 private static function _ber_addseq($str, $identifier)
1905 $len = dechex(strlen($str)/2);
1906 if (strlen($len) % 2 != 0)
1909 return $identifier . $len . $str;
1913 * Returns BER encoded integer value in hex format
1915 private static function _ber_encode_int($offset)
1917 $val = dechex($offset);
1920 // check if bit 8 of high byte is 1
1921 if (preg_match('/^[89abcdef]/', $val))
1924 if (strlen($val)%2 != 0)
1927 return $prefix . $val;
1931 * Returns ascii string encoded in hex
1933 private static function _string2hex($str)
1936 for ($i=0; $i < strlen($str); $i++)
1937 $hex .= dechex(ord($str[$i]));
1942 * HTML-safe DN string encoding
1944 * @param string $str DN string
1946 * @return string Encoded HTML identifier string
1948 static function dn_encode($str)
1950 // @TODO: to make output string shorter we could probably
1951 // remove dc=* items from it
1952 return rtrim(strtr(base64_encode($str), '+/', '-_'), '=');
1956 * Decodes DN string encoded with _dn_encode()
1958 * @param string $str Encoded HTML identifier string
1960 * @return string DN string
1962 static function dn_decode($str)
1964 $str = str_pad(strtr($str, '-_', '+/'), strlen($str) % 4, '=', STR_PAD_RIGHT);
1965 return base64_decode($str);