<?php
/**
 * Turba directory driver implementation for PHP's LDAP extension.
 *
 * $Horde: turba/lib/Driver/ldap.php,v 1.11.2.9 2003/02/26 02:40:48 chuck Exp $
 *
 * @author  Chuck Hagenbuch <chuck@horde.org>
 * @author  Jon Parise <jon@csh.rit.edu>
 * @version $Revision: 1.11.2.9 $
 * @since   Turba 0.0.1
 * @package turba
 */
class Turba_Driver_ldap extends Turba_Driver {

    /** String containing the hostname of the LDAP server.  */
    var $server = 'localhost';

    /** The port that the ldap server is running on. */
    var $port = 389;

    /** String containing the LDAP search root. */
    var $root = '';

    /** Handle for the current LDAP connection. */
    var $ds = 0;

    /** String containing the character set encoding. */
    var $encoding = '';

    /** LDAP protocol version. */
    var $version;

    /** Multiple Entry Separator. */
    var $separator = ', ';

    /**
     * Constructs a new Turba LDAP driver object.
     *
     * @param $params       Hash containing additional configuration parameters.
     */
    function Turba_Driver_ldap($params)
    {
        $this->type = 'ldap';
        $this->params = $params;

        $this->server = $params['server'];
        $this->root = $params['root'];
        if (!empty($params['port'])) {
            $this->port = $params['port'];
        }
        if (!empty($params['version'])) {
            $this->version = $params['version'];
        }
        if (!empty($params['multiple_entry_separator'])) {
            $this->separator = $params['multiple_entry_separator'];
        }

        if (!($this->ds = @ldap_connect($this->server, $this->port))) {
            $this->errno = 1;
            $this->errstr = 'Connection failure';
            return false;
        }

        // Set the LDAP protocol version.
        if (isset($this->version)) {
            @ldap_set_option($this->ds, LDAP_OPT_PROTOCOL_VERSION, $this->version);
        }

        if (!empty($params['encoding'])) {
            $this->encoding = $params['encoding'];
        }

        if (isset($params['bind_dn']) &&
            isset($params['bind_password'])) {
            if (!@ldap_bind($this->ds, $params['bind_dn'], $params['bind_password'])) {
                $this->errno = ldap_errno($this->ds);
                $this->errstr = ldap_error($this->ds);
                return false;
            }
        } else if (!(@ldap_bind($this->ds))) {
            $this->errno = ldap_errno($this->ds);
            $this->errstr = ldap_error($this->ds);
            return false;
        }
    }

    /**
     * Searches the LDAP directory with the given criteria and returns
     * a filtered list of results. If no criteria are specified, all
     * records are returned.
     *
     * @param $criteria      Array containing the search criteria.
     * @param $fields        List of fields to return.
     * @param $strict_fields Array of fields which must be matched exactly.
     * @param const $match   Do an 'and' or an 'or' search (defaults to TURBA_SEARCH_AND).
     *
     * @return              Hash containing the search results.
     */
    function search($criteria, $fields, $strict_fields = array(), $match = TURBA_SEARCH_AND)
    {
        if ($match === TURBA_SEARCH_AND) {
            $glue = '(&';
        } else {
            $glue = '(|';
        }

        if (count($criteria) > 0) {
            $filter = $glue;

            /* Sort into strict and non-strict fields. */
            $strict = array();
            $loose = array();
            foreach ($criteria as $key => $val) {
                if ($this->encoding == 'utf8') {
                    $val = utf8_encode($val);
                }
                if (in_array($key, $strict_fields)) {
                    $strict[$key] = $val;
                } else {
                    $loose[$key] = $val;
                }
            }

            /* Add strict sub-query. */
            if (count($strict) > 0) {
                $filter .= '(&';
                foreach ($strict as $key => $val) {
                    $filter .= '(' . $key . '=' . $val . ')';
                }
                $filter .= ')';
            }

            /* Add loose sub-query. */
            if (count($loose) > 0) {
                $filter .= $glue;
                foreach ($loose as $key => $val) {
                    $filter .= '(' . $key . '=*' . (empty($val) ? '' : $val . '*') . ')';
                }
                $filter .= ')';
            }

            /* Close overall query. */
            $filter .= ')';
        } else {
            // Filter on objectclass.
            $filter = $this->_buildObjectclassFilter();
        }

        /* Add source-wide filters, which are _always_ AND-ed. */
        if (!empty($this->params['filter'])) {
            $filter = '(&' . '(' . $this->params['filter'] . ')' . $filter . ')';
        }

        /*
         * Four11 (at least) doesn't seem to return 'cn' if you don't ask
         * for 'sn' as well.  Add 'sn' implicitly.
         */
        $attr = $fields;
        if (!in_array('sn', $attr)) {
            $attr[] = 'sn';
        }

        /* Log the query at a DEBUG log level. */
        Horde::logMessage(sprintf('LDAP search by %s: root = %s (%s); filter = "%s"; attributes = "%s"',
                                  Auth::getAuth(), $this->root, $this->server, $filter, implode(', ', $attr)),
                          __FILE__, __LINE__, LOG_DEBUG);

        /* Send the query to the LDAP server and fetch the matching entries. */
        if (!($res = @ldap_search($this->ds, $this->root, $filter, $attr))) {
            $this->errno = ldap_errno($this->ds);
            $this->errstr = ldap_error($this->ds);
            // FIXME: raise an exception using PEAR error handling
            return array();
        }

        return $this->getResults($fields, $res);
    }

