+ /**
+ * Converts LDAP entry into an array
+ */
+ private function _ldap2result($rec)
+ {
+ $out = array();
+
+ if ($rec['dn'])
+ $out[$this->primary_key] = self::dn_encode($rec['dn']);
+
+ foreach ($this->fieldmap as $rf => $lf)
+ {
+ for ($i=0; $i < $rec[$lf]['count']; $i++) {
+ if (!($value = $rec[$lf][$i]))
+ continue;
+
+ $out['_raw_attrib'][$lf][$i] = $value;
+
+ if ($rf == 'email' && $this->mail_domain && !strpos($value, '@'))
+ $out[$rf][] = sprintf('%s@%s', $value, $this->mail_domain);
+ else if (in_array($rf, array('street','zipcode','locality','country','region')))
+ $out['address'][$i][$rf] = $value;
+ else if ($rec[$lf]['count'] > 1)
+ $out[$rf][] = $value;
+ else
+ $out[$rf] = $value;
+ }
+
+ // Make sure name fields aren't arrays (#1488108)
+ if (is_array($out[$rf]) && in_array($rf, array('name', 'surname', 'firstname', 'middlename', 'nickname'))) {
+ $out[$rf] = $out['_raw_attrib'][$lf] = $out[$rf][0];
+ }
+ }
+
+ return $out;
+ }
+
+
+ /**
+ * Return real field name (from fields map)
+ */
+ private function _map_field($field)
+ {
+ return $this->fieldmap[$field];
+ }
+
+
+ /**
+ * Convert a record data set into LDAP field attributes
+ */
+ private function _map_data($save_cols)
+ {
+ // flatten composite fields first
+ foreach ($this->coltypes as $col => $colprop) {
+ if (is_array($colprop['childs']) && ($values = $this->get_col_values($col, $save_cols, false))) {
+ foreach ($values as $subtype => $childs) {
+ $subtype = $subtype ? ':'.$subtype : '';
+ foreach ($childs as $i => $child_values) {
+ foreach ((array)$child_values as $childcol => $value) {
+ $save_cols[$childcol.$subtype][$i] = $value;
+ }
+ }
+ }
+ }
+ }
+
+ $ldap_data = array();
+ foreach ($this->fieldmap as $col => $fld) {
+ $val = $save_cols[$col];
+ if (is_array($val))
+ $val = array_filter($val); // remove empty entries
+ if ($fld && $val) {
+ // The field does exist, add it to the entry.
+ $ldap_data[$fld] = $val;
+ }
+ }
+
+ return $ldap_data;
+ }
+
+
+ /**
+ * Returns unified attribute name (resolving aliases)
+ */
+ private static function _attr_name($name)
+ {
+ // list of known attribute aliases
+ $aliases = array(
+ 'gn' => 'givenname',
+ 'rfc822mailbox' => 'email',
+ 'userid' => 'uid',
+ 'emailaddress' => 'email',
+ 'pkcs9email' => 'email',
+ );
+ return isset($aliases[$name]) ? $aliases[$name] : $name;
+ }
+
+
+ /**
+ * Prints debug info to the log
+ */
+ private function _debug($str)
+ {
+ if ($this->debug)
+ write_log('ldap', $str);
+ }
+
+
+ /**
+ * Activate/deactivate debug mode
+ *
+ * @param boolean $dbg True if LDAP commands should be logged
+ * @access public
+ */
+ function set_debug($dbg = true)
+ {
+ $this->debug = $dbg;
+ }
+
+
+ /**
+ * Quotes attribute value string
+ *
+ * @param string $str Attribute value
+ * @param bool $dn True if the attribute is a DN
+ *
+ * @return string Quoted string
+ */
+ private static function _quote_string($str, $dn=false)
+ {
+ // take firt entry if array given
+ if (is_array($str))
+ $str = reset($str);
+
+ if ($dn)
+ $replace = array(','=>'\2c', '='=>'\3d', '+'=>'\2b', '<'=>'\3c',
+ '>'=>'\3e', ';'=>'\3b', '\\'=>'\5c', '"'=>'\22', '#'=>'\23');
+ else
+ $replace = array('*'=>'\2a', '('=>'\28', ')'=>'\29', '\\'=>'\5c',
+ '/'=>'\2f');
+
+ return strtr($str, $replace);
+ }
+
+
+ /**
+ * Setter for the current group
+ * (empty, has to be re-implemented by extending class)
+ */
+ function set_group($group_id)
+ {
+ if ($group_id)
+ {
+ if (($group_cache = $this->cache->get('groups')) === null)
+ $group_cache = $this->_fetch_groups();
+
+ $this->group_id = $group_id;
+ $this->group_data = $group_cache[$group_id];
+ }
+ else
+ {
+ $this->group_id = 0;
+ $this->group_data = null;
+ }
+ }
+
+ /**
+ * List all active contact groups of this source
+ *
+ * @param string Optional search string to match group name
+ * @return array Indexed list of contact groups, each a hash array
+ */
+ function list_groups($search = null)
+ {
+ if (!$this->groups)
+ return array();
+
+ // use cached list for searching
+ $this->cache->expunge();
+ if (!$search || ($group_cache = $this->cache->get('groups')) === null)
+ $group_cache = $this->_fetch_groups();
+
+ $groups = array();
+ if ($search) {
+ $search = mb_strtolower($search);
+ foreach ($group_cache as $group) {
+ if (strpos(mb_strtolower($group['name']), $search) !== false)
+ $groups[] = $group;
+ }
+ }
+ else
+ $groups = $group_cache;
+
+ return array_values($groups);
+ }
+
+ /**
+ * Fetch groups from server
+ */
+ private function _fetch_groups($vlv_page = 0)
+ {
+ $base_dn = $this->groups_base_dn;
+ $filter = $this->prop['groups']['filter'];
+ $name_attr = $this->prop['groups']['name_attr'];
+ $email_attr = $this->prop['groups']['email_attr'] ? $this->prop['groups']['email_attr'] : 'mail';
+ $sort_attrs = $this->prop['groups']['sort'] ? (array)$this->prop['groups']['sort'] : array($name_attr);
+ $sort_attr = $sort_attrs[0];
+
+ $this->_debug("C: Search [$filter][dn: $base_dn]");
+
+ // use vlv to list groups
+ if ($this->prop['groups']['vlv']) {
+ $page_size = 200;
+ if (!$this->prop['groups']['sort'])
+ $this->prop['groups']['sort'] = $sort_attrs;
+ $vlv_active = $this->_vlv_set_controls($this->prop['groups'], $vlv_page+1, $page_size);
+ }
+
+ $function = $this->_scope2func($this->prop['groups']['scope'], $ns_function);
+ $res = @$function($this->conn, $base_dn, $filter, array_unique(array('dn', 'objectClass', $name_attr, $email_attr, $sort_attr)));
+ if ($res === false)
+ {
+ $this->_debug("S: ".ldap_error($this->conn));
+ return array();
+ }
+
+ $ldap_data = ldap_get_entries($this->conn, $res);
+ $this->_debug("S: ".ldap_count_entries($this->conn, $res)." record(s)");
+
+ $groups = array();
+ $group_sortnames = array();
+ $group_count = $ldap_data["count"];
+ for ($i=0; $i < $group_count; $i++)
+ {
+ $group_name = is_array($ldap_data[$i][$name_attr]) ? $ldap_data[$i][$name_attr][0] : $ldap_data[$i][$name_attr];
+ $group_id = self::dn_encode($group_name);
+ $groups[$group_id]['ID'] = $group_id;
+ $groups[$group_id]['dn'] = $ldap_data[$i]['dn'];
+ $groups[$group_id]['name'] = $group_name;
+ $groups[$group_id]['member_attr'] = $this->prop['member_attr'];
+
+ // check objectClass attributes of group and act accordingly
+ for ($j=0; $j < $ldap_data[$i]['objectclass']['count']; $j++) {
+ switch (strtolower($ldap_data[$i]['objectclass'][$j])) {
+ case 'groupofnames':
+ case 'kolabgroupofnames':
+ $groups[$group_id]['member_attr'] = 'member';
+ break;
+
+ case 'groupofuniquenames':
+ case 'kolabgroupofuniquenames':
+ $groups[$group_id]['member_attr'] = 'uniqueMember';
+ break;
+ }
+ }
+
+ // list email attributes of a group
+ for ($j=0; $ldap_data[$i][$email_attr] && $j < $ldap_data[$i][$email_attr]['count']; $j++) {
+ if (strpos($ldap_data[$i][$email_attr][$j], '@') > 0)
+ $groups[$group_id]['email'][] = $ldap_data[$i][$email_attr][$j];
+ }
+
+ $group_sortnames[] = mb_strtolower($ldap_data[$i][$sort_attr][0]);
+ }
+
+ // recursive call can exit here
+ if ($vlv_page > 0)
+ return $groups;
+
+ // call recursively until we have fetched all groups
+ while ($vlv_active && $group_count == $page_size)
+ {
+ $next_page = $this->_fetch_groups(++$vlv_page);
+ $groups = array_merge($groups, $next_page);
+ $group_count = count($next_page);
+ }
+
+ // when using VLV the list of groups is already sorted
+ if (!$this->prop['groups']['vlv'])
+ array_multisort($group_sortnames, SORT_ASC, SORT_STRING, $groups);
+
+ // cache this
+ $this->cache->set('groups', $groups);
+
+ return $groups;
+ }
+
+ /**
+ * Get group properties such as name and email address(es)
+ *
+ * @param string Group identifier
+ * @return array Group properties as hash array
+ */
+ function get_group($group_id)
+ {
+ if (($group_cache = $this->cache->get('groups')) === null)
+ $group_cache = $this->_fetch_groups();
+
+ $group_data = $group_cache[$group_id];
+ unset($group_data['dn'], $group_data['member_attr']);
+
+ return $group_data;
+ }
+
+ /**
+ * Create a contact group with the given name
+ *
+ * @param string The group name
+ * @return mixed False on error, array with record props in success
+ */
+ function create_group($group_name)
+ {
+ $base_dn = $this->groups_base_dn;
+ $new_dn = "cn=$group_name,$base_dn";
+ $new_gid = self::dn_encode($group_name);
+ $member_attr = $this->prop['groups']['member_attr'];
+ $name_attr = $this->prop['groups']['name_attr'];
+
+ $new_entry = array(
+ 'objectClass' => $this->prop['groups']['object_classes'],
+ $name_attr => $group_name,
+ $member_attr => '',
+ );
+
+ $this->_debug("C: Add [dn: $new_dn]: ".print_r($new_entry, true));
+
+ $res = ldap_add($this->conn, $new_dn, $new_entry);
+ if ($res === false)
+ {
+ $this->_debug("S: ".ldap_error($this->conn));
+ $this->set_error(self::ERROR_SAVING, 'errorsaving');
+ return false;
+ }
+
+ $this->_debug("S: OK");
+ $this->cache->remove('groups');
+
+ return array('id' => $new_gid, 'name' => $group_name);
+ }
+
+ /**
+ * Delete the given group and all linked group members
+ *
+ * @param string Group identifier
+ * @return boolean True on success, false if no data was changed
+ */
+ function delete_group($group_id)
+ {
+ if (($group_cache = $this->cache->get('groups')) === null)
+ $group_cache = $this->_fetch_groups();
+
+ $base_dn = $this->groups_base_dn;
+ $group_name = $group_cache[$group_id]['name'];
+ $del_dn = "cn=$group_name,$base_dn";
+
+ $this->_debug("C: Delete [dn: $del_dn]");
+
+ $res = ldap_delete($this->conn, $del_dn);
+ if ($res === false)
+ {
+ $this->_debug("S: ".ldap_error($this->conn));
+ $this->set_error(self::ERROR_SAVING, 'errorsaving');
+ return false;
+ }
+
+ $this->_debug("S: OK");
+ $this->cache->remove('groups');
+
+ return true;
+ }
+
+ /**
+ * Rename a specific contact group
+ *
+ * @param string Group identifier
+ * @param string New name to set for this group
+ * @param string New group identifier (if changed, otherwise don't set)
+ * @return boolean New name on success, false if no data was changed
+ */
+ function rename_group($group_id, $new_name, &$new_gid)
+ {
+ if (($group_cache = $this->cache->get('groups')) === null)
+ $group_cache = $this->_fetch_groups();
+
+ $base_dn = $this->groups_base_dn;
+ $group_name = $group_cache[$group_id]['name'];
+ $old_dn = "cn=$group_name,$base_dn";
+ $new_rdn = "cn=$new_name";
+ $new_gid = self::dn_encode($new_name);
+
+ $this->_debug("C: Rename [dn: $old_dn] [dn: $new_rdn]");
+
+ $res = ldap_rename($this->conn, $old_dn, $new_rdn, NULL, TRUE);
+ if ($res === false)
+ {
+ $this->_debug("S: ".ldap_error($this->conn));
+ $this->set_error(self::ERROR_SAVING, 'errorsaving');
+ return false;
+ }
+
+ $this->_debug("S: OK");
+ $this->cache->remove('groups');
+
+ return $new_name;
+ }
+
+ /**
+ * Add the given contact records the a certain group
+ *
+ * @param string Group identifier
+ * @param array List of contact identifiers to be added
+ * @return int Number of contacts added
+ */
+ function add_to_group($group_id, $contact_ids)
+ {
+ if (($group_cache = $this->cache->get('groups')) === null)
+ $group_cache = $this->_fetch_groups();
+
+ if (!is_array($contact_ids))
+ $contact_ids = explode(',', $contact_ids);
+
+ $base_dn = $this->groups_base_dn;
+ $group_name = $group_cache[$group_id]['name'];
+ $member_attr = $group_cache[$group_id]['member_attr'];
+ $group_dn = "cn=$group_name,$base_dn";
+
+ $new_attrs = array();
+ foreach ($contact_ids as $id)
+ $new_attrs[$member_attr][] = self::dn_decode($id);
+
+ $this->_debug("C: Add [dn: $group_dn]: ".print_r($new_attrs, true));
+
+ $res = ldap_mod_add($this->conn, $group_dn, $new_attrs);
+ if ($res === false)
+ {
+ $this->_debug("S: ".ldap_error($this->conn));
+ $this->set_error(self::ERROR_SAVING, 'errorsaving');
+ return 0;
+ }
+
+ $this->_debug("S: OK");
+ $this->cache->remove('groups');
+
+ return count($new_attrs['member']);
+ }
+
+ /**
+ * Remove the given contact records from a certain group
+ *
+ * @param string Group identifier
+ * @param array List of contact identifiers to be removed
+ * @return int Number of deleted group members
+ */
+ function remove_from_group($group_id, $contact_ids)
+ {
+ if (($group_cache = $this->cache->get('groups')) === null)
+ $group_cache = $this->_fetch_groups();
+
+ $base_dn = $this->groups_base_dn;
+ $group_name = $group_cache[$group_id]['name'];
+ $member_attr = $group_cache[$group_id]['member_attr'];
+ $group_dn = "cn=$group_name,$base_dn";
+
+ $del_attrs = array();
+ foreach (explode(",", $contact_ids) as $id)
+ $del_attrs[$member_attr][] = self::dn_decode($id);
+
+ $this->_debug("C: Delete [dn: $group_dn]: ".print_r($del_attrs, true));
+
+ $res = ldap_mod_del($this->conn, $group_dn, $del_attrs);
+ if ($res === false)
+ {
+ $this->_debug("S: ".ldap_error($this->conn));
+ $this->set_error(self::ERROR_SAVING, 'errorsaving');
+ return 0;
+ }
+
+ $this->_debug("S: OK");
+ $this->cache->remove('groups');
+
+ return count($del_attrs['member']);
+ }
+
+ /**
+ * Get group assignments of a specific contact record
+ *
+ * @param mixed Record identifier
+ *
+ * @return array List of assigned groups as ID=>Name pairs
+ * @since 0.5-beta
+ */
+ function get_record_groups($contact_id)
+ {
+ if (!$this->groups)
+ return array();
+
+ $base_dn = $this->groups_base_dn;
+ $contact_dn = self::dn_decode($contact_id);
+ $name_attr = $this->prop['groups']['name_attr'];
+ $member_attr = $this->prop['member_attr'];
+ $add_filter = '';
+ if ($member_attr != 'member' && $member_attr != 'uniqueMember')
+ $add_filter = "($member_attr=$contact_dn)";
+ $filter = strtr("(|(member=$contact_dn)(uniqueMember=$contact_dn)$add_filter)", array('\\' => '\\\\'));
+
+ $this->_debug("C: Search [$filter][dn: $base_dn]");
+
+ $res = @ldap_search($this->conn, $base_dn, $filter, array($name_attr));
+ if ($res === false)
+ {
+ $this->_debug("S: ".ldap_error($this->conn));
+ return array();
+ }
+ $ldap_data = ldap_get_entries($this->conn, $res);
+ $this->_debug("S: ".ldap_count_entries($this->conn, $res)." record(s)");
+
+ $groups = array();
+ for ($i=0; $i<$ldap_data["count"]; $i++)
+ {
+ $group_name = $ldap_data[$i][$name_attr][0];
+ $group_id = self::dn_encode($group_name);
+ $groups[$group_id] = $group_id;
+ }
+ return $groups;
+ }
+
+
+ /**
+ * Generate BER encoded string for Virtual List View option
+ *
+ * @param integer List offset (first record)
+ * @param integer Records per page
+ * @return string BER encoded option value
+ */
+ private function _vlv_ber_encode($offset, $rpp, $search = '')
+ {
+ # this string is ber-encoded, php will prefix this value with:
+ # 04 (octet string) and 10 (length of 16 bytes)
+ # the code behind this string is broken down as follows:
+ # 30 = ber sequence with a length of 0e (14) bytes following
+ # 02 = type integer (in two's complement form) with 2 bytes following (beforeCount): 01 00 (ie 0)
+ # 02 = type integer (in two's complement form) with 2 bytes following (afterCount): 01 18 (ie 25-1=24)
+ # a0 = type context-specific/constructed with a length of 06 (6) bytes following
+ # 02 = type integer with 2 bytes following (offset): 01 01 (ie 1)
+ # 02 = type integer with 2 bytes following (contentCount): 01 00
+
+ # whith a search string present:
+ # 81 = type context-specific/constructed with a length of 04 (4) bytes following (the length will change here)
+ # 81 indicates a user string is present where as a a0 indicates just a offset search
+ # 81 = type context-specific/constructed with a length of 06 (6) bytes following
+
+ # the following info was taken from the ISO/IEC 8825-1:2003 x.690 standard re: the
+ # encoding of integer values (note: these values are in
+ # two-complement form so since offset will never be negative bit 8 of the
+ # leftmost octet should never by set to 1):
+ # 8.3.2: If the contents octets of an integer value encoding consist
+ # of more than one octet, then the bits of the first octet (rightmost) and bit 8
+ # of the second (to the left of first octet) octet:
+ # a) shall not all be ones; and
+ # b) shall not all be zero
+
+ if ($search)
+ {
+ $search = preg_replace('/[^-[:alpha:] ,.()0-9]+/', '', $search);
+ $ber_val = self::_string2hex($search);
+ $str = self::_ber_addseq($ber_val, '81');
+ }
+ else
+ {
+ # construct the string from right to left
+ $str = "020100"; # contentCount
+
+ $ber_val = self::_ber_encode_int($offset); // returns encoded integer value in hex format
+
+ // calculate octet length of $ber_val
+ $str = self::_ber_addseq($ber_val, '02') . $str;
+
+ // now compute length over $str
+ $str = self::_ber_addseq($str, 'a0');
+ }
+
+ // now tack on records per page
+ $str = "020100" . self::_ber_addseq(self::_ber_encode_int($rpp-1), '02') . $str;
+
+ // now tack on sequence identifier and length
+ $str = self::_ber_addseq($str, '30');
+
+ return pack('H'.strlen($str), $str);
+ }
+
+
+ /**
+ * create ber encoding for sort control
+ *
+ * @param array List of cols to sort by
+ * @return string BER encoded option value
+ */
+ private function _sort_ber_encode($sortcols)
+ {
+ $str = '';
+ foreach (array_reverse((array)$sortcols) as $col) {
+ $ber_val = self::_string2hex($col);
+
+ # 30 = ber sequence with a length of octet value
+ # 04 = octet string with a length of the ascii value
+ $oct = self::_ber_addseq($ber_val, '04');
+ $str = self::_ber_addseq($oct, '30') . $str;
+ }
+
+ // now tack on sequence identifier and length
+ $str = self::_ber_addseq($str, '30');
+
+ return pack('H'.strlen($str), $str);
+ }
+
+ /**
+ * Add BER sequence with correct length and the given identifier
+ */
+ private static function _ber_addseq($str, $identifier)
+ {
+ $len = dechex(strlen($str)/2);
+ if (strlen($len) % 2 != 0)
+ $len = '0'.$len;
+
+ return $identifier . $len . $str;
+ }
+
+ /**
+ * Returns BER encoded integer value in hex format
+ */
+ private static function _ber_encode_int($offset)
+ {
+ $val = dechex($offset);
+ $prefix = '';
+
+ // check if bit 8 of high byte is 1
+ if (preg_match('/^[89abcdef]/', $val))
+ $prefix = '00';
+
+ if (strlen($val)%2 != 0)
+ $prefix .= '0';
+
+ return $prefix . $val;
+ }
+
+ /**
+ * Returns ascii string encoded in hex
+ */
+ private static function _string2hex($str)
+ {
+ $hex = '';
+ for ($i=0; $i < strlen($str); $i++)
+ $hex .= dechex(ord($str[$i]));
+ return $hex;
+ }
+
+ /**
+ * HTML-safe DN string encoding
+ *
+ * @param string $str DN string
+ *
+ * @return string Encoded HTML identifier string
+ */
+ static function dn_encode($str)
+ {
+ // @TODO: to make output string shorter we could probably
+ // remove dc=* items from it
+ return rtrim(strtr(base64_encode($str), '+/', '-_'), '=');
+ }
+
+ /**
+ * Decodes DN string encoded with _dn_encode()
+ *
+ * @param string $str Encoded HTML identifier string
+ *
+ * @return string DN string
+ */
+ static function dn_decode($str)
+ {
+ $str = str_pad(strtr($str, '-_', '+/'), strlen($str) % 4, '=', STR_PAD_RIGHT);
+ return base64_decode($str);
+ }
+}