<?php
/*
 * PiAnoS (Picture Annotation System), A fingerprint examiner training tool
 * Copyright (C) 2011-2021 University of Lausanne, Switzerland
 * https://ips-labs.unil.ch/pianos
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program. If not, see http://www.gnu.org/licenses/.
 */
require_once ( "unil/Database.php" );
class ValidationError extends Exception
{
    function __construct( $errors )
    {
        $message = "Validation error:\n";
        foreach( $errors as $err )
            $message .= $err[ 'message' ] . "\n";
        parent::__construct( $message );
        $this->errors = $errors;
    }
    function getErrors()
    {
        return $this->errors;
    }
    private $errors;
}
class AppDB extends Database
{
    function __construct()
    {
        parent::__construct();
        $this->validation = array ();
        $this->app = Application::instance();
    }
    
    // checkAdmin() *must* be called at the beginning of each function requiring
    // administration privileges. This opt-out scheme is less secure than an opt-in
    // mechanism where only non-admin functions can be called, except if a certain
    // flag is set on the function. The opt-in mechanism could be, on top of my head:
    // - have RemoteProcedureCall check in a catalog functions that are accessible
    // to non-admin users
    // => tedious, slow and not guaranteed to work: these methods could be called from
    // any PHP code on the server side, defeating the check in RemoteProcedureCall
    // - "inject" a function call at the beginning of each method here.
    // => how to do that with PHP ?
    // - split this class into 2 distinct classes: one for the users and one for the
    // admins (possibly, a third parent class offering shared methods)
    // => need redesign + problem when the application starts: we need to interrogate
    // the DB to know if a user is admin or not, so we'd have to first create a
    // very simplistic database class that only knows how to log-in, then dispose
    // of the connection, and create a new connection with the appropriate class
    // (this solution is in use in e.g. PiAnoS 3.x)
    
    // So, for the time being, call $this->checkAdmin() for all functions that can be
    // called only by admins (almost all, actually).
    protected function checkAdmin()
    {
        if( ! $_SESSION[ 'user' ][ 'isadmin' ] )
            throw new Exception( "Access denied" );
    }
    
    // Objects management
    public function getAnyAdmin()
    {
        $res = $this->query( "SELECT person.id FROM person,membership,pgroup WHERE person.id = membership.person AND membership.pgroup = pgroup.id AND pgroup.tutors IS true ORDER BY id ASC LIMIT 1;" );
        if( count( $res ) != 1 )
            throw new Exception( "No admin found" );
        return ( int ) $res[ 0 ][ 'id' ];
    }
    public function setObjectOwnership( $objID, $userID = NULL )
    {
        if( $userID === NULL || ! $_SESSION[ 'user' ][ 'isadmin' ] )
        {
            // Non-admins can only "relinquish" ownership of an object - that is, they can't
            // give it to anyone else but an admin.
            $userID = $this->getAnyAdmin();
        }
        
        $this->query( "UPDATE object SET creator = " . ( int ) $userID . " WHERE id = " . ( int ) $objID . ";" );
    }
    
