<?php

use dokuwiki\Extension\AuthPlugin;
use dokuwiki\PassHash;
use dokuwiki\Utf8\Sort;

/**
 * DokuWiki Plugin authpdo (Auth Component)
 *
 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
 * @author  Andreas Gohr <andi@splitbrain.org>
 */

/**
 * Class auth_plugin_authpdo
 */
class auth_plugin_authpdo extends AuthPlugin
{
    /** @var PDO */
    protected $pdo;

    /** @var null|array The list of all groups */
    protected $groupcache;

    /**
     * Constructor.
     */
    public function __construct()
    {
        parent::__construct(); // for compatibility

        if (!class_exists('PDO')) {
            $this->debugMsg('PDO extension for PHP not found.', -1, __LINE__);
            $this->success = false;
            return;
        }

        if (!$this->getConf('dsn')) {
            $this->debugMsg('No DSN specified', -1, __LINE__);
            $this->success = false;
            return;
        }

        try {
            $this->pdo = new PDO(
                $this->getConf('dsn'),
                $this->getConf('user'),
                conf_decodeString($this->getConf('pass')),
                [
                    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, // always fetch as array
                    PDO::ATTR_EMULATE_PREPARES => true, // emulating prepares allows us to reuse param names
                    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // we want exceptions, not error codes
                ]
            );
        } catch (PDOException $e) {
            $this->debugMsg($e);
            msg($this->getLang('connectfail'), -1);
            $this->success = false;
            return;
        }

        // can Users be created?
        $this->cando['addUser'] = $this->checkConfig(
            ['select-user', 'select-user-groups', 'select-groups', 'insert-user', 'insert-group', 'join-group']
        );

        // can Users be deleted?
        $this->cando['delUser'] = $this->checkConfig(
            ['select-user', 'select-user-groups', 'select-groups', 'leave-group', 'delete-user']
        );

        // can login names be changed?
        $this->cando['modLogin'] = $this->checkConfig(
            ['select-user', 'select-user-groups', 'update-user-login']
        );

        // can passwords be changed?
        $this->cando['modPass'] = $this->checkConfig(
            ['select-user', 'select-user-groups', 'update-user-pass']
        );

        // can real names be changed?
        $this->cando['modName'] = $this->checkConfig(
            ['select-user', 'select-user-groups', 'update-user-info:name']
        );

        // can real email be changed?
        $this->cando['modMail'] = $this->checkConfig(
            ['select-user', 'select-user-groups', 'update-user-info:mail']
        );

        // can groups be changed?
        $this->cando['modGroups'] = $this->checkConfig(
            ['select-user', 'select-user-groups', 'select-groups', 'leave-group', 'join-group', 'insert-group']
        );

        // can a filtered list of users be retrieved?
        $this->cando['getUsers'] = $this->checkConfig(
            ['list-users']
        );

        // can the number of users be retrieved?
        $this->cando['getUserCount'] = $this->checkConfig(
            ['count-users']
        );

        // can a list of available groups be retrieved?
        $this->cando['getGroups'] = $this->checkConfig(
            ['select-groups']
        );

        $this->success = true;
    }

    /**
     * Check user+password
     *
     * @param string $user the user name
     * @param string $pass the clear text password
     * @return  bool
     */
    public function checkPass($user, $pass)
    {

        $userdata = $this->selectUser($user);
        if ($userdata === false) {
            auth_cryptPassword('dummy'); // run a crypt op to prevent timing attacks
            return false;
        }

        // password checking done in SQL?
        if ($this->checkConfig(['check-pass'])) {
            $userdata['clear'] = $pass;
            $userdata['hash'] = auth_cryptPassword($pass);
            $result = $this->query($this->getConf('check-pass'), $userdata);
            if ($result === false) return false;
            return (count($result) == 1);
        }

        // we do password checking on our own
        if (isset($userdata['hash'])) {
            // hashed password
            $passhash = new PassHash();
            return $passhash->verify_hash($pass, $userdata['hash']);
        } else {
            // clear text password in the database O_o
            return ($pass === $userdata['clear']);
        }
    }

