<?php
/**
*
* This file is part of the phpBB Forum Software package.
*
* @copyright (c) phpBB Limited <https://www.phpbb.com>
* @license GNU General Public License, version 2 (GPL-2.0)
*
* For full copyright and license information, please see
* the docs/CREDITS.txt file.
*
*/

namespace phpbb\db\migration\tool;

/**
* Migration permission management tool
*/
class permission implements \phpbb\db\migration\tool\tool_interface
{
	/** @var \phpbb\auth\auth */
	protected $auth;

	/** @var \auth_admin */
	protected $auth_admin;

	/** @var \phpbb\cache\service */
	protected $cache;

	/** @var \phpbb\db\driver\driver_interface */
	protected $db;

	/** @var string */
	protected $phpbb_root_path;

	/** @var string */
	protected $php_ext;

	/**
	* Constructor
	*
	* @param \phpbb\db\driver\driver_interface $db
	* @param \phpbb\cache\service $cache
	* @param \phpbb\auth\auth $auth
	* @param string $phpbb_root_path
	* @param string $php_ext
	*/
	public function __construct(\phpbb\db\driver\driver_interface $db, \phpbb\cache\service $cache, \phpbb\auth\auth $auth, $phpbb_root_path, $php_ext)
	{
		$this->db = $db;
		$this->cache = $cache;
		$this->auth = $auth;
		$this->phpbb_root_path = $phpbb_root_path;
		$this->php_ext = $php_ext;

		if (!class_exists('auth_admin'))
		{
			include($this->phpbb_root_path . 'includes/acp/auth.' . $this->php_ext);
		}
		$this->auth_admin = new \auth_admin();
	}

	/**
	* {@inheritdoc}
	*/
	public function get_name()
	{
		return 'permission';
	}

	/**
	* Permission Exists
	*
	* Check if a permission (auth) setting exists
	*
	* @param string $auth_option The name of the permission (auth) option
	* @param bool $global True for checking a global permission setting,
	* 	False for a local permission setting
	* @return bool true if it exists, false if not
	*/
	public function exists($auth_option, $global = true)
	{
		if ($global)
		{
			$type_sql = ' AND is_global = 1';
		}
		else
		{
			$type_sql = ' AND is_local = 1';
		}

		$sql = 'SELECT auth_option_id
			FROM ' . ACL_OPTIONS_TABLE . "
			WHERE auth_option = '" . $this->db->sql_escape($auth_option) . "'"
				. $type_sql;
		$result = $this->db->sql_query($sql);

		$row = $this->db->sql_fetchrow($result);
		$this->db->sql_freeresult($result);

		if ($row)
		{
			return true;
		}

		return false;
	}

	/**
	* Permission Add
	*
	* Add a permission (auth) option
	*
	* @param string $auth_option The name of the permission (auth) option
	* @param bool $global True for checking a global permission setting,
	* 	False for a local permission setting
	* @param int|false $copy_from If set, contains the id of the permission from which to copy the new one.
	* @return void
	*/
	public function add($auth_option, $global = true, $copy_from = false)
	{
		if ($this->exists($auth_option, $global))
		{
			return;
		}

		// We have to add a check to see if the !$global (if global, local, and if local, global) permission already exists.  If it does, acl_add_option currently has a bug which would break the ACL system, so we are having a work-around here.
		if ($this->exists($auth_option, !$global))
		{
			$sql_ary = array(
				'is_global'	=> 1,
				'is_local'	=> 1,
			);
			$sql = 'UPDATE ' . ACL_OPTIONS_TABLE . '
				SET ' . $this->db->sql_build_array('UPDATE', $sql_ary) . "
				WHERE auth_option = '" . $this->db->sql_escape($auth_option) . "'";
			$this->db->sql_query($sql);
		}
		else
		{
			if ($global)
			{
				$this->auth_admin->acl_add_option(array('global' => array($auth_option)));
			}
			else
			{
				$this->auth_admin->acl_add_option(array('local' => array($auth_option)));
			}
		}

		// The permission has been added, now we can copy it if needed
		if ($copy_from && isset($this->auth_admin->acl_options['id'][$copy_from]))
		{
			$old_id = $this->auth_admin->acl_options['id'][$copy_from];
			$new_id = $this->auth_admin->acl_options['id'][$auth_option];

			$tables = array(ACL_GROUPS_TABLE, ACL_ROLES_DATA_TABLE, ACL_USERS_TABLE);

			foreach ($tables as $table)
			{
				$sql = 'SELECT *
					FROM ' . $table . '
					WHERE auth_option_id = ' . $old_id;
				$result = $this->db->sql_query($sql);

				$sql_ary = array();
				while ($row = $this->db->sql_fetchrow($result))
				{
					$row['auth_option_id'] = $new_id;
					$sql_ary[] = $row;
				}
				$this->db->sql_freeresult($result);

				if (!empty($sql_ary))
				{
					$this->db->sql_multi_insert($table, $sql_ary);
				}
			}

			$this->auth_admin->acl_clear_prefetch();
		}
	}