    // User management
    public function createUserStub( $username, $group, $withPassword = false )
    {
        $this->checkAdmin();
        
        $nameEsc = "E'" . pg_escape_string( $username ) . "'";
        
        if( mb_strlen( $username, 'utf8' ) < 2 )
            $this->pushValidationError( 'username', _( 'Username too short' ) );
        $res = $this->query( "SELECT count(*) FROM person WHERE username = " . $nameEsc . ";" );
        if( ( int ) $res[ 0 ][ 'count' ] !== 0 )
            $this->pushValidationError( 'username', _( 'Username already in use' ) );
        $this->flushValidationErrors();
        
        if( $withPassword )
        {
            $pwgen = Application::get( 'pwgen_path' );
            if( ! $pwgen )
                throw new Exception( "Cannot create a user with a random password: the 'pwgen_path' configuration setting has not been defined" );
            
            $command = escapeshellcmd( $pwgen );
            $output = '';
            $retval = 0;
            $lastline = exec( $command, $output, $retval );
            
            if( $retval !== 0 || strlen( $lastline ) == 0 )
                throw new Exception( "The command '" . $command . "' failed. Return code: " . $retval );
            
            $password = trim( $lastline );
            $passwordStored = md5( $username . ':' . $password );
        } else
        {
            $password = false;
            $passwordStored = 'will never match';
        }
        
        $this->beginTransaction();
        
        $res = $this->query( "INSERT INTO person (username,name,password,language,active,suitability_approach) VALUES (" . $nameEsc . "," . $nameEsc . ",E'" . pg_escape_string( $passwordStored ) . "',(SELECT id FROM language ORDER BY id LIMIT 1),'" . ( $withPassword ? 'true': 'false' ) . "',2) RETURNING id;" );
        $userID = $res[ 0 ][ 'id' ];
        
        $this->query( "INSERT INTO membership(pgroup,person) VALUES (" . ( int ) $group . "," . ( int ) $userID . ");" );
        
        $this->commitTransaction();
        
        return array (
                $userID, 
                $password 
        );
    }

    public function createMultiUser( $usernames, $folder )
    {
        $this->checkAdmin();
        $ret = Array();
        
        foreach( json_decode( $usernames ) as $user )
        {
            $password = $this->userDefaultPassword( $user );
            $this->createUser( $user, $user, $password, $folder );
            $ret[] = Array(
                'username' => $user,
                'password' => $password
            );
        }
        return $ret;
    }
    
    public function userDefaultPassword( $username )
    {
        $salt = Application::get( "usernameDefaultPasswordSalt" );
        $h = hash( "sha512", $username . "::" . $salt );
        return substr( $h, 10, 10 );
    }
    public function getDefaultPasswordForUsers( $folder = null )
    {
        $this->checkAdmin();
        $userlist = $this->query( "SELECT username FROM person LEFT JOIN membership ON person.id = membership.person WHERE membership.pgroup = E'" . pg_escape_string( $folder ) . "';" );
        
        $ret = Array();
        foreach( $userlist as $user ){
            $ret[] = Array(
                'username' => $user[ "username" ],
                'password' => $this->userDefaultPassword( $user[ "username" ] )
            );
        }
        return $ret;
    }