    /**
     * Return user info
     *
     * Returns info about the given user needs to contain
     * at least these fields:
     *
     * name string  full name of the user
     * mail string  email addres of the user
     * grps array   list of groups the user is in
     *
     * @param string $user the user name
     * @param bool $requireGroups whether or not the returned data must include groups
     * @return array|bool containing user data or false
     */
    public function getUserData($user, $requireGroups = true)
    {
        if (!is_string($user)) return false;
        $data = $this->selectUser($user);
        if ($data == false) return false;

        if (isset($data['hash'])) unset($data['hash']);
        if (isset($data['clean'])) unset($data['clean']);

        if ($requireGroups) {
            $data['grps'] = $this->selectUserGroups($data);
            if ($data['grps'] === false) return false;
        }

        return $data;
    }

    /**
     * Create a new User [implement only where required/possible]
     *
     * Returns false if the user already exists, null when an error
     * occurred and true if everything went well.
     *
     * The new user HAS TO be added to the default group by this
     * function!
     *
     * Set addUser capability when implemented
     *
     * @param string $user
     * @param string $clear
     * @param string $name
     * @param string $mail
     * @param null|array $grps
     * @return bool|null
     */
    public function createUser($user, $clear, $name, $mail, $grps = null)
    {
        global $conf;

        if (($info = $this->getUserData($user, false)) !== false) {
            msg($this->getLang('userexists'), -1);
            return false; // user already exists
        }

        // prepare data
        if ($grps == null) $grps = [];
        array_unshift($grps, $conf['defaultgroup']);
        $grps = array_unique($grps);
        $hash = auth_cryptPassword($clear);
        $userdata = ['user' => $user, 'clear' => $clear, 'hash' => $hash, 'name' => $name, 'mail' => $mail];

        // action protected by transaction
        $this->pdo->beginTransaction();
        {
            // insert the user
            $ok = $this->query($this->getConf('insert-user'), $userdata);
            if ($ok === false) goto FAIL;
            $userdata = $this->getUserData($user, false);
            if ($userdata === false) goto FAIL;

            // create all groups that do not exist, the refetch the groups
            $allgroups = $this->selectGroups();
        foreach ($grps as $group) {
            if (!isset($allgroups[$group])) {
                $ok = $this->addGroup($group);
                if ($ok === false) goto FAIL;
            }
        }
            $allgroups = $this->selectGroups();

            // add user to the groups
        foreach ($grps as $group) {
            $ok = $this->joinGroup($userdata, $allgroups[$group]);
            if ($ok === false) goto FAIL;
        }
        }
        $this->pdo->commit();
        return true;

        // something went wrong, rollback
        FAIL:
        $this->pdo->rollBack();
        $this->debugMsg('Transaction rolled back', 0, __LINE__);
        msg($this->getLang('writefail'), -1);
        return null; // return error
    }

    /**
     * Modify user data
     *
     * @param string $user nick of the user to be changed
     * @param array $changes array of field/value pairs to be changed (password will be clear text)
     * @return  bool
     */
    public function modifyUser($user, $changes)
    {
        // secure everything in transaction
        $this->pdo->beginTransaction();
        {
            $olddata = $this->getUserData($user);
            $oldgroups = $olddata['grps'];
            unset($olddata['grps']);

            // changing the user name?
        if (isset($changes['user'])) {
            if ($this->getUserData($changes['user'], false)) goto FAIL;
            $params = $olddata;
            $params['newlogin'] = $changes['user'];

            $ok = $this->query($this->getConf('update-user-login'), $params);
            if ($ok === false) goto FAIL;
        }

            // changing the password?
        if (isset($changes['pass'])) {
            $params = $olddata;
            $params['clear'] = $changes['pass'];
            $params['hash'] = auth_cryptPassword($changes['pass']);

            $ok = $this->query($this->getConf('update-user-pass'), $params);
            if ($ok === false) goto FAIL;
        }

            // changing info?
        if (isset($changes['mail']) || isset($changes['name'])) {
            $params = $olddata;
            if (isset($changes['mail'])) $params['mail'] = $changes['mail'];
            if (isset($changes['name'])) $params['name'] = $changes['name'];

            $ok = $this->query($this->getConf('update-user-info'), $params);
            if ($ok === false) goto FAIL;
        }

            // changing groups?
        if (isset($changes['grps'])) {
            $allgroups = $this->selectGroups();

            // remove membership for previous groups
            foreach ($oldgroups as $group) {
                if (!in_array($group, $changes['grps']) && isset($allgroups[$group])) {
                    $ok = $this->leaveGroup($olddata, $allgroups[$group]);
                    if ($ok === false) goto FAIL;
                }
            }

            // create all new groups that are missing
            $added = 0;
            foreach ($changes['grps'] as $group) {
                if (!isset($allgroups[$group])) {
                    $ok = $this->addGroup($group);
                    if ($ok === false) goto FAIL;
                    $added++;
                }
            }
            // reload group info
            if ($added > 0) $allgroups = $this->selectGroups();

            // add membership for new groups
            foreach ($changes['grps'] as $group) {
                if (!in_array($group, $oldgroups)) {
                    $ok = $this->joinGroup($olddata, $allgroups[$group]);
                    if ($ok === false) goto FAIL;
                }
            }
        }

        }
        $this->pdo->commit();
        return true;

        // something went wrong, rollback
        FAIL:
        $this->pdo->rollBack();
        $this->debugMsg('Transaction rolled back', 0, __LINE__);
        msg($this->getLang('writefail'), -1);
        return false; // return error
    }