    /**
     * Reads the LDAP directory for a given element and returns
     * the result's fields.
     *
     * @param $criteria      Search criteria (must be 'dn').
     * @param $dn        dn of the object to read.
     * @param $fields        List of fields to return.
     *
     * @return              Hash containing the search results.
     */
    function read($criteria, $dn, $fields)
    {
        // Only DN
        if ($criteria != 'dn') {
            return array();
        }

        $filter = $this->_buildObjectclassFilter();

        /*
         * Four11 (at least) doesn't seem to return 'cn' if you don't ask
         * for 'sn' as well.  Add 'sn' implicitly.
         */
        $attr = $fields;
        if (!in_array('sn', $attr)) $attr[] = 'sn';

        $res = @ldap_read($this->ds, $dn, $filter, $attr);
        if (!$res) {
            $this->errno = ldap_errno($this->ds);
            $this->errstr = ldap_error($this->ds);
            // FIXME: raise an exception using PEAR error handling
            return array();
        }

        return $this->getResults($fields, $res);
    }

    /**
     * Get some results and clean then from a result identifier.
     *
     * @param $fields   List of fields to return.
     * @param $res  Result identifier.
     *
     * @return      Hash containing the results.
     */
    function getResults($fields, $res)
    {
        if (!($entries = @ldap_get_entries($this->ds, $res))) {
            $this->errno = ldap_errno($this->ds);
            $this->errstr = ldap_error($this->ds);
            // FIXME: raise an exception using PEAR error handling
            return array();
        }

        /* Return only the requested fields (from $fields, above). */
        $results = array();
        for ($i = 0; $i < $entries['count']; $i++) {
            $entry = $entries[$i];
            $result = array();

            foreach ($fields as $field) {
                if ($field == 'dn') {
                    $result[$field] = $entry[$field];
                } else {
                    $result[$field] = '';
                    if (!empty($entry[$field])) {
                        for ($j = 0; $j < $entry[$field]['count']; $j++) {
                            if (!empty($result[$field])) {
                                $result[$field] .= $this->separator;
                            }
                            if ($this->encoding == 'utf8') {
                                $result[$field] .= utf8_decode($entry[$field][$j]);
                            } else {
                                $result[$field] .= $entry[$field][$j];
                            }
                        }
                    }
                }
            }

            $results[] = $result;
        }

        return $results;
    }

    /**
     * Closes the current LDAP connection.
     *
     * @return          The result of the ldap_close() call.
     */
    function close()
    {
        return ldap_close($this->ds);
    }

    /**
     * Adds the specified entry to the LDAP directory.
     */
    function addObject($attributes)
    {
        if (!isset($attributes['dn'])) {
            return new PEAR_Error('Tried to add an object with no dn: [' . serialize($attributes) . '].');
        } elseif (!isset($this->params['objectclass'])) {
            return new PEAR_Error('Tried to add an object with no objectclass: [' . serialize($attributes) . '].');
        }

        // Take the DN out of the attributes array
        $dn = $attributes['dn'];
        unset($attributes['dn']);

        // Put the Objectclass into the attributes array
        if (!is_array($this->params['objectclass'])) {
            $attributes['objectclass'] = $this->params['objectclass'];
        } else {
            $i = 0;
            foreach ($this->params['objectclass'] as $objectclass) {
                $attributes['objectclass'][$i] = $objectclass;
                $i++;
            }
        }

        // Don't add empty attributes.
        $attributes = array_filter($attributes, array($this, 'cleanEmptyAttributes'));

        // Encode entries in UTF-8 if requested.
        if ($this->encoding == 'utf8') {
            foreach ($attributes as $key => $val) {
                if (!is_array($val)) {
                    $attributes[$key] = utf8_encode($val);
                }
            }
        }

        if (!@ldap_add($this->ds, $dn, $attributes)) {
            return new PEAR_Error('Failed to add an object: [' . ldap_errno($this->ds) . '] "' . ldap_error($this->ds) . '" (attributes: [' . serialize($attributes) . ']).');
        } else {
            return true;
        }
    }