    public function createUser( $username, $name, $password, $group )
    {
        $this->checkAdmin();
        $ret = $this->createUserStub( $username, $group, false );
        $uid = $ret[ 0 ];
        $this->query( "UPDATE person SET name=E'" . pg_escape_string( $name ) . "',password=E'" . pg_escape_string( md5( $username . ':' . $password ) ) . "', active=true, must_change_pwd=true WHERE id = " . $uid . ";" );
    }
    public function updateUserPassword( $uid, $name, $newpassword )
    {
        $this->checkAdmin();
        
        $res = $this->query( "SELECT * FROM person WHERE id = " . ( int ) $uid . " AND username = E'" . pg_escape_string( $name ) . "';" );
        if( count( $res ) )
        {
            $this->query( "UPDATE person SET password=E'" . pg_escape_string( $newpassword ) . "', must_change_pwd=true WHERE id = " . ( int ) $uid . ";" );
        } else {
            throw new Exception( "Invalid user parameters" );
        }
    }
    public function getUser( $id, $withAssignments = false )
    {
        $this->checkAdmin();
        
        $res = $this->query( "SELECT id,username,name,active,email,last_login FROM person WHERE id = " . ( int ) $id . ";" );
        if( count( $res ) != 1 )
            throw new Exception( "Access denied" );
        
        $user = $res[ 0 ];
        $user[ 'active' ] = $user[ 'active' ] === 't';
        
        $res = $this->query( "SELECT pgroup.* FROM pgroup,membership WHERE pgroup.id = membership.pgroup AND membership.person = " . ( int ) $id . ";" );
        $user[ 'groups' ] = $res;
        
        $user[ 'tutor' ] = false;
        foreach( $user[ 'groups' ] as &$g )
        {
            $user[ 'tutor' ] = $user[ 'tutor' ] || $g[ 'tutors' ] === 't';
        }
        
        if( $withAssignments )
        {
            $this->checkAdmin();
            $user[ 'assignments' ] = $this->getUserAssignments( $user[ 'id' ] );
        }
        
        return $user;
    }
    public function saveUser( $id, $name, $email, $groups )
    {
        $this->checkAdmin();
        
        require_once ( "lib/is_email.php" );
        $email = trim( $email );
        if( strlen( $email ) && ! is_email( $email ) )
            $this->pushValidationError( 'email', _( 'Not a valid email address' ) );
        $this->flushValidationErrors();
        
        $this->beginTransaction();
        $res = $this->query( "UPDATE person SET name = E'" . pg_escape_string( $name ) . "', email = E'" . pg_escape_string( $email ) . "' WHERE id = " . ( int ) $id . " RETURNING id;" );
        if( count( $res ) != 1 || ( int ) $res[ 0 ][ 'id' ] != ( int ) $id )
            throw new Exception( "Access denied" );
        $this->query( "DELETE FROM membership WHERE person = " . ( int ) $id . ";" );
        foreach( $groups as $group )
            $this->query( "INSERT INTO membership(person,pgroup) VALUES (" . ( int ) $id . "," . ( int ) $group . ");" );
        $this->commitTransaction();
    }
    public function isPasswordValid( $hashedPassword )
    {
        // The strength of the password can only be checked on the client side: we'll
        // never see on this side the actual password. The best we can do is check that
        // the password is not an empty string - heck, we can't even be sure it's not a
        // single space character, or a multiple of them, or the string 'pwd'.
        // Since that's awfully unuseful, we only check that the provided password is
        // a valid md5 sum.
        return preg_match( '/^[a-zA-Z0-9]{32}$/', $hashedPassword );
    }
    public function setUserPassword( $id, $newPassword )
    {
        $this->checkAdmin();
        
        if( ! $this->isPasswordValid( $newPassword ) )
            $this->pushValidationError( 'newPassword', _( 'Not a valid password' ) );
        $this->flushValidationErrors();
        
        $res = $this->query( "UPDATE person SET password = E'" . pg_escape_string( $newPassword ) . "' WHERE id = " . ( int ) $id . " RETURNING id;" );
        if( count( $res ) != 1 || ( int ) $res[ 0 ][ 'id' ] != ( int ) $id )
            throw new Exception( "Access denied" );
    }
    public function setUserLanguage( $id, $langShort )
    {
        $res = $this->query( "UPDATE person SET language = (SELECT id FROM language WHERE short = E'" . pg_escape_string( $langShort ) . "') WHERE id = " . ( int ) $id . " RETURNING id;" );
        if( count( $res ) != 1 || ( int ) $res[ 0 ][ 'id' ] != ( int ) $id )
            throw new Exception( "Access denied" );
    }
    public function toggleUserActive( $id )
    {
        $this->checkAdmin();
        
        $res = $this->query( "UPDATE person SET active = NOT active WHERE id = " . ( int ) $id . " RETURNING active;" );
        if( count( $res ) != 1 )
            throw new Exception( "Access denied" );
        return $res[ 0 ][ 'active' ] === 't';
    }
    public function getUsers( $groupID = NULL, $showInactive = false )
    {
        $this->checkAdmin();
        
        $res = $this->query( "
			SELECT 
				person.id,
				person.username,
				person.name,
				person.active,
				person.email,
				to_array(pgroup.id) AS groupids,
				to_array(pgroup.name) AS groups
			FROM person,membership,pgroup
			WHERE person.id = membership.person
				AND membership.pgroup = pgroup.id
				" . ( $groupID ? 'AND pgroup.id = ' . ( int ) $groupID: '' ) . "
				" . ( $showInactive ? '': 'AND person.active IS true' ) . "
			GROUP BY
				person.id,
				person.username,
				person.name,
				person.active,
				person.email
			ORDER BY person.name ASC;
		" );
        foreach( $res as &$row )
        {
            $row[ 'groupids' ] = $this->intSqlArrayToPhp( $row[ 'groupids' ] );
            $row[ 'groups' ] = $this->stringSqlArrayToPhp( $row[ 'groups' ] );
            $row[ 'active' ] = $row[ 'active' ] === 't';
        }
        return $res;
    }
    public function deleteUser( $id )
    {
        $this->checkAdmin();
        
        $this->beginTransaction();
        
        $this->app->callPlugins( 'deleteUser', $id );
        
        $res = $this->query( "SELECT assignment.id FROM assignment,object WHERE assignment.id = object.id AND object.creator = " . ( int ) $id . ";" );
        foreach( $res as $row )
        {
            $this->deleteAssignment( $row[ 'id' ] );
        }
        
        $res = $this->query( "DELETE FROM membership WHERE person = " . ( int ) $id . ";" );
        $res = $this->query( "DELETE FROM security WHERE object = " . ( int ) $id . ";" );
        $res = $this->query( "DELETE FROM security WHERE trustee = " . ( int ) $id . ";" );
        
        $res = $this->query( "SELECT id FROM folder WHERE personalof = " . ( int ) $id . ";" );
        
        if( sizeof( $res ) != 0 )
        {
            $this->query( "DELETE FROM exercise WHERE folder = " . ( int ) $res[ 0 ][ 'id' ] . ";" );
            $this->query( "DELETE FROM folder WHERE id = " . ( int ) $res[ 0 ][ 'id' ] . ";" );
            $this->query( "DELETE FROM object WHERE id = " . ( int ) $res[ 0 ][ 'id' ] . ";" );
        }
        
        $res = $this->query( "DELETE FROM person WHERE id = " . ( int ) $id . ";" );
        $res = $this->query( "DELETE FROM trustee WHERE id = " . ( int ) $id . ";" );
        $res = $this->query( "DELETE FROM object WHERE id = " . ( int ) $id . ";" );
        
        $this->commitTransaction();
    }
    public function getUserDataSummary( $id )
    {
        $this->checkAdmin();
        
        $res = $this->query( "SELECT count(assignment.*) FROM assignment,object WHERE assignment.id = object.id AND object.creator = " . ( int ) $id . ";" );
        
        return array (
                'assignments' => ( int ) $res[ 0 ][ 'count' ] 
        );
    }
    public function updateUser( $id, $name = NULL, $group = NULL, $active = NULL )
    {
        $this->checkAdmin();
        
        $token = $this->beginTransactionIf();
        
        if( $name )
        {
            $this->query( "UPDATE person SET name = E'" . pg_escape_string( $name ) . "' WHERE id = " . ( int ) $id . ";" );
        }
        
        if( $group )
        {
            $this->query( "DELETE FROM membership WHERE person = " . ( int ) $id . ";" );
            $this->query( "INSERT INTO membership(person,pgroup) VALUES (" . ( int ) $id . "," . ( int ) $group . ");" );
        }
        
        if( is_bool( $active ) )
        {
            $this->query( "UPDATE person SET active = " . ( $active ? 'true': 'false' ) . " WHERE id = " . ( int ) $id . ";" );
        }
        
        $this->commitTransactionIf( $token );
    }
    
    // User groups management
    public function getUserGroups( $noAdmins = false, $countUsers = false, $showInactive = false )
    {
        $this->checkAdmin();
        $select = 'pgroup.id,pgroup.name';
        $from = 'pgroup';
        $group = '';
        if( $countUsers )
        {
            $select .= ',count(membership.*) AS nummembers';
            $from .= ' LEFT OUTER JOIN membership ON pgroup.id = membership.pgroup';
            $group .= 'GROUP BY pgroup.id,pgroup.name,pgroup.tutors';
        }
        $where = '';
        if( $noAdmins || ( $countUsers && ! $showInactive ) )
        {
            $wherev = array ();
            if( $noAdmins )
                $wherev[] = "pgroup.tutors IS false";
            if( $countUsers && ! $showInactive )
            {
                $from .= " LEFT OUTER JOIN person ON membership.person = person.id AND person.active IS true";
            }
            if( count( $wherev ) )
                $where = 'WHERE ' . implode( ' AND ', $wherev );
        }
        $res = $this->query( "SELECT " . $select . " FROM " . $from . " " . $where . " " . $group . " ORDER BY pgroup.name ASC;" );
        return $res;
    }
    public function createUserGroup( $name )
    {
        $this->checkAdmin();
        
        $res = $this->query( "INSERT INTO pgroup (name) 
						VALUES (E'" . pg_escape_string( $name ) . "') RETURNING id;" );
        return $res[ 0 ][ 'id' ];
    }
    public function setUserGroupName( $id, $name )
    {
        $this->checkAdmin();
        
        $res = $this->query( "UPDATE pgroup SET name = E'" . pg_escape_string( $name ) . "' WHERE id = " . ( int ) $id . " RETURNING id;" );
        return ( int ) $res[ 0 ][ 'id' ];
    }
    public function deleteUserGroup( $id )
    {
        $this->checkAdmin();
        
        // TODO: find a better way to manage the deletion of non-empty user groups
        // (If we allow it, this is not a big problem since the users will still exist
        // but the UI will not show them)
        // For the moment, such deletion is forbidden
        $res = $this->query( "SELECT count(*) FROM membership WHERE pgroup = " . ( int ) $id . ";" );
        if( ( int ) $res[ 0 ][ 'count' ] !== 0 )
            $this->pushValidationError( 'id', _( 'Cannot delete non-empty user group' ) );
        $this->flushValidationErrors();
        
        $this->beginTransaction();
        $res = $this->query( "DELETE FROM membership WHERE pgroup = " . ( int ) $id . ";" );
        $res = $this->query( "DELETE FROM security WHERE object = " . ( int ) $id . ";" );
        $res = $this->query( "DELETE FROM security WHERE trustee = " . ( int ) $id . ";" );
        $res = $this->query( "DELETE FROM pgroup WHERE id = " . ( int ) $id . ";" );
        $res = $this->query( "DELETE FROM trustee WHERE id = " . ( int ) $id . ";" );
        $res = $this->query( "DELETE FROM object WHERE id = " . ( int ) $id . ";" );
        $this->commitTransaction();
    }
    public function getUserGroup( $id )
    {
        $this->checkAdmin();
        
        $res = $this->query( "SELECT id,name,tutors FROM pgroup WHERE id = " . ( int ) $id . ";" );
        if( count( $res ) != 1 )
            throw new Exception( "Access denied" );
        $pgroup = $res[ 0 ];
        $pgroup[ 'tutors' ] = $pgroup[ 'tutors' ] === 't';
        return $pgroup;
    }
    public function saveUserGroup( $id, $name, $tutors = false )
    {
        $this->checkAdmin();
        
        $res = $this->query( "UPDATE pgroup SET name = E'" . pg_escape_string( $name ) . "',tutors = " . ( $tutors ? 'true': 'false' ) . " WHERE id = " . ( int ) $id . " RETURNING id;" );
        if( count( $res ) != 1 || ( int ) $res[ 0 ][ 'id' ] != ( int ) $id )
            throw new Exception( "Access denied" );
    }
    
    // Languages
    public function getLanguages()
    {
        $res = $this->query( "SELECT * FROM language ORDER BY id ASC;" );
        return $res;
    }
    
    // Private functions
    protected function pushValidationError( $field, $message )
    {
        $this->validation[] = array (
                'field' => $field, 
                'message' => $message 
        );
    }
    protected function flushValidationErrors()
    {
        if( count( $this->validation ) )
            throw new ValidationError( $this->validation );
    }
    private $validation;
    private $app;
}
;