    /**
     * Delete one or more users
     *
     * Set delUser capability when implemented
     *
     * @param array $users
     * @return  int    number of users deleted
     */
    public function deleteUsers($users)
    {
        $count = 0;
        foreach ($users as $user) {
            if ($this->deleteUser($user)) $count++;
        }
        return $count;
    }

    /**
     * Bulk retrieval of user data [implement only where required/possible]
     *
     * Set getUsers capability when implemented
     *
     * @param int $start index of first user to be returned
     * @param int $limit max number of users to be returned
     * @param array $filter array of field/pattern pairs, null for no filter
     * @return  array list of userinfo (refer getUserData for internal userinfo details)
     */
    public function retrieveUsers($start = 0, $limit = -1, $filter = null)
    {
        if ($limit < 0) $limit = 10000; // we don't support no limit
        if (is_null($filter)) $filter = [];

        if (isset($filter['grps'])) $filter['group'] = $filter['grps'];
        foreach (['user', 'name', 'mail', 'group'] as $key) {
            if (!isset($filter[$key])) {
                $filter[$key] = '%';
            } else {
                $filter[$key] = '%' . $filter[$key] . '%';
            }
        }
        $filter['start'] = (int)$start;
        $filter['end'] = (int)$start + $limit;
        $filter['limit'] = (int)$limit;

        $result = $this->query($this->getConf('list-users'), $filter);
        if (!$result) return [];
        $users = [];
        if (is_array($result)) {
            foreach ($result as $row) {
                if (!isset($row['user'])) {
                    $this->debugMsg("list-users statement did not return 'user' attribute", -1, __LINE__);
                    return [];
                }
                $users[] = $this->getUserData($row['user']);
            }
        } else {
            $this->debugMsg("list-users statement did not return a list of result", -1, __LINE__);
        }
        return $users;
    }

    /**
     * Return a count of the number of user which meet $filter criteria
     *
     * @param array $filter array of field/pattern pairs, empty array for no filter
     * @return int
     */
    public function getUserCount($filter = [])
    {
        if (is_null($filter)) $filter = [];

        if (isset($filter['grps'])) $filter['group'] = $filter['grps'];
        foreach (['user', 'name', 'mail', 'group'] as $key) {
            if (!isset($filter[$key])) {
                $filter[$key] = '%';
            } else {
                $filter[$key] = '%' . $filter[$key] . '%';
            }
        }

        $result = $this->query($this->getConf('count-users'), $filter);
        if (!$result || !isset($result[0]['count'])) {
            $this->debugMsg("Statement did not return 'count' attribute", -1, __LINE__);
        }
        return (int)$result[0]['count'];
    }

    /**
     * Create a new group with the given name
     *
     * @param string $group
     * @return bool
     */
    public function addGroup($group)
    {
        $sql = $this->getConf('insert-group');

        $result = $this->query($sql, [':group' => $group]);
        $this->clearGroupCache();
        if ($result === false) return false;
        return true;
    }

    /**
     * Retrieve groups
     *
     * Set getGroups capability when implemented
     *
     * @param int $start
     * @param int $limit
     * @return  array
     */
    public function retrieveGroups($start = 0, $limit = 0)
    {
        $groups = array_keys($this->selectGroups());
        if ($groups === false) return [];

        if (!$limit) {
            return array_splice($groups, $start);
        } else {
            return array_splice($groups, $start, $limit);
        }
    }