	/**
	* Permission Remove
	*
	* Remove a permission (auth) option
	*
	* @param string $auth_option The name of the permission (auth) option
	* @param bool $global True for checking a global permission setting,
	* 	False for a local permission setting
	* @return void
	*/
	public function remove($auth_option, $global = true)
	{
		if (!$this->exists($auth_option, $global))
		{
			return;
		}

		if ($global)
		{
			$type_sql = ' AND is_global = 1';
		}
		else
		{
			$type_sql = ' AND is_local = 1';
		}
		$sql = 'SELECT auth_option_id, is_global, is_local
			FROM ' . ACL_OPTIONS_TABLE . "
			WHERE auth_option = '" . $this->db->sql_escape($auth_option) . "'" .
				$type_sql;
		$result = $this->db->sql_query($sql);
		$row = $this->db->sql_fetchrow($result);
		$this->db->sql_freeresult($result);

		$id = (int) $row['auth_option_id'];

		// If it is a local and global permission, do not remove the row! :P
		if ($row['is_global'] && $row['is_local'])
		{
			$sql = 'UPDATE ' . ACL_OPTIONS_TABLE . '
				SET ' . (($global) ? 'is_global = 0' : 'is_local = 0') . '
				WHERE auth_option_id = ' . $id;
			$this->db->sql_query($sql);
		}
		else
		{
			// Delete time
			$tables = array(ACL_GROUPS_TABLE, ACL_ROLES_DATA_TABLE, ACL_USERS_TABLE, ACL_OPTIONS_TABLE);
			foreach ($tables as $table)
			{
				$this->db->sql_query('DELETE FROM ' . $table . '
					WHERE auth_option_id = ' . $id);
			}
		}

		// Purge the auth cache
		$this->cache->destroy('_acl_options');
		$this->auth->acl_clear_prefetch();
	}

	/**
	 * Check if a permission role exists
	 *
	 * @param string $role_name The role name
	 *
	 * @return int The id of the role if it exists, 0 otherwise
	 */
	public function role_exists($role_name)
	{
		$sql = 'SELECT role_id
			FROM ' . ACL_ROLES_TABLE . "
			WHERE role_name = '" . $this->db->sql_escape($role_name) . "'";
		$result = $this->db->sql_query($sql);
		$role_id = (int) $this->db->sql_fetchfield('role_id');
		$this->db->sql_freeresult($result);

		// Try falling back to searching by role description for standard role titles
		if (!$role_id && preg_match('/ROLE_(?<title>([A-Z]+_?)+)/', $role_name, $matches))
		{
			$role_description = 'ROLE_DESCRIPTION_' . $matches['title'];
			$sql = 'SELECT role_id
				FROM ' . ACL_ROLES_TABLE . "
				WHERE role_description = '" . $this->db->sql_escape($role_description) . "'";
			$result = $this->db->sql_query($sql);
			$role_id = (int) $this->db->sql_fetchfield('role_id');
			$this->db->sql_freeresult($result);
		}

		return $role_id;
	}

	/**
	* Add a new permission role
	*
	* @param string $role_name The new role name
	* @param string $role_type The type (u_, m_, a_)
	* @param string $role_description Description of the new role
	*
	* @return int|null Inserted SQL id or null if role already exists
	*/
	public function role_add($role_name, $role_type, $role_description = '')
	{
		if ($this->role_exists($role_name))
		{
			return null;
		}

		$sql = 'SELECT MAX(role_order) AS max_role_order
			FROM ' . ACL_ROLES_TABLE . "
			WHERE role_type = '" . $this->db->sql_escape($role_type) . "'";
		$this->db->sql_query($sql);
		$role_order = (int) $this->db->sql_fetchfield('max_role_order');
		$role_order = (!$role_order) ? 1 : $role_order + 1;

		$sql_ary = array(
			'role_name'			=> $role_name,
			'role_description'	=> $role_description,
			'role_type'			=> $role_type,
			'role_order'		=> $role_order,
		);

		$sql = 'INSERT INTO ' . ACL_ROLES_TABLE . ' ' . $this->db->sql_build_array('INSERT', $sql_ary);
		$this->db->sql_query($sql);

		return (int) $this->db->sql_nextid();
	}

	/**
	* Update the name on a permission role
	*
	* @param string $old_role_name The old role name
	* @param string $new_role_name The new role name
	* @return void
	* @throws \phpbb\db\migration\exception
	*/
	public function role_update($old_role_name, $new_role_name)
	{
		if (!$this->role_exists($old_role_name))
		{
			throw new \phpbb\db\migration\exception('ROLE_NOT_EXIST', $old_role_name);
		}

		$sql = 'UPDATE ' . ACL_ROLES_TABLE . "
			SET role_name = '" . $this->db->sql_escape($new_role_name) . "'
			WHERE role_name = '" . $this->db->sql_escape($old_role_name) . "'";
		$this->db->sql_query($sql);
	}

	/**
	* Remove a permission role
	*
	* @param string $role_name The role name to remove
	* @return void
	*/
	public function role_remove($role_name)
	{
		if (!($role_id = $this->role_exists($role_name)))
		{
			return;
		}

		// Get the role type
		$sql = 'SELECT role_type
			FROM ' . ACL_ROLES_TABLE . '
			WHERE role_id = ' . (int) $role_id;
		$result = $this->db->sql_query($sql);
		$role_type = $this->db->sql_fetchfield('role_type');
		$this->db->sql_freeresult($result);

		// Get complete auth array
		$sql = 'SELECT auth_option, auth_option_id
			FROM ' . ACL_OPTIONS_TABLE . "
			WHERE auth_option " . $this->db->sql_like_expression($role_type . $this->db->get_any_char());
		$result = $this->db->sql_query($sql);

		$auth_settings = [];
		while ($row = $this->db->sql_fetchrow($result))
		{
			$auth_settings[$row['auth_option']] = ACL_NO;
		}
		$this->db->sql_freeresult($result);

		// Get the role auth settings we need to re-set...
		$sql = 'SELECT o.auth_option, r.auth_setting
			FROM ' . ACL_ROLES_DATA_TABLE . ' r, ' . ACL_OPTIONS_TABLE . ' o
			WHERE o.auth_option_id = r.auth_option_id
				AND r.role_id = ' . (int) $role_id;
		$result = $this->db->sql_query($sql);

		while ($row = $this->db->sql_fetchrow($result))
		{
			$auth_settings[$row['auth_option']] = $row['auth_setting'];
		}
		$this->db->sql_freeresult($result);

		// Get role assignments
		$hold_ary = $this->auth_admin->get_role_mask($role_id);

		// Re-assign permissions
		foreach ($hold_ary as $forum_id => $forum_ary)
		{
			if (isset($forum_ary['users']))
			{
				$this->auth_admin->acl_set('user', $forum_id, $forum_ary['users'], $auth_settings, 0, false);
			}

			if (isset($forum_ary['groups']))
			{
				$this->auth_admin->acl_set('group', $forum_id, $forum_ary['groups'], $auth_settings, 0, false);
			}
		}

		// Remove role from users and groups just to be sure (happens through acl_set)
		$sql = 'DELETE FROM ' . ACL_USERS_TABLE . '
			WHERE auth_role_id = ' . $role_id;
		$this->db->sql_query($sql);

		$sql = 'DELETE FROM ' . ACL_GROUPS_TABLE . '
			WHERE auth_role_id = ' . $role_id;
		$this->db->sql_query($sql);

		$sql = 'DELETE FROM ' . ACL_ROLES_DATA_TABLE . '
			WHERE role_id = ' . $role_id;
		$this->db->sql_query($sql);

		$sql = 'DELETE FROM ' . ACL_ROLES_TABLE . '
			WHERE role_id = ' . $role_id;
		$this->db->sql_query($sql);

		$this->auth->acl_clear_prefetch();
	}

	/**
	* Permission Set
	*
	* Allows you to set permissions for a certain group/role
	*
	* @param string $name The name of the role/group
	* @param string|array $auth_option The auth_option or array of
	* 	auth_options you would like to set
	* @param string $type The type (role|group)
	* @param bool $has_permission True if you want to give them permission,
	* 	false if you want to deny them permission
	* @return void
	* @throws \phpbb\db\migration\exception
	*/
	public function permission_set($name, $auth_option, $type = 'role', $has_permission = true)
	{
		if (!is_array($auth_option))
		{
			$auth_option = array($auth_option);
		}

		$new_auth = array();
		$sql = 'SELECT auth_option_id
			FROM ' . ACL_OPTIONS_TABLE . '
			WHERE ' . $this->db->sql_in_set('auth_option', $auth_option);
		$result = $this->db->sql_query($sql);
		while ($row = $this->db->sql_fetchrow($result))
		{
			$new_auth[] = (int) $row['auth_option_id'];
		}
		$this->db->sql_freeresult($result);

		$type = (string) $type; // Prevent PHP bug.
		if (empty($new_auth) || !in_array($type, ['role','group']))
		{
			return;
		}

		$current_auth = array();

		switch ($type)
		{
			case 'role':
				if (!($role_id = $this->role_exists($name)))
				{
					throw new \phpbb\db\migration\exception('ROLE_NOT_EXIST', $name);
				}

				$sql = 'SELECT auth_option_id, auth_setting
					FROM ' . ACL_ROLES_DATA_TABLE . '
					WHERE role_id = ' . $role_id;
				$result = $this->db->sql_query($sql);
				while ($row = $this->db->sql_fetchrow($result))
				{
					$current_auth[$row['auth_option_id']] = $row['auth_setting'];
				}
				$this->db->sql_freeresult($result);
			break;

			case 'group':
				$sql = 'SELECT group_id
					FROM ' . GROUPS_TABLE . "
					WHERE group_name = '" . $this->db->sql_escape($name) . "'";
				$this->db->sql_query($sql);
				$group_id = (int) $this->db->sql_fetchfield('group_id');

				if (!$group_id)
				{
					throw new \phpbb\db\migration\exception('GROUP_NOT_EXIST', $name);
				}

				// If the group has a role set for them we will add the requested permissions to that role.
				$sql = 'SELECT auth_role_id
					FROM ' . ACL_GROUPS_TABLE . '
					WHERE group_id = ' . $group_id . '
						AND auth_role_id <> 0
						AND forum_id = 0';
				$this->db->sql_query($sql);
				$role_id = (int) $this->db->sql_fetchfield('auth_role_id');
				if ($role_id)
				{
					$sql = 'SELECT role_name, role_type
						FROM ' . ACL_ROLES_TABLE . '
						WHERE role_id = ' . $role_id;
					$this->db->sql_query($sql);
					$role_data = $this->db->sql_fetchrow();
					if (!$role_data)
					{
						throw new \phpbb\db\migration\exception('ROLE_ASSIGNED_NOT_EXIST', $name, $role_id);
					}

					$role_name = $role_data['role_name'];
					$role_type = $role_data['role_type'];

					// Filter new auth options to match the role type: a_ | f_ | m_ | u_
					// Set new auth options to the role only if options matching the role type were found
					$auth_option = array_filter($auth_option,
						function ($option) use ($role_type)
						{
							return strpos($option, $role_type) === 0;
						}
					);

					if (count($auth_option))
					{
						$this->permission_set($role_name, $auth_option, 'role', $has_permission);
						return;
					}
				}

				$sql = 'SELECT auth_option_id, auth_setting
					FROM ' . ACL_GROUPS_TABLE . '
					WHERE group_id = ' . $group_id;
				$result = $this->db->sql_query($sql);
				while ($row = $this->db->sql_fetchrow($result))
				{
					$current_auth[$row['auth_option_id']] = $row['auth_setting'];
				}
				$this->db->sql_freeresult($result);
			break;
		}

		$sql_ary = $auth_update_list = [];
		$table = $type == 'role' ? ACL_ROLES_DATA_TABLE : ACL_GROUPS_TABLE;
		foreach ($new_auth as $auth_option_id)
		{
			if (!isset($current_auth[$auth_option_id]))
			{
				$sql_ary[] = [
					$type . '_id'		=> ${$type . '_id'},
					'auth_option_id'	=> $auth_option_id,
					'auth_setting'		=> (int) $has_permission,
				];
			}
			else
			{
				$auth_update_list[] = $auth_option_id;
			}
		}
		$this->db->sql_multi_insert($table, $sql_ary);

		if (count($auth_update_list))
		{
			$sql = 'UPDATE ' . $table . '
				SET auth_setting = ' . (int) $has_permission . '
				WHERE ' . $this->db->sql_in_set('auth_option_id', $auth_update_list) . '
					AND ' . $type . '_id = ' .  (int) ${$type . '_id'};
			$this->db->sql_query($sql);
		}

		$this->auth->acl_clear_prefetch();
	}

	/**
	* Permission Unset
	*
	* Allows you to unset (remove) permissions for a certain group/role
	*
	* @param string $name The name of the role/group
	* @param string|array $auth_option The auth_option or array of
	* 	auth_options you would like to set
	* @param string $type The type (role|group)
	* @return void
	* @throws \phpbb\db\migration\exception
	*/
	public function permission_unset($name, $auth_option, $type = 'role')
	{
		if (!is_array($auth_option))
		{
			$auth_option = array($auth_option);
		}

		$to_remove = array();
		$sql = 'SELECT auth_option_id
			FROM ' . ACL_OPTIONS_TABLE . '
			WHERE ' . $this->db->sql_in_set('auth_option', $auth_option);
		$result = $this->db->sql_query($sql);
		while ($row = $this->db->sql_fetchrow($result))
		{
			$to_remove[] = (int) $row['auth_option_id'];
		}
		$this->db->sql_freeresult($result);

		if (empty($to_remove))
		{
			return;
		}

		$type = (string) $type; // Prevent PHP bug.

		switch ($type)
		{
			case 'role':
				if (!($role_id = $this->role_exists($name)))
				{
					throw new \phpbb\db\migration\exception('ROLE_NOT_EXIST', $name);
				}

				$sql = 'DELETE FROM ' . ACL_ROLES_DATA_TABLE . '
					WHERE ' . $this->db->sql_in_set('auth_option_id', $to_remove) . '
						AND role_id = ' . (int) $role_id;
				$this->db->sql_query($sql);
			break;

			case 'group':
				$sql = 'SELECT group_id
					FROM ' . GROUPS_TABLE . "
					WHERE group_name = '" . $this->db->sql_escape($name) . "'";
				$this->db->sql_query($sql);
				$group_id = (int) $this->db->sql_fetchfield('group_id');

				if (!$group_id)
				{
					throw new \phpbb\db\migration\exception('GROUP_NOT_EXIST', $name);
				}

				// If the group has a role set for them we will remove the requested permissions from that role.
				$sql = 'SELECT auth_role_id
					FROM ' . ACL_GROUPS_TABLE . '
					WHERE group_id = ' . $group_id . '
						AND auth_role_id <> 0';
				$this->db->sql_query($sql);
				$role_id = (int) $this->db->sql_fetchfield('auth_role_id');
				if ($role_id)
				{
					$sql = 'SELECT role_name
						FROM ' . ACL_ROLES_TABLE . '
						WHERE role_id = ' . $role_id;
					$this->db->sql_query($sql);
					$role_name = $this->db->sql_fetchfield('role_name');
					if (!$role_name)
					{
						throw new \phpbb\db\migration\exception('ROLE_ASSIGNED_NOT_EXIST', $name, $role_id);
					}

					$this->permission_unset($role_name, $auth_option, 'role');
					return;
				}

				$sql = 'DELETE FROM ' . ACL_GROUPS_TABLE . '
					WHERE ' . $this->db->sql_in_set('auth_option_id', $to_remove);
				$this->db->sql_query($sql);
			break;
		}

		$this->auth->acl_clear_prefetch();
	}

	/**
	* {@inheritdoc}
	*/
	public function reverse()
	{
		$arguments = func_get_args();
		$original_call = array_shift($arguments);

		$call = false;
		switch ($original_call)
		{
			case 'add':
				$call = 'remove';
			break;

			case 'remove':
				$call = 'add';
			break;

			case 'permission_set':
				$call = 'permission_unset';
			break;

			case 'permission_unset':
				$call = 'permission_set';
			break;

			case 'role_add':
				$call = 'role_remove';
			break;

			case 'role_remove':
				$call = 'role_add';
			break;

			case 'role_update':
				// Set to the original value if the current value is what we compared to originally
				$arguments = array(
					$arguments[1],
					$arguments[0],
				);
			break;

			case 'reverse':
				// Reversing a reverse is just the call itself
				$call = array_shift($arguments);
			break;
		}

		return $call ? call_user_func_array(array(&$this, $call), $arguments) : null;
	}
}
