<?php
/*
PiAnoS (Picture Annotation System), A fingerprint examiner training tool
Copyright (C) 2011-2012 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 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 WHERE id = ".$uid.";");
	}
	
	public function getUser($id, $withAssignments=false)
	{
		$this->checkAdmin();
		
		$res = $this->query("SELECT id,username,name,active,email 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)
		{
			$g['tutors'] = $g['tutors'] === 't';
			if ($g['tutors']) $user['tutor'] = true;
		}
		
		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("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;
};