    /**
     * Select data of a specified user
     *
     * @param string $user the user name
     * @return bool|array user data, false on error
     */
    protected function selectUser($user)
    {
        $sql = $this->getConf('select-user');

        $result = $this->query($sql, [':user' => $user]);
        if (!$result) return false;

        if (count($result) > 1) {
            $this->debugMsg('Found more than one matching user', -1, __LINE__);
            return false;
        }

        $data = array_shift($result);
        $dataok = true;

        if (!isset($data['user'])) {
            $this->debugMsg("Statement did not return 'user' attribute", -1, __LINE__);
            $dataok = false;
        }
        if (!isset($data['hash']) && !isset($data['clear']) && !$this->checkConfig(['check-pass'])) {
            $this->debugMsg("Statement did not return 'clear' or 'hash' attribute", -1, __LINE__);
            $dataok = false;
        }
        if (!isset($data['name'])) {
            $this->debugMsg("Statement did not return 'name' attribute", -1, __LINE__);
            $dataok = false;
        }
        if (!isset($data['mail'])) {
            $this->debugMsg("Statement did not return 'mail' attribute", -1, __LINE__);
            $dataok = false;
        }

        if (!$dataok) return false;
        return $data;
    }

    /**
     * Delete a user after removing all their group memberships
     *
     * @param string $user
     * @return bool true when the user was deleted
     */
    protected function deleteUser($user)
    {
        $this->pdo->beginTransaction();
        {
            $userdata = $this->getUserData($user);
            if ($userdata === false) goto FAIL;
            $allgroups = $this->selectGroups();

            // remove group memberships (ignore errors)
        foreach ($userdata['grps'] as $group) {
            if (isset($allgroups[$group])) {
                $this->leaveGroup($userdata, $allgroups[$group]);
            }
        }

            $ok = $this->query($this->getConf('delete-user'), $userdata);
            if ($ok === false) goto FAIL;
        }
        $this->pdo->commit();
        return true;

        FAIL:
        $this->pdo->rollBack();
        return false;
    }

    /**
     * Select all groups of a user
     *
     * @param array $userdata The userdata as returned by _selectUser()
     * @return array|bool list of group names, false on error
     */
    protected function selectUserGroups($userdata)
    {
        global $conf;
        $sql = $this->getConf('select-user-groups');
        $result = $this->query($sql, $userdata);
        if ($result === false) return false;

        $groups = [$conf['defaultgroup']]; // always add default config
        if (is_array($result)) {
            foreach ($result as $row) {
                if (!isset($row['group'])) {
                    $this->debugMsg("No 'group' field returned in select-user-groups statement", -1, __LINE__);
                    return false;
                }
                $groups[] = $row['group'];
            }
        } else {
            $this->debugMsg("select-user-groups statement did not return a list of result", -1, __LINE__);
        }

        $groups = array_unique($groups);
        Sort::sort($groups);
        return $groups;
    }

    /**
     * Select all available groups
     *
     * @return array|bool list of all available groups and their properties
     */
    protected function selectGroups()
    {
        if ($this->groupcache) return $this->groupcache;

        $sql = $this->getConf('select-groups');
        $result = $this->query($sql);
        if ($result === false) return false;

        $groups = [];
        if (is_array($result)) {
            foreach ($result as $row) {
                if (!isset($row['group'])) {
                    $this->debugMsg("No 'group' field returned from select-groups statement", -1, __LINE__);
                    return false;
                }

                // relayout result with group name as key
                $group = $row['group'];
                $groups[$group] = $row;
            }
        } else {
            $this->debugMsg("select-groups statement did not return a list of result", -1, __LINE__);
        }

        Sort::ksort($groups);
        return $groups;
    }

    /**
     * Remove all entries from the group cache
     */
    protected function clearGroupCache()
    {
        $this->groupcache = null;
    }

    /**
     * Adds the user to the group
     *
     * @param array $userdata all the user data
     * @param array $groupdata all the group data
     * @return bool
     */
    protected function joinGroup($userdata, $groupdata)
    {
        $data = array_merge($userdata, $groupdata);
        $sql = $this->getConf('join-group');
        $result = $this->query($sql, $data);
        if ($result === false) return false;
        return true;
    }

    /**
     * Removes the user from the group
     *
     * @param array $userdata all the user data
     * @param array $groupdata all the group data
     * @return bool
     */
    protected function leaveGroup($userdata, $groupdata)
    {
        $data = array_merge($userdata, $groupdata);
        $sql = $this->getConf('leave-group');
        $result = $this->query($sql, $data);
        if ($result === false) return false;
        return true;
    }