    /**
     * Deletes the specified entry from the LDAP directory.
     */
    function removeObject($object_key, $object_id)
    {
        if ($object_key != 'dn') {
            // PEAR error
            return false;
        }

        if (!@ldap_delete($this->ds, $object_id)) {
            $this->errno = ldap_errno($this->ds);
            $this->errstr = ldap_error($this->ds);
            return false;
        } else {
            return true;
        }
    }

    /**
     * Modifies the specified entry in the LDAP directory.
     */
    function setObject($object_key, $object_id, $attributes)
    {
        // Get the old entry so that we can access the old
        // values. These are needed so that we can delete any
        // attributes that have been removed by using ldap_mod_del.

        $filter = $this->_buildObjectclassFilter();
        $oldres = @ldap_read($this->ds, $object_id, $filter, array_keys($attributes));
        $info = ldap_get_attributes($this->ds, ldap_first_entry($this->ds, $oldres));

        foreach ($info as $key => $value) {
            $var = $info[$key];
            $oldval = null;
            // The attributes in the attributes array are all lower
            // case while they are mixedCase in the search result. So
            // convert the keys to lower.
            // FIXME: use array_change_key_case() eventually.
            $lowerkey = strtolower($key);

            // Check to see if the old value and the new value are
            // different and that the new value is empty. If so then
            // we use ldap_mod_del to delete the attribute.
            if (isset($attributes[$lowerkey]) &&
                ($var[0] != $attributes[$lowerkey]) &&
                $attributes[$lowerkey] == '') {

                $oldval[$key] = $var[0];
                if (!@ldap_mod_del($this->ds, $object_id, $oldval)) {
                    $this->errno = ldap_errno($this->ds);
                    $this->errstr = ldap_error($this->ds);
                    return false;
                }
                unset($attributes[$lowerkey]);
            }
        }

        // Encode entries in UTF-8 if requested.
        if ($this->encoding == 'utf8') {
            foreach ($attributes as $key => $val) {
                if (!is_array($val)) {
                    $attributes[$key] = utf8_encode($val);
                }
            }
        }

        unset($attributes[$object_key]);
        $attributes = array_filter($attributes, array($this, 'cleanEmptyAttributes'));

        if (!@ldap_modify($this->ds, $object_id, $attributes)) {
            $this->errno = ldap_errno($this->ds);
            $this->errstr = ldap_error($this->ds);
            return false;
        } else {
            return true;
        }
    }

    /**
     * Build a DN based on a set of attributes and what attributes
     * make a DN for the current source.
     *
     * @param array $attributes The attributes (in driver keys) of the
     *                          object being added.
     *
     * @return string  The DN for the new object.
     */
    function makeKey($attributes)
    {
        $dn = '';
        if (is_array($this->params['dn'])) {
            foreach ($this->params['dn'] as $param) {
                if (isset($attributes[$param])) {
                    $dn .= $param . '=' . $attributes[$param] . ',';
                }
            }
        }

        $dn .= $this->params['root'];
        return $dn;
    }

    /**
     *  Remove empty attributes from attributes array
     *
     * @param mixed $val    Value from attributes array.
     * @return bool     Boolean used by array_filter.
     */
    function cleanEmptyAttributes($var)
    {
        if (!is_array($var)) {
            return ($var != '');
        } else {
            foreach ($var as $v) {
                return($v != '');
            }
        }
    }

    /**
     * Build an LDAP filter based on the objectclass parameter.
     *
     * @return string An LDAP filter.
     */
    function _buildObjectclassFilter()
    {
        $filter = '';
        if (!empty($this->params['objectclass'])) {
            if (!is_array($this->params['objectclass'])) {
                $filter = '(objectclass=' . $this->params['objectclass'] . ')';
            } else {
                $filter = '(|';
                foreach ($this->params['objectclass'] as $objectclass) {
                    $filter .= '(objectclass=' . $objectclass . ')';
                }
                $filter .= ')';
            }
        }
        return $filter;
    }

}
