alecpl
2012-04-14 1495ac7913095ae8284c3501b7d4e6dd31a484ec
program/include/rcube_ldap.php
@@ -4,14 +4,20 @@
 | program/include/rcube_ldap.php                                        |
 |                                                                       |
 | This file is part of the Roundcube Webmail client                     |
 | Copyright (C) 2006-2009, Roundcube Dev. - Switzerland                 |
 | Licensed under the GNU GPL                                            |
 | Copyright (C) 2006-2012, The Roundcube Dev Team                       |
 | Copyright (C) 2011, Kolab Systems AG                                  |
 |                                                                       |
 | Licensed under the GNU General Public License version 3 or            |
 | any later version with exceptions for skins & plugins.                |
 | See the README file for a full license statement.                     |
 |                                                                       |
 | PURPOSE:                                                              |
 |   Interface to an LDAP address directory                              |
 |                                                                       |
 +-----------------------------------------------------------------------+
 | Author: Thomas Bruederli <roundcube@gmail.com>                        |
 |         Andreas Dick <andudi (at) gmx (dot) ch>                       |
 |         Aleksander Machniak <machniak@kolabsys.com>                   |
 +-----------------------------------------------------------------------+
 $Id$
@@ -26,709 +32,2231 @@
 */
class rcube_ldap extends rcube_addressbook
{
  var $conn;
  var $prop = array();
  var $fieldmap = array();
  var $filter = '';
  var $result = null;
  var $ldap_result = null;
  var $sort_col = '';
  var $mail_domain = '';
  var $debug = false;
  /** public properties */
  var $primary_key = 'ID';
  var $readonly = true;
  var $list_page = 1;
  var $page_size = 10;
  var $ready = false;
  /**
   * Object constructor
   *
   * @param array    LDAP connection properties
   * @param boolean    Enables debug mode
   * @param string    Current user mail domain name
   * @param integer User-ID
   */
  function __construct($p, $debug=false, $mail_domain=NULL)
  {
    $this->prop = $p;
    /** public properties */
    public $primary_key = 'ID';
    public $groups = false;
    public $readonly = true;
    public $ready = false;
    public $group_id = 0;
    public $coltypes = array();
    foreach ($p as $prop => $value)
      if (preg_match('/^(.+)_field$/', $prop, $matches))
        $this->fieldmap[$matches[1]] = $this->_attr_name(strtolower($value));
    /** private properties */
    protected $conn;
    protected $prop = array();
    protected $fieldmap = array();
    protected $sub_filter;
    protected $filter = '';
    protected $result = null;
    protected $ldap_result = null;
    protected $mail_domain = '';
    protected $debug = false;
    // make sure 'required_fields' is an array
    if (!is_array($this->prop['required_fields']))
      $this->prop['required_fields'] = (array) $this->prop['required_fields'];
    private $base_dn = '';
    private $groups_base_dn = '';
    private $group_url = null;
    private $cache;
    foreach ($this->prop['required_fields'] as $key => $val)
      $this->prop['required_fields'][$key] = $this->_attr_name(strtolower($val));
    $this->sort_col = $p['sort'];
    $this->debug = $debug;
    $this->mail_domain = $mail_domain;
    $this->connect();
  }
    private $vlv_active = false;
    private $vlv_count = 0;
  /**
   * Establish a connection to the LDAP server
   */
  function connect()
  {
    global $RCMAIL;
    if (!function_exists('ldap_connect'))
      raise_error(array('code' => 100, 'type' => 'ldap',
        'file' => __FILE__, 'line' => __LINE__,
        'message' => "No ldap support in this installation of PHP"), true);
    if (is_resource($this->conn))
      return true;
    if (!is_array($this->prop['hosts']))
      $this->prop['hosts'] = array($this->prop['hosts']);
    if (empty($this->prop['ldap_version']))
      $this->prop['ldap_version'] = 3;
    foreach ($this->prop['hosts'] as $host)
    /**
    * Object constructor
    *
    * @param array     $p            LDAP connection properties
    * @param boolean $debug        Enables debug mode
    * @param string  $mail_domain  Current user mail domain name
    */
    function __construct($p, $debug = false, $mail_domain = null)
    {
      $host = idn_to_ascii(rcube_parse_host($host));
      $this->_debug("C: Connect [$host".($this->prop['port'] ? ':'.$this->prop['port'] : '')."]");
        $this->prop = $p;
      if ($lc = @ldap_connect($host, $this->prop['port']))
      {
        if ($this->prop['use_tls']===true)
          if (!ldap_start_tls($lc))
            continue;
        if (isset($p['searchonly']))
            $this->searchonly = $p['searchonly'];
        $this->_debug("S: OK");
        ldap_set_option($lc, LDAP_OPT_PROTOCOL_VERSION, $this->prop['ldap_version']);
        $this->prop['host'] = $host;
        $this->conn = $lc;
        break;
      }
      $this->_debug("S: NOT OK");
    }
    if (is_resource($this->conn))
    {
      $this->ready = true;
      // User specific access, generate the proper values to use.
      if ($this->prop['user_specific']) {
        // No password set, use the session password
        if (empty($this->prop['bind_pass'])) {
          $this->prop['bind_pass'] = $RCMAIL->decrypt($_SESSION['password']);
        // check if groups are configured
        if (is_array($p['groups']) && count($p['groups'])) {
            $this->groups = true;
            // set member field
            if (!empty($p['groups']['member_attr']))
                $this->prop['member_attr'] = strtolower($p['groups']['member_attr']);
            else if (empty($p['member_attr']))
                $this->prop['member_attr'] = 'member';
            // set default name attribute to cn
            if (empty($this->prop['groups']['name_attr']))
                $this->prop['groups']['name_attr'] = 'cn';
            if (empty($this->prop['groups']['scope']))
                $this->prop['groups']['scope'] = 'sub';
        }
        // Get the pieces needed for variable replacement.
        $fu = $RCMAIL->user->get_username();
        list($u, $d) = explode('@', $fu);
        $dc = 'dc='.strtr($d, array('.' => ',dc=')); // hierarchal domain string
        // fieldmap property is given
        if (is_array($p['fieldmap'])) {
            foreach ($p['fieldmap'] as $rf => $lf)
                $this->fieldmap[$rf] = $this->_attr_name(strtolower($lf));
        }
        else if (!empty($p)) {
            // read deprecated *_field properties to remain backwards compatible
            foreach ($p as $prop => $value)
                if (preg_match('/^(.+)_field$/', $prop, $matches))
                    $this->fieldmap[$matches[1]] = $this->_attr_name(strtolower($value));
        }
        // Replace the bind_dn and base_dn variables.
        $replaces = array('%dc' => $dc, '%d' => $d, '%fu' => $fu, '%u' => $u);
        $this->prop['bind_dn'] = strtr($this->prop['bind_dn'], $replaces);
        $this->prop['base_dn'] = strtr($this->prop['base_dn'], $replaces);
      }
        // use fieldmap to advertise supported coltypes to the application
        foreach ($this->fieldmap as $col => $lf) {
            list($col, $type) = explode(':', $col);
            if (!is_array($this->coltypes[$col])) {
                $subtypes = $type ? array($type) : null;
                $this->coltypes[$col] = array('limit' => 1, 'subtypes' => $subtypes);
            }
            elseif ($type) {
                $this->coltypes[$col]['subtypes'][] = $type;
                $this->coltypes[$col]['limit']++;
            }
            if ($type && !$this->fieldmap[$col])
                $this->fieldmap[$col] = $lf;
        }
      if (!empty($this->prop['bind_dn']) && !empty($this->prop['bind_pass']))
        $this->ready = $this->bind($this->prop['bind_dn'], $this->prop['bind_pass']);
    }
    else
      raise_error(array('code' => 100, 'type' => 'ldap',
        'file' => __FILE__, 'line' => __LINE__,
        'message' => "Could not connect to any LDAP server, last tried $host:{$this->prop[port]}"), true);
        // support for composite address
        if ($this->fieldmap['street'] && $this->fieldmap['locality']) {
            $this->coltypes['address'] = array('limit' => max(1, $this->coltypes['locality']['limit']), 'subtypes' => $this->coltypes['locality']['subtypes'], 'childs' => array());
            foreach (array('street','locality','zipcode','region','country') as $childcol) {
                if ($this->fieldmap[$childcol]) {
                    $this->coltypes['address']['childs'][$childcol] = array('type' => 'text');
                    unset($this->coltypes[$childcol]);  // remove address child col from global coltypes list
                }
            }
        }
        else if ($this->coltypes['address']) {
            $this->coltypes['address'] = array('type' => 'textarea', 'childs' => null, 'limit' => 1, 'size' => 40);
        }
    // See if the directory is writeable.
    if ($this->prop['writable']) {
      $this->readonly = false;
    } // end if
        // make sure 'required_fields' is an array
        if (!is_array($this->prop['required_fields'])) {
            $this->prop['required_fields'] = (array) $this->prop['required_fields'];
        }
  }
        // make sure LDAP_rdn field is required
        if (!empty($this->prop['LDAP_rdn']) && !in_array($this->prop['LDAP_rdn'], $this->prop['required_fields'])) {
            $this->prop['required_fields'][] = $this->prop['LDAP_rdn'];
        }
        foreach ($this->prop['required_fields'] as $key => $val) {
            $this->prop['required_fields'][$key] = $this->_attr_name(strtolower($val));
        }
  /**
   * Bind connection with DN and password
   *
   * @param string Bind DN
   * @param string Bind password
   * @return boolean True on success, False on error
   */
  function bind($dn, $pass)
  {
    if (!$this->conn) {
      return false;
    }
    $this->_debug("C: Bind [dn: $dn] [pass: $pass]");
    if (@ldap_bind($this->conn, $dn, $pass)) {
      $this->_debug("S: OK");
      return true;
        // Build sub_fields filter
        if (!empty($this->prop['sub_fields']) && is_array($this->prop['sub_fields'])) {
            $this->sub_filter = '';
            foreach ($this->prop['sub_fields'] as $attr => $class) {
                if (!empty($class)) {
                    $class = is_array($class) ? array_pop($class) : $class;
                    $this->sub_filter .= '(objectClass=' . $class . ')';
                }
            }
            if (count($this->prop['sub_fields']) > 1) {
                $this->sub_filter = '(|' . $this->sub_filter . ')';
            }
        }
        $this->sort_col    = is_array($p['sort']) ? $p['sort'][0] : $p['sort'];
        $this->debug       = $debug;
        $this->mail_domain = $mail_domain;
        // initialize cache
        $rcmail = rcmail::get_instance();
        $this->cache = $rcmail->get_cache('LDAP.' . asciiwords($this->prop['name']), 'db', 600);
        $this->_connect();
    }
    $this->_debug("S: ".ldap_error($this->conn));
    raise_error(array(
        'code' => ldap_errno($this->conn), 'type' => 'ldap',
   'file' => __FILE__, 'line' => __LINE__,
        'message' => "Bind failed for dn=$dn: ".ldap_error($this->conn)),
        true);
    return false;
  }
  /**
   * Close connection to LDAP server
   */
  function close()
  {
    if ($this->conn)
    /**
    * Establish a connection to the LDAP server
    */
    private function _connect()
    {
      $this->_debug("C: Close");
      ldap_unbind($this->conn);
      $this->conn = null;
    }
  }
        $RCMAIL = rcmail::get_instance();
        if (!function_exists('ldap_connect'))
            rcube::raise_error(array('code' => 100, 'type' => 'ldap',
                'file' => __FILE__, 'line' => __LINE__,
                'message' => "No ldap support in this installation of PHP"),
                true, true);
  /**
   * Set internal list page
   *
   * @param  number  Page number to list
   * @access public
   */
  function set_page($page)
  {
    $this->list_page = (int)$page;
  }
        if (is_resource($this->conn))
            return true;
        if (!is_array($this->prop['hosts']))
            $this->prop['hosts'] = array($this->prop['hosts']);
  /**
   * Set internal page size
   *
   * @param  number  Number of messages to display on one page
   * @access public
   */
  function set_pagesize($size)
  {
    $this->page_size = (int)$size;
  }
        if (empty($this->prop['ldap_version']))
            $this->prop['ldap_version'] = 3;
  /**
   * Save a search string for future listings
   *
   * @param string Filter string
   */
  function set_search_set($filter)
  {
    $this->filter = $filter;
  }
  /**
   * Getter for saved search properties
   *
   * @return mixed Search properties used by this class
   */
  function get_search_set()
  {
    return $this->filter;
  }
  /**
   * Reset all saved results and search parameters
   */
  function reset()
  {
    $this->result = null;
    $this->ldap_result = null;
    $this->filter = '';
  }
  /**
   * List the current set of contact records
   *
   * @param  array  List of cols to show
   * @param  int    Only return this number of records
   * @return array  Indexed list of contact records, each a hash array
   */
  function list_records($cols=null, $subset=0)
  {
    // add general filter to query
    if (!empty($this->prop['filter']) && empty($this->filter))
    {
      $filter = $this->prop['filter'];
      $this->set_search_set($filter);
    }
    // exec LDAP search if no result resource is stored
    if ($this->conn && !$this->ldap_result)
      $this->_exec_search();
    // count contacts for this user
    $this->result = $this->count();
    // we have a search result resource
    if ($this->ldap_result && $this->result->count > 0)
    {
      if ($this->sort_col && $this->prop['scope'] !== 'base')
        ldap_sort($this->conn, $this->ldap_result, $this->sort_col);
      $start_row = $subset < 0 ? $this->result->first + $this->page_size + $subset : $this->result->first;
      $last_row = $this->result->first + $this->page_size;
      $last_row = $subset != 0 ? $start_row + abs($subset) : $last_row;
      $entries = ldap_get_entries($this->conn, $this->ldap_result);
      for ($i = $start_row; $i < min($entries['count'], $last_row); $i++)
        $this->result->add($this->_ldap2result($entries[$i]));
    }
    return $this->result;
  }
  /**
   * Search contacts
   *
   * @param array   List of fields to search in
   * @param string  Search value
   * @param boolean True for strict, False for partial (fuzzy) matching
   * @param boolean True if results are requested, False if count only
   * @param boolean (Not used)
   * @param array   List of fields that cannot be empty
   * @return array  Indexed list of contact records and 'count' value
   */
  function search($fields, $value, $strict=false, $select=true, $nocount=false, $required=array())
  {
    // special treatment for ID-based search
    if ($fields == 'ID' || $fields == $this->primary_key)
    {
      $ids = explode(',', $value);
      $result = new rcube_result_set();
      foreach ($ids as $id)
        if ($rec = $this->get_record($id, true))
        foreach ($this->prop['hosts'] as $host)
        {
          $result->add($rec);
          $result->count++;
            $host     = idn_to_ascii(rcmail::parse_host($host));
            $hostname = $host.($this->prop['port'] ? ':'.$this->prop['port'] : '');
            $this->_debug("C: Connect [$hostname] [{$this->prop['name']}]");
            if ($lc = @ldap_connect($host, $this->prop['port']))
            {
                if ($this->prop['use_tls'] === true)
                    if (!ldap_start_tls($lc))
                        continue;
                $this->_debug("S: OK");
                ldap_set_option($lc, LDAP_OPT_PROTOCOL_VERSION, $this->prop['ldap_version']);
                $this->prop['host'] = $host;
                $this->conn = $lc;
                if (isset($this->prop['referrals']))
                    ldap_set_option($lc, LDAP_OPT_REFERRALS, $this->prop['referrals']);
                break;
            }
            $this->_debug("S: NOT OK");
        }
      return $result;
    }
    $filter = '(|';
    $wc = !$strict && $this->prop['fuzzy_search'] ? '*' : '';
    if (is_array($this->prop['search_fields']))
    {
      foreach ($this->prop['search_fields'] as $k => $field)
        $filter .= "($field=$wc" . rcube_ldap::quote_string($value) . "$wc)";
    }
    else
    {
      foreach ((array)$fields as $field)
        if ($f = $this->_map_field($field))
          $filter .= "($f=$wc" . rcube_ldap::quote_string($value) . "$wc)";
    }
    $filter .= ')';
    // add required (non empty) fields filter
    $req_filter = '';
    foreach ((array)$required as $field)
      if ($f = $this->_map_field($field))
        $req_filter .= "($f=*)";
    if (!empty($req_filter))
      $filter = '(&' . $req_filter . $filter . ')';
    // avoid double-wildcard if $value is empty
    $filter = preg_replace('/\*+/', '*', $filter);
    // add general filter to query
    if (!empty($this->prop['filter']))
      $filter = '(&(' . preg_replace('/^\(|\)$/', '', $this->prop['filter']) . ')' . $filter . ')';
    // set filter string and execute search
    $this->set_search_set($filter);
    $this->_exec_search();
    if ($select)
      $this->list_records();
    else
      $this->result = $this->count();
    return $this->result;
  }
  /**
   * Count number of available contacts in database
   *
   * @return object rcube_result_set Resultset with values for 'count' and 'first'
   */
  function count()
  {
    $count = 0;
    if ($this->conn && $this->ldap_result) {
      $count = ldap_count_entries($this->conn, $this->ldap_result);
    } // end if
    elseif ($this->conn) {
      // We have a connection but no result set, attempt to get one.
      if (empty($this->filter)) {
        // The filter is not set, set it.
        $this->filter = $this->prop['filter'];
      } // end if
      $this->_exec_search();
      if ($this->ldap_result) {
        $count = ldap_count_entries($this->conn, $this->ldap_result);
      } // end if
    } // end else
    return new rcube_result_set($count, ($this->list_page-1) * $this->page_size);
  }
  /**
   * Return the last result set
   *
   * @return object rcube_result_set Current resultset or NULL if nothing selected yet
   */
  function get_result()
  {
    return $this->result;
  }
  /**
   * Get a specific contact record
   *
   * @param mixed   Record identifier
   * @param boolean Return as associative array
   * @return mixed  Hash array or rcube_result_set with all record fields
   */
  function get_record($dn, $assoc=false)
  {
    $res = null;
    if ($this->conn && $dn)
    {
      $dn = base64_decode($dn);
      $this->_debug("C: Read [dn: $dn] [(objectclass=*)]");
      if ($this->ldap_result = @ldap_read($this->conn, $dn, '(objectclass=*)', array_values($this->fieldmap)))
        $entry = ldap_first_entry($this->conn, $this->ldap_result);
      else
        $this->_debug("S: ".ldap_error($this->conn));
      if ($entry && ($rec = ldap_get_attributes($this->conn, $entry)))
      {
        $this->_debug("S: OK");
        $rec = array_change_key_case($rec, CASE_LOWER);
        // Add in the dn for the entry.
        $rec['dn'] = $dn;
        $res = $this->_ldap2result($rec);
        $this->result = new rcube_result_set(1);
        $this->result->add($res);
      }
    }
    return $assoc ? $res : $this->result;
  }
  /**
   * Create a new contact record
   *
   * @param array    Hash array with save data
   * @return encoded record ID on success, False on error
   */
  function insert($save_cols)
  {
    // Map out the column names to their LDAP ones to build the new entry.
    $newentry = array();
    $newentry['objectClass'] = $this->prop['LDAP_Object_Classes'];
    foreach ($save_cols as $col => $val) {
      $fld = $this->_map_field($col);
      if ($fld && $val) {
        // The field does exist, add it to the entry.
        $newentry[$fld] = $val;
      } // end if
    } // end foreach
    // Verify that the required fields are set.
    // We know that the email address is required as a default of rcube, so
    // we will default its value into any unfilled required fields.
    foreach ($this->prop['required_fields'] as $fld) {
      if (!isset($newentry[$fld])) {
        $newentry[$fld] = $newentry[$this->_map_field('email')];
      } // end if
    } // end foreach
    // Build the new entries DN.
    $dn = $this->prop['LDAP_rdn'].'='.rcube_ldap::quote_string($newentry[$this->prop['LDAP_rdn']], true)
      .','.$this->prop['base_dn'];
    $this->_debug("C: Add [dn: $dn]: ".print_r($newentry, true));
    $res = ldap_add($this->conn, $dn, $newentry);
    if ($res === FALSE) {
      $this->_debug("S: ".ldap_error($this->conn));
      return false;
    } // end if
    $this->_debug("S: OK");
    return base64_encode($dn);
  }
  /**
   * Update a specific contact record
   *
   * @param mixed Record identifier
   * @param array Hash array with save data
   * @return boolean True on success, False on error
   */
  function update($id, $save_cols)
  {
    $record = $this->get_record($id, true);
    $result = $this->get_result();
    $record = $result->first();
    $newdata = array();
    $replacedata = array();
    $deletedata = array();
    foreach ($save_cols as $col => $val) {
      $fld = $this->_map_field($col);
      if ($fld) {
        // The field does exist compare it to the ldap record.
        if ($record[$col] != $val) {
          // Changed, but find out how.
          if (!isset($record[$col])) {
            // Field was not set prior, need to add it.
            $newdata[$fld] = $val;
          } // end if
          elseif ($val == '') {
            // Field supplied is empty, verify that it is not required.
            if (!in_array($fld, $this->prop['required_fields'])) {
              // It is not, safe to clear.
              $deletedata[$fld] = $record[$col];
            } // end if
          } // end elseif
          else {
            // The data was modified, save it out.
            $replacedata[$fld] = $val;
          } // end else
        } // end if
      } // end if
    } // end foreach
    $dn = base64_decode($id);
    // Update the entry as required.
    if (!empty($deletedata)) {
      // Delete the fields.
      $this->_debug("C: Delete [dn: $dn]: ".print_r($deletedata, true));
      if (!ldap_mod_del($this->conn, $dn, $deletedata)) {
        $this->_debug("S: ".ldap_error($this->conn));
        return false;
      }
      $this->_debug("S: OK");
    } // end if
    if (!empty($replacedata)) {
      // Handle RDN change
      if ($replacedata[$this->prop['LDAP_rdn']]) {
        $newdn = $this->prop['LDAP_rdn'].'='
     .rcube_ldap::quote_string($replacedata[$this->prop['LDAP_rdn']], true)
     .','.$this->prop['base_dn'];
        if ($dn != $newdn) {
          $newrdn = $this->prop['LDAP_rdn'].'='
       .rcube_ldap::quote_string($replacedata[$this->prop['LDAP_rdn']], true);
          unset($replacedata[$this->prop['LDAP_rdn']]);
        // See if the directory is writeable.
        if ($this->prop['writable']) {
            $this->readonly = false;
        }
      }
      // Replace the fields.
      if (!empty($replacedata)) {
        $this->_debug("C: Replace [dn: $dn]: ".print_r($replacedata, true));
        if (!ldap_mod_replace($this->conn, $dn, $replacedata)) {
          $this->_debug("S: ".ldap_error($this->conn));
          return false;
   }
        $this->_debug("S: OK");
      } // end if
    } // end if
    if (!empty($newdata)) {
      // Add the fields.
      $this->_debug("C: Add [dn: $dn]: ".print_r($newdata, true));
      if (!ldap_mod_add($this->conn, $dn, $newdata)) {
        $this->_debug("S: ".ldap_error($this->conn));
        return false;
      }
      $this->_debug("S: OK");
    } // end if
        if (!is_resource($this->conn)) {
            rcube::raise_error(array('code' => 100, 'type' => 'ldap',
                'file' => __FILE__, 'line' => __LINE__,
                'message' => "Could not connect to any LDAP server, last tried $hostname"), true);
    // Handle RDN change
    if (!empty($newrdn)) {
      $this->_debug("C: Rename [dn: $dn] [dn: $newrdn]");
      if (@ldap_rename($this->conn, $dn, $newrdn, NULL, TRUE)) {
        $this->_debug("S: ".ldap_error($this->conn));
        return base64_encode($newdn);
      }
      $this->_debug("S: OK");
            return false;
        }
        $bind_pass = $this->prop['bind_pass'];
        $bind_user = $this->prop['bind_user'];
        $bind_dn   = $this->prop['bind_dn'];
        $this->base_dn        = $this->prop['base_dn'];
        $this->groups_base_dn = ($this->prop['groups']['base_dn']) ?
        $this->prop['groups']['base_dn'] : $this->base_dn;
        // User specific access, generate the proper values to use.
        if ($this->prop['user_specific']) {
            // No password set, use the session password
            if (empty($bind_pass)) {
                $bind_pass = $RCMAIL->decrypt($_SESSION['password']);
            }
            // Get the pieces needed for variable replacement.
            if ($fu = $RCMAIL->get_user_name())
                list($u, $d) = explode('@', $fu);
            else
                $d = $this->mail_domain;
            $dc = 'dc='.strtr($d, array('.' => ',dc=')); // hierarchal domain string
            $replaces = array('%dn' => '', '%dc' => $dc, '%d' => $d, '%fu' => $fu, '%u' => $u);
            if ($this->prop['search_base_dn'] && $this->prop['search_filter']) {
                if (!empty($this->prop['search_bind_dn']) && !empty($this->prop['search_bind_pw'])) {
                    $this->bind($this->prop['search_bind_dn'], $this->prop['search_bind_pw']);
                }
                // Search for the dn to use to authenticate
                $this->prop['search_base_dn'] = strtr($this->prop['search_base_dn'], $replaces);
                $this->prop['search_filter'] = strtr($this->prop['search_filter'], $replaces);
                $this->_debug("S: searching with base {$this->prop['search_base_dn']} for {$this->prop['search_filter']}");
                $res = @ldap_search($this->conn, $this->prop['search_base_dn'], $this->prop['search_filter'], array('uid'));
                if ($res) {
                    if (($entry = ldap_first_entry($this->conn, $res))
                        && ($bind_dn = ldap_get_dn($this->conn, $entry))
                    ) {
                        $this->_debug("S: search returned dn: $bind_dn");
                        $dn = ldap_explode_dn($bind_dn, 1);
                        $replaces['%dn'] = $dn[0];
                    }
                }
                else {
                    $this->_debug("S: ".ldap_error($this->conn));
                }
                // DN not found
                if (empty($replaces['%dn'])) {
                    if (!empty($this->prop['search_dn_default']))
                        $replaces['%dn'] = $this->prop['search_dn_default'];
                    else {
                        rcube::raise_error(array(
                            'code' => 100, 'type' => 'ldap',
                            'file' => __FILE__, 'line' => __LINE__,
                            'message' => "DN not found using LDAP search."), true);
                        return false;
                    }
                }
            }
            // Replace the bind_dn and base_dn variables.
            $bind_dn              = strtr($bind_dn, $replaces);
            $this->base_dn        = strtr($this->base_dn, $replaces);
            $this->groups_base_dn = strtr($this->groups_base_dn, $replaces);
            if (empty($bind_user)) {
                $bind_user = $u;
            }
        }
        if (empty($bind_pass)) {
            $this->ready = true;
        }
        else {
            if (!empty($bind_dn)) {
                $this->ready = $this->bind($bind_dn, $bind_pass);
            }
            else if (!empty($this->prop['auth_cid'])) {
                $this->ready = $this->sasl_bind($this->prop['auth_cid'], $bind_pass, $bind_user);
            }
            else {
                $this->ready = $this->sasl_bind($bind_user, $bind_pass);
            }
        }
        return $this->ready;
    }
    return true;
  }
  /**
   * Mark one or more contact records as deleted
   *
   * @param array  Record identifiers
   * @return boolean True on success, False on error
   */
  function delete($ids)
  {
    if (!is_array($ids)) {
      // Not an array, break apart the encoded DNs.
      $dns = explode(',', $ids);
    } // end if
    foreach ($dns as $id) {
      $dn = base64_decode($id);
      $this->_debug("C: Delete [dn: $dn]");
      // Delete the record.
      $res = ldap_delete($this->conn, $dn);
      if ($res === FALSE) {
    /**
     * Bind connection with (SASL-) user and password
     *
     * @param string $authc Authentication user
     * @param string $pass  Bind password
     * @param string $authz Autorization user
     *
     * @return boolean True on success, False on error
     */
    public function sasl_bind($authc, $pass, $authz=null)
    {
        if (!$this->conn) {
            return false;
        }
        if (!function_exists('ldap_sasl_bind')) {
            rcube::raise_error(array('code' => 100, 'type' => 'ldap',
                'file' => __FILE__, 'line' => __LINE__,
                'message' => "Unable to bind: ldap_sasl_bind() not exists"),
                true, true);
        }
        if (!empty($authz)) {
            $authz = 'u:' . $authz;
        }
        if (!empty($this->prop['auth_method'])) {
            $method = $this->prop['auth_method'];
        }
        else {
            $method = 'DIGEST-MD5';
        }
        $this->_debug("C: Bind [mech: $method, authc: $authc, authz: $authz] [pass: $pass]");
        if (ldap_sasl_bind($this->conn, NULL, $pass, $method, NULL, $authc, $authz)) {
            $this->_debug("S: OK");
            return true;
        }
        $this->_debug("S: ".ldap_error($this->conn));
        rcube::raise_error(array(
            'code' => ldap_errno($this->conn), 'type' => 'ldap',
            'file' => __FILE__, 'line' => __LINE__,
            'message' => "Bind failed for authcid=$authc ".ldap_error($this->conn)),
            true);
        return false;
      } // end if
      $this->_debug("S: OK");
    } // end foreach
    return count($dns);
  }
  /**
   * Execute the LDAP search based on the stored credentials
   *
   * @access private
   */
  private function _exec_search()
  {
    if ($this->ready)
    {
      $filter = $this->filter ? $this->filter : '(objectclass=*)';
      $function = $this->prop['scope'] == 'sub' ? 'ldap_search' : ($this->prop['scope'] == 'base' ? 'ldap_read' : 'ldap_list');
      $this->_debug("C: Search [".$filter."]");
      if ($this->ldap_result = @$function($this->conn, $this->prop['base_dn'], $filter,
          array_values($this->fieldmap), 0, (int) $this->prop['sizelimit'], (int) $this->prop['timelimit'])
      ) {
        $this->_debug("S: ".ldap_count_entries($this->conn, $this->ldap_result)." record(s)");
        return true;
      } else
        $this->_debug("S: ".ldap_error($this->conn));
    }
    return false;
  }
  /**
   * @access private
   */
  private function _ldap2result($rec)
  {
    global $RCMAIL;
    $out = array();
    if ($rec['dn'])
      $out[$this->primary_key] = base64_encode($rec['dn']);
    foreach ($this->fieldmap as $rf => $lf)
    /**
     * Bind connection with DN and password
     *
     * @param string Bind DN
     * @param string Bind password
     *
     * @return boolean True on success, False on error
     */
    public function bind($dn, $pass)
    {
      if ($rec[$lf]['count']) {
        if ($rf == 'email' && $this->mail_domain && !strpos($rec[$lf][0], '@'))
          $out[$rf] = sprintf('%s@%s', $rec[$lf][0], $this->mail_domain);
        if (!$this->conn) {
            return false;
        }
        $this->_debug("C: Bind [dn: $dn] [pass: $pass]");
        if (@ldap_bind($this->conn, $dn, $pass)) {
            $this->_debug("S: OK");
            return true;
        }
        $this->_debug("S: ".ldap_error($this->conn));
        rcube::raise_error(array(
            'code' => ldap_errno($this->conn), 'type' => 'ldap',
            'file' => __FILE__, 'line' => __LINE__,
            'message' => "Bind failed for dn=$dn: ".ldap_error($this->conn)),
            true);
        return false;
    }
    /**
     * Close connection to LDAP server
     */
    function close()
    {
        if ($this->conn)
        {
            $this->_debug("C: Close");
            ldap_unbind($this->conn);
            $this->conn = null;
        }
    }
    /**
     * Returns address book name
     *
     * @return string Address book name
     */
    function get_name()
    {
        return $this->prop['name'];
    }
    /**
     * Set internal sort settings
     *
     * @param string $sort_col Sort column
     * @param string $sort_order Sort order
     */
    function set_sort_order($sort_col, $sort_order = null)
    {
        if ($this->fieldmap[$sort_col])
            $this->sort_col = $this->fieldmap[$sort_col];
    }
    /**
     * Save a search string for future listings
     *
     * @param string $filter Filter string
     */
    function set_search_set($filter)
    {
        $this->filter = $filter;
    }
    /**
     * Getter for saved search properties
     *
     * @return mixed Search properties used by this class
     */
    function get_search_set()
    {
        return $this->filter;
    }
    /**
     * Reset all saved results and search parameters
     */
    function reset()
    {
        $this->result = null;
        $this->ldap_result = null;
        $this->filter = '';
    }
    /**
     * List the current set of contact records
     *
     * @param  array  List of cols to show
     * @param  int    Only return this number of records
     *
     * @return array  Indexed list of contact records, each a hash array
     */
    function list_records($cols=null, $subset=0)
    {
        if ($this->prop['searchonly'] && empty($this->filter) && !$this->group_id)
        {
            $this->result = new rcube_result_set(0);
            $this->result->searchonly = true;
            return $this->result;
        }
        // fetch group members recursively
        if ($this->group_id && $this->group_data['dn'])
        {
            $entries = $this->list_group_members($this->group_data['dn']);
            // make list of entries unique and sort it
            $seen = array();
            foreach ($entries as $i => $rec) {
                if ($seen[$rec['dn']]++)
                    unset($entries[$i]);
            }
            usort($entries, array($this, '_entry_sort_cmp'));
            $entries['count'] = count($entries);
            $this->result = new rcube_result_set($entries['count'], ($this->list_page-1) * $this->page_size);
        }
        else
          $out[$rf] = $rec[$lf][0];
      }
        {
            // add general filter to query
            if (!empty($this->prop['filter']) && empty($this->filter))
                $this->set_search_set($this->prop['filter']);
            // exec LDAP search if no result resource is stored
            if ($this->conn && !$this->ldap_result)
                $this->_exec_search();
            // count contacts for this user
            $this->result = $this->count();
            // we have a search result resource
            if ($this->ldap_result && $this->result->count > 0)
            {
                // sorting still on the ldap server
                if ($this->sort_col && $this->prop['scope'] !== 'base' && !$this->vlv_active)
                    ldap_sort($this->conn, $this->ldap_result, $this->sort_col);
                // get all entries from the ldap server
                $entries = ldap_get_entries($this->conn, $this->ldap_result);
            }
        }  // end else
        // start and end of the page
        $start_row = $this->vlv_active ? 0 : $this->result->first;
        $start_row = $subset < 0 ? $start_row + $this->page_size + $subset : $start_row;
        $last_row = $this->result->first + $this->page_size;
        $last_row = $subset != 0 ? $start_row + abs($subset) : $last_row;
        // filter entries for this page
        for ($i = $start_row; $i < min($entries['count'], $last_row); $i++)
            $this->result->add($this->_ldap2result($entries[$i]));
        return $this->result;
    }
    return $out;
  }
  /**
   * @access private
   */
  private function _map_field($field)
  {
    return $this->fieldmap[$field];
  }
  /**
   * @access private
   */
  private 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;
  }
    /**
     * Get all members of the given group
     *
     * @param string Group DN
     * @param array  Group entries (if called recursively)
     * @return array Accumulated group members
     */
    function list_group_members($dn, $count = false, $entries = null)
    {
        $group_members = array();
        // fetch group object
        if (empty($entries)) {
            $result = @ldap_read($this->conn, $dn, '(objectClass=*)', array('dn','objectClass','member','uniqueMember','memberURL'));
            if ($result === false)
            {
                $this->_debug("S: ".ldap_error($this->conn));
                return $group_members;
            }
            $entries = @ldap_get_entries($this->conn, $result);
        }
        for ($i=0; $i < $entries['count']; $i++)
        {
            $entry = $entries[$i];
            if (empty($entry['objectclass']))
                continue;
            foreach ((array)$entry['objectclass'] as $objectclass)
            {
                switch (strtolower($objectclass)) {
                    case "group":
                    case "groupofnames":
                    case "kolabgroupofnames":
                        $group_members = array_merge($group_members, $this->_list_group_members($dn, $entry, 'member', $count));
                        break;
                    case "groupofuniquenames":
                    case "kolabgroupofuniquenames":
                        $group_members = array_merge($group_members, $this->_list_group_members($dn, $entry, 'uniquemember', $count));
                        break;
                    case "groupofurls":
                        $group_members = array_merge($group_members, $this->_list_group_memberurl($dn, $entry, $count));
                        break;
                }
            }
            if ($this->prop['sizelimit'] && count($group_members) > $this->prop['sizelimit'])
              break;
        }
        return array_filter($group_members);
    }
    /**
     * Fetch members of the given group entry from server
     *
     * @param string Group DN
     * @param array  Group entry
     * @param string Member attribute to use
     * @return array Accumulated group members
     */
    private function _list_group_members($dn, $entry, $attr, $count)
    {
        // Use the member attributes to return an array of member ldap objects
        // NOTE that the member attribute is supposed to contain a DN
        $group_members = array();
        if (empty($entry[$attr]))
            return $group_members;
        // read these attributes for all members
        $attrib = $count ? array('dn') : array_values($this->fieldmap);
        $attrib[] = 'objectClass';
        $attrib[] = 'member';
        $attrib[] = 'uniqueMember';
        $attrib[] = 'memberURL';
        for ($i=0; $i < $entry[$attr]['count']; $i++)
        {
            if (empty($entry[$attr][$i]))
                continue;
            $result = @ldap_read($this->conn, $entry[$attr][$i], '(objectclass=*)',
                $attrib, 0, (int)$this->prop['sizelimit'], (int)$this->prop['timelimit']);
            $members = @ldap_get_entries($this->conn, $result);
            if ($members == false)
            {
                $this->_debug("S: ".ldap_error($this->conn));
                $members = array();
            }
            // for nested groups, call recursively
            $nested_group_members = $this->list_group_members($entry[$attr][$i], $count, $members);
            unset($members['count']);
            $group_members = array_merge($group_members, array_filter($members), $nested_group_members);
        }
        return $group_members;
    }
    /**
     * List members of group class groupOfUrls
     *
     * @param string Group DN
     * @param array  Group entry
     * @param boolean True if only used for counting
     * @return array Accumulated group members
     */
    private function _list_group_memberurl($dn, $entry, $count)
    {
        $group_members = array();
        for ($i=0; $i < $entry['memberurl']['count']; $i++)
        {
            // extract components from url
            if (!preg_match('!ldap:///([^\?]+)\?\?(\w+)\?(.*)$!', $entry['memberurl'][$i], $m))
                continue;
            // add search filter if any
            $filter = $this->filter ? '(&(' . $m[3] . ')(' . $this->filter . '))' : $m[3];
            $func = $m[2] == 'sub' ? 'ldap_search' : ($m[2] == 'base' ? 'ldap_read' : 'ldap_list');
            $attrib = $count ? array('dn') : array_values($this->fieldmap);
            if ($result = @$func($this->conn, $m[1], $filter,
                $attrib, 0, (int)$this->prop['sizelimit'], (int)$this->prop['timelimit'])
            ) {
                $this->_debug("S: ".ldap_count_entries($this->conn, $result)." record(s) for ".$m[1]);
            }
            else {
                $this->_debug("S: ".ldap_error($this->conn));
                return $group_members;
            }
            $entries = @ldap_get_entries($this->conn, $result);
            for ($j = 0; $j < $entries['count']; $j++)
            {
                if ($nested_group_members = $this->list_group_members($entries[$j]['dn'], $count))
                    $group_members = array_merge($group_members, $nested_group_members);
                else
                    $group_members[] = $entries[$j];
            }
        }
        return $group_members;
    }
    /**
     * Callback for sorting entries
     */
    function _entry_sort_cmp($a, $b)
    {
        return strcmp($a[$this->sort_col][0], $b[$this->sort_col][0]);
    }
  /**
   * @access private
   */
  private function _debug($str)
  {
    if ($this->debug)
      write_log('ldap', $str);
  }
    /**
     * Search contacts
     *
     * @param mixed   $fields   The field name of array of field names to search in
     * @param mixed   $value    Search value (or array of values when $fields is array)
     * @param int     $mode     Matching mode:
     *                          0 - partial (*abc*),
     *                          1 - strict (=),
     *                          2 - prefix (abc*)
     * @param boolean $select   True if results are requested, False if count only
     * @param boolean $nocount  (Not used)
     * @param array   $required List of fields that cannot be empty
     *
     * @return array  Indexed list of contact records and 'count' value
     */
    function search($fields, $value, $mode=0, $select=true, $nocount=false, $required=array())
    {
        $mode = intval($mode);
  /**
   * @static
   */
  function quote_string($str, $dn=false)
  {
    if ($dn)
      $replace = array(','=>'\2c', '='=>'\3d', '+'=>'\2b', '<'=>'\3c',
        '>'=>'\3e', ';'=>'\3b', '\\'=>'\5c', '"'=>'\22', '#'=>'\23');
    else
      $replace = array('*'=>'\2a', '('=>'\28', ')'=>'\29', '\\'=>'\5c',
        '/'=>'\2f');
        // special treatment for ID-based search
        if ($fields == 'ID' || $fields == $this->primary_key)
        {
            $ids = !is_array($value) ? explode(',', $value) : $value;
            $result = new rcube_result_set();
            foreach ($ids as $id)
            {
                if ($rec = $this->get_record($id, true))
                {
                    $result->add($rec);
                    $result->count++;
                }
            }
            return $result;
        }
    return strtr($str, $replace);
  }
        // use VLV pseudo-search for autocompletion
        if ($this->prop['vlv_search'] && $this->conn && join(',', (array)$fields) == 'email,name')
        {
            // add general filter to query
            if (!empty($this->prop['filter']) && empty($this->filter))
                $this->set_search_set($this->prop['filter']);
            // set VLV controls with encoded search string
            $this->_vlv_set_controls($this->prop, $this->list_page, $this->page_size, $value);
            $function = $this->_scope2func($this->prop['scope']);
            $this->ldap_result = @$function($this->conn, $this->base_dn, $this->filter ? $this->filter : '(objectclass=*)',
                array_values($this->fieldmap), 0, $this->page_size, (int)$this->prop['timelimit']);
            $this->result = new rcube_result_set(0);
            if (!$this->ldap_result) {
                $this->_debug("S: ".ldap_error($this->conn));
                return $this->result;
            }
            $this->_debug("S: ".ldap_count_entries($this->conn, $this->ldap_result)." record(s)");
            // get all entries of this page and post-filter those that really match the query
            $search = mb_strtolower($value);
            $entries = ldap_get_entries($this->conn, $this->ldap_result);
            for ($i = 0; $i < $entries['count']; $i++) {
                $rec = $this->_ldap2result($entries[$i]);
                foreach (array('email', 'name') as $f) {
                    $val = mb_strtolower($rec[$f]);
                    switch ($mode) {
                    case 1:
                        $got = ($val == $search);
                        break;
                    case 2:
                        $got = ($search == substr($val, 0, strlen($search)));
                        break;
                    default:
                        $got = (strpos($val, $search) !== false);
                        break;
                    }
                    if ($got) {
                        $this->result->add($rec);
                        $this->result->count++;
                        break;
                    }
                }
            }
            return $this->result;
        }
        // use AND operator for advanced searches
        $filter = is_array($value) ? '(&' : '(|';
        // set wildcards
        $wp = $ws = '';
        if (!empty($this->prop['fuzzy_search']) && $mode != 1) {
            $ws = '*';
            if (!$mode) {
                $wp = '*';
            }
        }
        if ($fields == '*')
        {
            // search_fields are required for fulltext search
            if (empty($this->prop['search_fields']))
            {
                $this->set_error(self::ERROR_SEARCH, 'nofulltextsearch');
                $this->result = new rcube_result_set();
                return $this->result;
            }
            if (is_array($this->prop['search_fields']))
            {
                foreach ($this->prop['search_fields'] as $field) {
                    $filter .= "($field=$wp" . $this->_quote_string($value) . "$ws)";
                }
            }
        }
        else
        {
            foreach ((array)$fields as $idx => $field) {
                $val = is_array($value) ? $value[$idx] : $value;
                if ($f = $this->_map_field($field)) {
                    $filter .= "($f=$wp" . $this->_quote_string($val) . "$ws)";
                }
            }
        }
        $filter .= ')';
        // add required (non empty) fields filter
        $req_filter = '';
        foreach ((array)$required as $field)
            if ($f = $this->_map_field($field))
                $req_filter .= "($f=*)";
        if (!empty($req_filter))
            $filter = '(&' . $req_filter . $filter . ')';
        // avoid double-wildcard if $value is empty
        $filter = preg_replace('/\*+/', '*', $filter);
        // add general filter to query
        if (!empty($this->prop['filter']))
            $filter = '(&(' . preg_replace('/^\(|\)$/', '', $this->prop['filter']) . ')' . $filter . ')';
        // set filter string and execute search
        $this->set_search_set($filter);
        $this->_exec_search();
        if ($select)
            $this->list_records();
        else
            $this->result = $this->count();
        return $this->result;
    }
    /**
     * Count number of available contacts in database
     *
     * @return object rcube_result_set Resultset with values for 'count' and 'first'
     */
    function count()
    {
        $count = 0;
        if ($this->conn && $this->ldap_result) {
            $count = $this->vlv_active ? $this->vlv_count : ldap_count_entries($this->conn, $this->ldap_result);
        }
        else if ($this->group_id && $this->group_data['dn']) {
            $count = count($this->list_group_members($this->group_data['dn'], true));
        }
        else if ($this->conn) {
            // We have a connection but no result set, attempt to get one.
            if (empty($this->filter)) {
                // The filter is not set, set it.
                $this->filter = $this->prop['filter'];
            }
            $count = (int) $this->_exec_search(true);
        }
        return new rcube_result_set($count, ($this->list_page-1) * $this->page_size);
    }
    /**
     * Return the last result set
     *
     * @return object rcube_result_set Current resultset or NULL if nothing selected yet
     */
    function get_result()
    {
        return $this->result;
    }
    /**
     * Get a specific contact record
     *
     * @param mixed   Record identifier
     * @param boolean Return as associative array
     *
     * @return mixed  Hash array or rcube_result_set with all record fields
     */
    function get_record($dn, $assoc=false)
    {
        $res = $this->result = null;
        if ($this->conn && $dn)
        {
            $dn = self::dn_decode($dn);
            $this->_debug("C: Read [dn: $dn] [(objectclass=*)]");
            if ($ldap_result = @ldap_read($this->conn, $dn, '(objectclass=*)', array_values($this->fieldmap))) {
                $this->_debug("S: OK");
                $entry = ldap_first_entry($this->conn, $ldap_result);
                if ($entry && ($rec = ldap_get_attributes($this->conn, $entry))) {
                    $rec = array_change_key_case($rec, CASE_LOWER);
                }
            }
            else {
                $this->_debug("S: ".ldap_error($this->conn));
            }
            // Use ldap_list to get subentries like country (c) attribute (#1488123)
            if (!empty($rec) && $this->sub_filter) {
                if ($entries = $this->ldap_list($dn, $this->sub_filter, array_keys($this->prop['sub_fields']))) {
                    foreach ($entries as $entry) {
                        $lrec = array_change_key_case($entry, CASE_LOWER);
                        $rec  = array_merge($lrec, $rec);
                    }
                }
            }
            if (!empty($rec)) {
                // Add in the dn for the entry.
                $rec['dn'] = $dn;
                $res = $this->_ldap2result($rec);
                $this->result = new rcube_result_set();
                $this->result->add($res);
            }
        }
        return $assoc ? $res : $this->result;
    }
    /**
     * Check the given data before saving.
     * If input not valid, the message to display can be fetched using get_error()
     *
     * @param array Assoziative array with data to save
     * @param boolean Try to fix/complete record automatically
     * @return boolean True if input is valid, False if not.
     */
    public function validate(&$save_data, $autofix = false)
    {
        // validate e-mail addresses
        if (!parent::validate($save_data, $autofix)) {
            return false;
        }
        // check for name input
        if (empty($save_data['name'])) {
            $this->set_error(self::ERROR_VALIDATE, 'nonamewarning');
            return false;
        }
        // Verify that the required fields are set.
        $missing = null;
        $ldap_data = $this->_map_data($save_data);
        foreach ($this->prop['required_fields'] as $fld) {
            if (!isset($ldap_data[$fld]) || $ldap_data[$fld] === '') {
                $missing[$fld] = 1;
            }
        }
        if ($missing) {
            // try to complete record automatically
            if ($autofix) {
                $sn_field    = $this->fieldmap['surname'];
                $fn_field    = $this->fieldmap['firstname'];
                $mail_field  = $this->fieldmap['email'];
                // try to extract surname and firstname from displayname
                $reverse_map = array_flip($this->fieldmap);
                $name_parts  = preg_split('/[\s,.]+/', $save_data['name']);
                if ($sn_field && $missing[$sn_field]) {
                    $save_data['surname'] = array_pop($name_parts);
                    unset($missing[$sn_field]);
                }
                if ($fn_field && $missing[$fn_field]) {
                    $save_data['firstname'] = array_shift($name_parts);
                    unset($missing[$fn_field]);
                }
                // try to fix missing e-mail, very often on import
                // from vCard we have email:other only defined
                if ($mail_field && $missing[$mail_field]) {
                    $emails = $this->get_col_values('email', $save_data, true);
                    if (!empty($emails) && ($email = array_shift($emails))) {
                        $save_data['email'] = $email;
                        unset($missing[$mail_field]);
                    }
                }
            }
            // TODO: generate message saying which fields are missing
            if (!empty($missing)) {
                $this->set_error(self::ERROR_VALIDATE, 'formincomplete');
                return false;
            }
        }
        return true;
    }
    /**
     * Create a new contact record
     *
     * @param array    Hash array with save data
     *
     * @return encoded record ID on success, False on error
     */
    function insert($save_cols)
    {
        // Map out the column names to their LDAP ones to build the new entry.
        $newentry = $this->_map_data($save_cols);
        $newentry['objectClass'] = $this->prop['LDAP_Object_Classes'];
        // Verify that the required fields are set.
        $missing = null;
        foreach ($this->prop['required_fields'] as $fld) {
            if (!isset($newentry[$fld])) {
                $missing[] = $fld;
            }
        }
        // abort process if requiered fields are missing
        // TODO: generate message saying which fields are missing
        if ($missing) {
            $this->set_error(self::ERROR_VALIDATE, 'formincomplete');
            return false;
        }
        // Build the new entries DN.
        $dn = $this->prop['LDAP_rdn'].'='.$this->_quote_string($newentry[$this->prop['LDAP_rdn']], true).','.$this->base_dn;
        // Remove attributes that need to be added separately (child objects)
        $xfields = array();
        if (!empty($this->prop['sub_fields']) && is_array($this->prop['sub_fields'])) {
            foreach ($this->prop['sub_fields'] as $xf => $xclass) {
                if (!empty($newentry[$xf])) {
                    $xfields[$xf] = $newentry[$xf];
                    unset($newentry[$xf]);
                }
            }
        }
        if (!$this->ldap_add($dn, $newentry)) {
            $this->set_error(self::ERROR_SAVING, 'errorsaving');
            return false;
        }
        foreach ($xfields as $xidx => $xf) {
            $xdn = $xidx.'='.$this->_quote_string($xf).','.$dn;
            $xf = array(
                $xidx => $xf,
                'objectClass' => (array) $this->prop['sub_fields'][$xidx],
            );
            $this->ldap_add($xdn, $xf);
        }
        $dn = self::dn_encode($dn);
        // add new contact to the selected group
        if ($this->group_id)
            $this->add_to_group($this->group_id, $dn);
        return $dn;
    }
    /**
     * Update a specific contact record
     *
     * @param mixed Record identifier
     * @param array Hash array with save data
     *
     * @return boolean True on success, False on error
     */
    function update($id, $save_cols)
    {
        $record = $this->get_record($id, true);
        $newdata     = array();
        $replacedata = array();
        $deletedata  = array();
        $subdata     = array();
        $subdeldata  = array();
        $subnewdata  = array();
        $ldap_data = $this->_map_data($save_cols);
        $old_data  = $record['_raw_attrib'];
        // special handling of photo col
        if ($photo_fld = $this->fieldmap['photo']) {
            // undefined means keep old photo
            if (!array_key_exists('photo', $save_cols)) {
                $ldap_data[$photo_fld] = $record['photo'];
            }
        }
        foreach ($this->fieldmap as $col => $fld) {
            if ($fld) {
                $val = $ldap_data[$fld];
                $old = $old_data[$fld];
                // remove empty array values
                if (is_array($val))
                    $val = array_filter($val);
                // $this->_map_data() result and _raw_attrib use different format
                // make sure comparing array with one element with a string works as expected
                if (is_array($old) && count($old) == 1 && !is_array($val)) {
                    $old = array_pop($old);
                }
                if (is_array($val) && count($val) == 1 && !is_array($old)) {
                    $val = array_pop($val);
                }
                // Subentries must be handled separately
                if (!empty($this->prop['sub_fields']) && isset($this->prop['sub_fields'][$fld])) {
                    if ($old != $val) {
                        if ($old !== null) {
                            $subdeldata[$fld] = $old;
                        }
                        if ($val) {
                            $subnewdata[$fld] = $val;
                        }
                    }
                    else if ($old !== null) {
                        $subdata[$fld] = $old;
                    }
                    continue;
                }
                // The field does exist compare it to the ldap record.
                if ($old != $val) {
                    // Changed, but find out how.
                    if ($old === null) {
                        // Field was not set prior, need to add it.
                        $newdata[$fld] = $val;
                    }
                    else if ($val == '') {
                        // Field supplied is empty, verify that it is not required.
                        if (!in_array($fld, $this->prop['required_fields'])) {
                            // It is not, safe to clear.
                            $deletedata[$fld] = $old_data[$fld];
                        }
                    }
                    else {
                        // The data was modified, save it out.
                        $replacedata[$fld] = $val;
                    }
                } // end if
            } // end if
        } // end foreach
/*
        console($old_data);
        console($ldap_data);
        console('----');
        console($newdata);
        console($replacedata);
        console($deletedata);
        console('----');
        console($subdata);
        console($subnewdata);
        console($subdeldata);
*/
        $dn = self::dn_decode($id);
        // Update the entry as required.
        if (!empty($deletedata)) {
            // Delete the fields.
            if (!$this->ldap_mod_del($dn, $deletedata)) {
                $this->set_error(self::ERROR_SAVING, 'errorsaving');
                return false;
            }
        } // end if
        if (!empty($replacedata)) {
            // Handle RDN change
            if ($replacedata[$this->prop['LDAP_rdn']]) {
                $newdn = $this->prop['LDAP_rdn'].'='
                    .$this->_quote_string($replacedata[$this->prop['LDAP_rdn']], true)
                    .','.$this->base_dn;
                if ($dn != $newdn) {
                    $newrdn = $this->prop['LDAP_rdn'].'='
                    .$this->_quote_string($replacedata[$this->prop['LDAP_rdn']], true);
                    unset($replacedata[$this->prop['LDAP_rdn']]);
                }
            }
            // Replace the fields.
            if (!empty($replacedata)) {
                if (!$this->ldap_mod_replace($dn, $replacedata)) {
                    $this->set_error(self::ERROR_SAVING, 'errorsaving');
                    return false;
                }
            }
        } // end if
        // RDN change, we need to remove all sub-entries
        if (!empty($newrdn)) {
            $subdeldata = array_merge($subdeldata, $subdata);
            $subnewdata = array_merge($subnewdata, $subdata);
        }
        // remove sub-entries
        if (!empty($subdeldata)) {
            foreach ($subdeldata as $fld => $val) {
                $subdn = $fld.'='.$this->_quote_string($val).','.$dn;
                if (!$this->ldap_delete($subdn)) {
                    return false;
                }
            }
        }
        if (!empty($newdata)) {
            // Add the fields.
            if (!$this->ldap_mod_add($dn, $newdata)) {
                $this->set_error(self::ERROR_SAVING, 'errorsaving');
                return false;
            }
        } // end if
        // Handle RDN change
        if (!empty($newrdn)) {
            if (!$this->ldap_rename($dn, $newrdn, null, true)) {
                $this->set_error(self::ERROR_SAVING, 'errorsaving');
                return false;
            }
            $dn    = self::dn_encode($dn);
            $newdn = self::dn_encode($newdn);
            // change the group membership of the contact
            if ($this->groups) {
                $group_ids = $this->get_record_groups($dn);
                foreach ($group_ids as $group_id)
                {
                    $this->remove_from_group($group_id, $dn);
                    $this->add_to_group($group_id, $newdn);
                }
            }
            $dn = self::dn_decode($newdn);
        }
        // add sub-entries
        if (!empty($subnewdata)) {
            foreach ($subnewdata as $fld => $val) {
                $subdn = $fld.'='.$this->_quote_string($val).','.$dn;
                $xf = array(
                    $fld => $val,
                    'objectClass' => (array) $this->prop['sub_fields'][$fld],
                );
                $this->ldap_add($subdn, $xf);
            }
        }
        return $newdn ? $newdn : true;
    }
    /**
     * Mark one or more contact records as deleted
     *
     * @param array   Record identifiers
     * @param boolean Remove record(s) irreversible (unsupported)
     *
     * @return boolean True on success, False on error
     */
    function delete($ids, $force=true)
    {
        if (!is_array($ids)) {
            // Not an array, break apart the encoded DNs.
            $ids = explode(',', $ids);
        } // end if
        foreach ($ids as $id) {
            $dn = self::dn_decode($id);
            // Need to delete all sub-entries first
            if ($this->sub_filter) {
                if ($entries = $this->ldap_list($dn, $this->sub_filter)) {
                    foreach ($entries as $entry) {
                        if (!$this->ldap_delete($entry['dn'])) {
                            $this->set_error(self::ERROR_SAVING, 'errorsaving');
                            return false;
                        }
                    }
                }
            }
            // Delete the record.
            if (!$this->ldap_delete($dn)) {
                $this->set_error(self::ERROR_SAVING, 'errorsaving');
                return false;
            }
            // remove contact from all groups where he was member
            if ($this->groups) {
                $dn = self::dn_encode($dn);
                $group_ids = $this->get_record_groups($dn);
                foreach ($group_ids as $group_id) {
                    $this->remove_from_group($group_id, $dn);
                }
            }
        } // end foreach
        return count($ids);
    }
    /**
     * Remove all contact records
     */
    function delete_all()
    {
        //searching for contact entries
        $dn_list = $this->ldap_list($this->base_dn, $this->prop['filter'] ? $this->prop['filter'] : '(objectclass=*)');
        if (!empty($dn_list)) {
            foreach ($dn_list as $idx => $entry) {
                $dn_list[$idx] = self::dn_encode($entry['dn']);
            }
            $this->delete($dn_list);
        }
    }
    /**
     * Execute the LDAP search based on the stored credentials
     */
    private function _exec_search($count = false)
    {
        if ($this->ready)
        {
            $filter = $this->filter ? $this->filter : '(objectclass=*)';
            $function = $this->_scope2func($this->prop['scope'], $ns_function);
            $this->_debug("C: Search [$filter][dn: $this->base_dn]");
            // when using VLV, we get the total count by...
            if (!$count && $function != 'ldap_read' && $this->prop['vlv'] && !$this->group_id) {
                // ...either reading numSubOrdinates attribute
                if ($this->prop['numsub_filter'] && ($result_count = @$ns_function($this->conn, $this->base_dn, $this->prop['numsub_filter'], array('numSubOrdinates'), 0, 0, 0))) {
                    $counts = ldap_get_entries($this->conn, $result_count);
                    for ($this->vlv_count = $j = 0; $j < $counts['count']; $j++)
                        $this->vlv_count += $counts[$j]['numsubordinates'][0];
                    $this->_debug("D: total numsubordinates = " . $this->vlv_count);
                }
                else if (!function_exists('ldap_parse_virtuallist_control'))  // ...or by fetching all records dn and count them
                    $this->vlv_count = $this->_exec_search(true);
                $this->vlv_active = $this->_vlv_set_controls($this->prop, $this->list_page, $this->page_size);
            }
            // only fetch dn for count (should keep the payload low)
            $attrs = $count ? array('dn') : array_values($this->fieldmap);
            if ($this->ldap_result = @$function($this->conn, $this->base_dn, $filter,
                $attrs, 0, (int)$this->prop['sizelimit'], (int)$this->prop['timelimit'])
            ) {
                // when running on a patched PHP we can use the extended functions to retrieve the total count from the LDAP search result
                if ($this->vlv_active && function_exists('ldap_parse_virtuallist_control')) {
                    if (ldap_parse_result($this->conn, $this->ldap_result,
                        $errcode, $matcheddn, $errmsg, $referrals, $serverctrls)
                    ) {
                        ldap_parse_virtuallist_control($this->conn, $serverctrls,
                            $last_offset, $this->vlv_count, $vresult);
                        $this->_debug("S: VLV result: last_offset=$last_offset; content_count=$this->vlv_count");
                    }
                    else {
                        $this->_debug("S: ".($errmsg ? $errmsg : ldap_error($this->conn)));
                    }
                }
                $entries_count = ldap_count_entries($this->conn, $this->ldap_result);
                $this->_debug("S: $entries_count record(s)");
                return $count ? $entries_count : true;
            }
            else {
                $this->_debug("S: ".ldap_error($this->conn));
            }
        }
        return false;
    }
    /**
     * Choose the right PHP function according to scope property
     */
    private function _scope2func($scope, &$ns_function = null)
    {
        switch ($scope) {
          case 'sub':
            $function = $ns_function  = 'ldap_search';
            break;
          case 'base':
            $function = $ns_function = 'ldap_read';
            break;
          default:
            $function = 'ldap_list';
            $ns_function = 'ldap_read';
            break;
        }
        return $function;
    }
    /**
     * Set server controls for Virtual List View (paginated listing)
     */
    private function _vlv_set_controls($prop, $list_page, $page_size, $search = null)
    {
        $sort_ctrl = array('oid' => "1.2.840.113556.1.4.473",  'value' => $this->_sort_ber_encode((array)$prop['sort']));
        $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);
        $sort = (array)$prop['sort'];
        $this->_debug("C: set controls sort=" . join(' ', unpack('H'.(strlen($sort_ctrl['value'])*2), $sort_ctrl['value'])) . " ($sort[0]);"
            . " vlv=" . join(' ', (unpack('H'.(strlen($vlv_ctrl['value'])*2), $vlv_ctrl['value']))) . " ($offset/$page_size)");
        if (!ldap_set_option($this->conn, LDAP_OPT_SERVER_CONTROLS, array($sort_ctrl, $vlv_ctrl))) {
            $this->_debug("S: ".ldap_error($this->conn));
            $this->set_error(self::ERROR_SEARCH, 'vlvnotsupported');
            return false;
        }
        return true;
    }
    /**
     * 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;
                list($col, $subtype) = explode(':', $rf);
                $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($col, array('street','zipcode','locality','country','region')))
                    $out['address'.($subtype?':':'').$subtype][$i][$col] = $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) {
            rcmail::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->get_group_member_attr($ldap_data[$i]['objectclass']);
            // 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->get_group_member_attr();
        $name_attr = $this->prop['groups']['name_attr'] ? $this->prop['groups']['name_attr'] : 'cn';
        $new_entry = array(
            'objectClass' => $this->prop['groups']['object_classes'],
            $name_attr => $group_name,
            $member_attr => '',
        );
        if (!$this->ldap_add($new_dn, $new_entry)) {
            $this->set_error(self::ERROR_SAVING, 'errorsaving');
            return false;
        }
        $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";
        if (!$this->ldap_delete($del_dn)) {
            $this->set_error(self::ERROR_SAVING, 'errorsaving');
            return false;
        }
        $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);
        if (!$this->ldap_rename($old_dn, $new_rdn, null, true)) {
            $this->set_error(self::ERROR_SAVING, 'errorsaving');
            return false;
        }
        $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);
        if (!$this->ldap_mod_add($group_dn, $new_attrs)) {
            $this->set_error(self::ERROR_SAVING, 'errorsaving');
            return 0;
        }
        $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);
        if (!$this->ldap_mod_del($group_dn, $del_attrs)) {
            $this->set_error(self::ERROR_SAVING, 'errorsaving');
            return 0;
        }
        $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'] ? $this->prop['groups']['name_attr'] : 'cn';
        $member_attr = $this->get_group_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;
    }
    /**
     * Detects group member attribute name
     */
    private function get_group_member_attr($object_classes = array())
    {
        if (empty($object_classes)) {
            $object_classes = $this->prop['groups']['object_classes'];
        }
        if (!empty($object_classes)) {
            foreach ((array)$object_classes as $oc) {
                switch (strtolower($oc)) {
                    case 'group':
                    case 'groupofnames':
                    case 'kolabgroupofnames':
                        $member_attr = 'member';
                        break;
                    case 'groupofuniquenames':
                    case 'kolabgroupofuniquenames':
                        $member_attr = 'uniqueMember';
                        break;
                }
            }
        }
        if (!empty($member_attr)) {
            return $member_attr;
        }
        if (!empty($this->prop['groups']['member_attr'])) {
            return $this->prop['groups']['member_attr'];
        }
        return 'member';
    }
    /**
     * 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);
    }
    /**
     * Wrapper for ldap_add()
     */
    protected function ldap_add($dn, $entry)
    {
        $this->_debug("C: Add [dn: $dn]: ".print_r($entry, true));
        $res = ldap_add($this->conn, $dn, $entry);
        if ($res === false) {
            $this->_debug("S: ".ldap_error($this->conn));
            return false;
        }
        $this->_debug("S: OK");
        return true;
    }
    /**
     * Wrapper for ldap_delete()
     */
    protected function ldap_delete($dn)
    {
        $this->_debug("C: Delete [dn: $dn]");
        $res = ldap_delete($this->conn, $dn);
        if ($res === false) {
            $this->_debug("S: ".ldap_error($this->conn));
            return false;
        }
        $this->_debug("S: OK");
        return true;
    }
    /**
     * Wrapper for ldap_mod_replace()
     */
    protected function ldap_mod_replace($dn, $entry)
    {
        $this->_debug("C: Replace [dn: $dn]: ".print_r($entry, true));
        if (!ldap_mod_replace($this->conn, $dn, $entry)) {
            $this->_debug("S: ".ldap_error($this->conn));
            return false;
        }
        $this->_debug("S: OK");
        return true;
    }
    /**
     * Wrapper for ldap_mod_add()
     */
    protected function ldap_mod_add($dn, $entry)
    {
        $this->_debug("C: Add [dn: $dn]: ".print_r($entry, true));
        if (!ldap_mod_add($this->conn, $dn, $entry)) {
            $this->_debug("S: ".ldap_error($this->conn));
            return false;
        }
        $this->_debug("S: OK");
        return true;
    }
    /**
     * Wrapper for ldap_mod_del()
     */
    protected function ldap_mod_del($dn, $entry)
    {
        $this->_debug("C: Delete [dn: $dn]: ".print_r($entry, true));
        if (!ldap_mod_del($this->conn, $dn, $entry)) {
            $this->_debug("S: ".ldap_error($this->conn));
            return false;
        }
        $this->_debug("S: OK");
        return true;
    }
    /**
     * Wrapper for ldap_rename()
     */
    protected function ldap_rename($dn, $newrdn, $newparent = null, $deleteoldrdn = true)
    {
        $this->_debug("C: Rename [dn: $dn] [dn: $newrdn]");
        if (!ldap_rename($this->conn, $dn, $newrdn, $newparent, $deleteoldrdn)) {
            $this->_debug("S: ".ldap_error($this->conn));
            return false;
        }
        $this->_debug("S: OK");
        return true;
    }
    /**
     * Wrapper for ldap_list()
     */
    protected function ldap_list($dn, $filter, $attrs = array(''))
    {
        $list = array();
        $this->_debug("C: List [dn: $dn] [{$filter}]");
        if ($result = ldap_list($this->conn, $dn, $filter, $attrs)) {
            $list = ldap_get_entries($this->conn, $result);
            if ($list === false) {
                $this->_debug("S: ".ldap_error($this->conn));
                return array();
            }
            $count = $list['count'];
            unset($list['count']);
            $this->_debug("S: $count record(s)");
        }
        else {
            $this->_debug("S: ".ldap_error($this->conn));
        }
        return $list;
    }
}