    /**
     * Executes a query
     *
     * @param string $sql The SQL statement to execute
     * @param array $arguments Named parameters to be used in the statement
     * @return array|int|bool The result as associative array for SELECTs, affected rows for others, false on error
     */
    protected function query($sql, $arguments = [])
    {
        $sql = trim($sql);
        if (empty($sql)) {
            $this->debugMsg('No SQL query given', -1, __LINE__);
            return false;
        }

        // execute
        $params = [];
        $sth = $this->pdo->prepare($sql);
        $result = false;
        try {
            // prepare parameters - we only use those that exist in the SQL
            foreach ($arguments as $key => $value) {
                if (is_array($value)) continue;
                if (is_object($value)) continue;
                if ($key[0] != ':') $key = ":$key"; // prefix with colon if needed
                if (!str_contains($sql, (string) $key)) continue; // skip if parameter is missing

                if (is_int($value)) {
                    $sth->bindValue($key, $value, PDO::PARAM_INT);
                } else {
                    $sth->bindValue($key, $value);
                }
                $params[$key] = $value; //remember for debugging
            }

            $sth->execute();
            // only report last line's result
            $hasnextrowset = true;
            $currentsql = $sql;
            while ($hasnextrowset) {
                if (str_starts_with(strtolower($currentsql), 'select')) {
                    $result = $sth->fetchAll();
                } else {
                    $result = $sth->rowCount();
                }
                $semi_pos = strpos($currentsql, ';');
                if ($semi_pos) {
                    $currentsql = trim(substr($currentsql, $semi_pos + 1));
                }
                try {
                    $hasnextrowset = $sth->nextRowset(); // run next rowset
                } catch (PDOException) {
                    $hasnextrowset = false; // driver does not support multi-rowset, should be executed in one time
                }
            }
        } catch (Exception $e) {
            // report the caller's line
            $trace = debug_backtrace();
            $line = $trace[0]['line'];
            $dsql = $this->debugSQL($sql, $params, !defined('DOKU_UNITTEST'));
            $this->debugMsg($e, -1, $line);
            $this->debugMsg("SQL: <pre>$dsql</pre>", -1, $line);
        }
        $sth->closeCursor();

        return $result;
    }

    /**
     * Wrapper around msg() but outputs only when debug is enabled
     *
     * @param string|Exception $message
     * @param int $err
     * @param int $line
     */
    protected function debugMsg($message, $err = 0, $line = 0)
    {
        if (!$this->getConf('debug')) return;
        if (is_a($message, 'Exception')) {
            $err = -1;
            $msg = $message->getMessage();
            if (!$line) $line = $message->getLine();
        } else {
            $msg = $message;
        }

        if (defined('DOKU_UNITTEST')) {
            printf("\n%s, %s:%d\n", $msg, __FILE__, $line);
        } else {
            msg('authpdo: ' . $msg, $err, $line, __FILE__);
        }
    }

    /**
     * Check if the given config strings are set
     *
     * @param string[] $keys
     * @return  bool
     * @author  Matthias Grimm <matthiasgrimm@users.sourceforge.net>
     *
     */
    protected function checkConfig($keys)
    {
        foreach ($keys as $key) {
            $params = explode(':', $key);
            $key = array_shift($params);
            $sql = trim($this->getConf($key));

            // check if sql is set
            if (!$sql) return false;
            // check if needed params are there
            foreach ($params as $param) {
                if (!str_contains($sql, ":$param")) return false;
            }
        }

        return true;
    }

    /**
     * create an approximation of the SQL string with parameters replaced
     *
     * @param string $sql
     * @param array $params
     * @param bool $htmlescape Should the result be escaped for output in HTML?
     * @return string
     */
    protected function debugSQL($sql, $params, $htmlescape = true)
    {
        foreach ($params as $key => $val) {
            if (is_int($val)) {
                $val = $this->pdo->quote($val, PDO::PARAM_INT);
            } elseif (is_bool($val)) {
                $val = $this->pdo->quote($val, PDO::PARAM_BOOL);
            } elseif (is_null($val)) {
                $val = 'NULL';
            } else {
                $val = $this->pdo->quote($val);
            }
            $sql = str_replace($key, $val, $sql);
        }
        if ($htmlescape) $sql = hsc($sql);
        return $sql;
    }
}

// vim:ts=4:sw=4:et:
