<?php
/**
 * Cms_security.php
 *
 * Secuirty class for all the templates. This was done after the unit template implementation and then we added
 * goal and survey which created a lot of security holes since it wasn't designed to support other than units.
 * This implementation will tighten the template secuirty regradeless of template type. This is intended to be
 * used in the Unitmain and unit/template objects level therefore we can handle all template types.
 *
 * The idea is that if you can see the editing page or the edit/delete/new/sort buttons
 * then you should be able to write to the related data. Basically, we piggy back on the
 * logic of how a template page was generated instead of having the same logic repeated
 * for security. I am sure some sophisticated high-security systems use the same concept
 * but this is open source for ed, no such thing as too good :).
 * This relies on CI session for storage.
 *
 * Note: All CI library objects are like singletons so this is implemented
 * 	   with the assumption there is always only one object per request. OOP...
 *
 * Please tell us if you find new security holes. We do know about the atomic level issue...
 *
 * @author $Author: dtong $
 * @version $Id: Cms_security.php,v 1.7 2011/05/20 11:09:46 dtong Exp $
 * @copyright Copyright (c) 2011, Tiller Software Co., Ltd.
*/

class Cms_security {
	/*
	 * 2000 tokens should only take about 5-6K session DB storage after compression
	 * This should be enough unless we config the app dramatically to allow many types with non-full-security access
	 * If we don't have to worry users to use 2 browser windows then we don't have to set a high number like this...
	 * Super users and all special system users have full permissions therefore they don't take up much space and
	 * users with limited permissions don't have access to many resources anyway so 2000 should be more than enough.
	 */
	const MAX_TOKEN_COUNT = 2000;

	private $sep = '*';
	//Stores the security tokens
	private $tokens = array();
	private $token_count = 0;
	private $CI;
	//The security data object
	private $dataobj = NULL;
	//Tells to save session or not
	private $dirty_flag = FALSE;
	//Tells user has full security or not so we can just use one token for the whole template page
	private $full_security = NULL;
	private $skip_error_msg = FALSE;
	private $orig_data = FALSE;

	function __construct() {
		$this->CI = & get_instance();
		$tmp = $this->CI->session->userdata(CMS_SESSION_TEMPLATE_SECURITY);
		if ( !empty($tmp) ) {
			$tmp = osa_decompress($tmp);
			if ( is_array($tmp) ) {
				$this->tokens = & $tmp;
				$this->token_count = count($tmp);
			}
			else {
				osa_errorlog(__METHOD__ . ' - Failed to decompress secuirty tokens.');
				$this->show_error('gen_security_error');
			}
		}
		$this->dataobj = new cms_security_data_class();
		$this->CI->load->library('cms/cms_security_type');
	}

	//This is a major system class so protect it from lazy coders...
	function __set($var, $value) {
		echo __METHOD__ . " - You are not allowed to set undeclared class property '$var', bye bye.";
		exit;
	}

	//templateid is ubd, myp, goal, course_desc, site_resources etc.
	//$courseid - This doesn't have to be course id but it is a core id of a data structure
	//$unitid - This can be goalid, surveyid etc.
	function init($action, $templateid, $unitid, $courseid=FALSE) {
		$this->full_security = NULL;
		$this->dataobj->init();
		//$unitid or $courseid can be zero
		if ( !$this->valid_action($action) || empty($templateid) ||
				!osa_is_int($unitid) || ($courseid !== FALSE && !osa_is_int($unitid)) ) {
			osa_errorlog(__METHOD__ . ' Bad input params', array($action, $templateid, $unitid, $courseid));
			$this->show_error('gen_security_bad_param_error');
			return FALSE;
		}
		$this->full_security = NULL;
		$this->dataobj->reset($action, $templateid, $unitid, $courseid);
		//Save the orginal data for the very first call to init()
		$this->set_orig_object();
	}

	//Switch back to the orignal data when the first time the init() function is called.
	function switch2original() {
		if ( is_object($this->orig_data) ) {
			$this->full_security = $this->orig_data->full_security;
			$this->dataobj = $this->orig_data->dataobj;
		}
	}

	//Adds a security record/token
	//$template_id is ubd, myp, pyp, goal etc.
	//$courseid is course id or 0 for most non-unit resources
	//$res_unitid can be unitid, goalid. surveyid but we don't really care
	//$type is the template type number like assessment, teacher eq etc.
	//Need to add resource id for atomic level check
	//function add($template_id, $courseid, $res_unitid, $type=FALSE) {
	function add($action, $type=FALSE, $resourceid=FALSE) {
		if ( !$this->valid_action($action) || ($type !== FALSE && !osa_is_int($type) && !is_array($type)) ||
			  ($resourceid !== FALSE && !osa_is_int($resourceid)) ) {
			osa_errorlog(__METHOD__ . ' Bad input params', array($action, $type, $resourceid));
			$this->show_error('gen_security_bad_param_error');
			return FALSE;
		}

		$this->set('action', $action);
		$default_key = $this->default_key();
		if ( isset($this->tokens[$default_key]) ) {
			//This covers all types so just return
			return;
		}
		if ( is_array($type) ) {
			//Type can be array with multiple types, this is for sorting multiple resource types
			//Add each individual type
			//TODO: resourceid is ignored here...
			foreach ($type as $typeid) {
				//$typeid = cms_security_data_class::convert_value($typeid);
				if ( $resourceid !== FALSE ) {
					$key = "{$default_key}{$this->sep}{$typeid}{$this->sep}{$resourceid}";
				}
				else {
				$key = "{$default_key}{$this->sep}{$typeid}";
			}
				$this->add_token($action, $key);
			}
		}
		elseif ( $resourceid === FALSE ) {
			if ( $type !== FALSE ) {
				//$type = cms_security_data_class::convert_value($type);
				$key = "{$default_key}{$this->sep}{$type}";
			}
			else {
				$key = $default_key;
			}
			$this->add_token($action, $key);
		}
		else {
			//TODO: This will not work with type being an array...
			/*
			$key = "{$default_key}{$this->sep}" . cms_security_data_class::convert_value($type) . $this->sep .
					 cms_security_data_class::convert_value($resourceid); */
			$key = "{$default_key}{$this->sep}" . $type . $this->sep . $resourceid;
			$this->add_token($action, $key);
		}
		//Shrink the token array if it grows too big
		while ( $this->token_count > self::MAX_TOKEN_COUNT ) {
			array_shift($this->tokens);
			$this->token_count--;
		}
	}

	//Finds the security token and returns true if there is one matching the params else returns FALSE.
	function validate($action, $template_id, $courseid, $res_unitid, $type=FALSE, $resourceid=FALSE) {
		if ( !$this->valid_action($action) || (!osa_is_int($courseid) && !($courseid != 0) ) ||
			  !osa_is_int($res_unitid) || ($type !== FALSE && !osa_is_int($type)) ||
			  ($resourceid && !osa_is_int($resourceid)) ) {
			osa_errorlog(__METHOD__ . ' Bad input params', array($action, $template_id, $courseid, $res_unitid, $type, $resourceid));
			//$this->show_error('gen_security_bad_param_error');
			return FALSE;
		}

		if ( !$template_id ) {
			$template_id = CMS_TEMPLATE_DEFAULT;
		}
		/*
		$action = cms_security_data_class::convert_value($action);
		$template_id = cms_security_data_class::convert_value($template_id);
		$courseid = cms_security_data_class::convert_value($courseid);
		$res_unitid = cms_security_data_class::convert_value($res_unitid);
		$type = cms_security_data_class::convert_value($type);
		$resourceid = cms_security_data_class::convert_value($resourceid);
		*/
		$key = $this->default_key($action, $template_id, $courseid, $res_unitid);
		$extra_key = FALSE;
		if ( $action != CMS_ACTION_EDIT ) {
			//Since edit can read too so check edit if current action is not edit
			//Curently, there are only read and edit but if more action gets added later than
			//modify this. Named the variable $extra_key so not only limit to edit only
			$extra_key = $this->default_key(CMS_ACTION_EDIT, $template_id, $courseid, $res_unitid);
		}
		if ( isset($this->tokens[$key]) || ($extra_key && isset($this->tokens[$extra_key])) ) {
			//This means for all resource types matching the condition
			return TRUE;
		}
		if ( $type !== FALSE ) {
			if ( isset($this->tokens["{$key}{$this->sep}{$type}"]) ||
				  ($extra_key && isset($this->tokens["{$extra_key}{$this->sep}{$type}"])) ) {
				return TRUE;
			}
		}
		if ( $resourceid !== FALSE ) {
			if ( isset($this->tokens["{$key}_{$type}{$this->sep}{$resourceid}"]) ||
				($extra_key && isset($this->tokens["{$extra_key}_{$type}{$this->sep}{$resourceid}"])) ) {
					return TRUE;
			}
		}
		//No matches at all
		return FALSE;
	}

	//Check and assign full_security token, highest level is the unitid/goalid level though
	function has_full_security($action, $skip_type_check=FALSE) {
		if ( $this->full_security === FALSE ) {
			//if the first call sets $this->full_security to false then all susequent calls will return false
			return FALSE;
		}
		if ( !$this->valid_action($action) ) {
			osa_errorlog(__METHOD__ . ' Bad input params', $action);
			$this->show_error('gen_security_bad_param_error');
			$this->full_security = FALSE;
			return FALSE;
		}

		if ( $this->full_security!==NULL && ($action == $this->full_security ||
			  $this->full_security == CMS_ACTION_EDIT) ) {
			return $this->full_security;
		}

		if ( !$this->dataobj->check_required() ) {
			$this->full_security = FALSE;
			return FALSE;
		}

		//$unitid = base_convert($this->dataobj->unitid, 36, 10);
		//$courseid = base_convert($this->dataobj->courseid, 36, 10);
		$unitid = $this->dataobj->unitid;
		$courseid = $this->dataobj->courseid;
		if ( $this->validate($action, $this->dataobj->templateid, $courseid, $unitid )) {
			$this->full_security = $action;
			return $this->full_security;
		}

		if ( !$skip_type_check ) {
			//This needs to be modified to allow atomic level checks
			if ( $action == CMS_ACTION_EDIT || $action == CMS_ACTION_READ ) {
				//Adding security token here, might take it out later

				//Should use the action from input since action can change from the original action
				if ( $this->CI->cms_security_type->check($this->dataobj->templateid,
							$action, $unitid, $courseid) ) {
							//$this->dataobj->action, $unitid, $courseid) ) {
					$this->add($action);
					$this->full_security = $action;
					return TRUE;
				}
			}
		}
		else {
			$this->add($action);
			$this->full_security = $action;
			return TRUE;
		}
		$this->full_security = FALSE;
		return FALSE;
	}

	//Needed for login as
	function clear_tokens() {
		$this->tokens = array();
		$this->dirty_flag = TRUE;
		$this->save();
	}

	//Saves the session, can't put this in the destructor becuase some CI resources get destroyed before this...
	function save() {
		if ( $this->dirty_flag ) {
			$this->CI->session->set_userdata(CMS_SESSION_TEMPLATE_SECURITY, osa_compress($this->tokens));
		}
	}

	private function add_token($action, $key) {
		if ( $action != CMS_ACTION_EDIT ) {
			if ( isset($this->tokens[CMS_ACTION_EDIT . "{$this->sep}{$key}"] ) ) {
				return;
			}
		}
		$key = "{$action}{$this->sep}{$key}";
		if ( !isset($this->tokens[$key] ) ) {
			$this->tokens[$key] = 1;
			$this->token_count++;
			$this->dirty_flag = TRUE;
		}
	}

	private function default_key($action=FALSE, $templateid=FALSE, $courseid=FALSE, $unitid=FALSE) {
		if ( $action != FALSE || $templateid !== FALSE || $courseid !== FALSE || $unitid !== FALSE) {
			return "{$action}{$this->sep}{$templateid}{$this->sep}{$courseid}{$this->sep}{$unitid}";
		}
		$obj = $this->dataobj;
		if ( $action === FALSE ) {
			//We need to check edit to override read so don't put action here
			return "{$obj->templateid}{$this->sep}{$obj->courseid}{$this->sep}{$obj->unitid}";
		}
		return "{$action}{$this->sep}{$obj->templateid}{$this->sep}{$obj->courseid}{$this->sep}{$obj->unitid}";
	}

	//Checks for valid action
	private function valid_action(& $action) {
		//TODO: A hack to get security to work, fix it for real later
		if ( $action == CMS_ACTION_ADD || $action == CMS_ACTION_MY ) {
			$action = CMS_ACTION_EDIT;
		}

		if ( $action != CMS_ACTION_EDIT && $action != CMS_ACTION_READ ) {
			return FALSE;
		}
		return TRUE;
	}

	//Only prints the error once
	private function show_error($lang) {
		if ( $this->skip_error_msg === FALSE ) {
			echo '<div class="buildererror">' . lang($lang) . '</div>';
			//@ob_flush(); @flush();
			$this->skip_error_msg = TRUE;
		}
	}

	//Sets a value in the dta object
	private function set($property, $value) {
		return $this->dataobj->set_value($property, $value);
	}

	//Save the original init data if not done yet.
	private function set_orig_object() {
		if ( !$this->orig_data ) {
			$orig = new stdClass();
			$orig->full_security = $this->full_security;
			$orig->dataobj = clone($this->dataobj);
			$this->orig_data = $orig;
		}
	}
}

/*
 * A sub class used only in the main class above
 */
class cms_security_data_class {
	public $courseid = FALSE;
	public $unitid = FALSE;
	public $type = FALSE;
	//This is ubd, my, pyp etc. - filepart
	public $templateid = FALSE;
	public $action = FALSE;

	function init() {
		$this->courseid = FALSE;
		$this->unitid = FALSE;
		$this->type = FALSE;
		$this->templateid = FALSE;
		$this->action = FALSE;
	}

	function reset($action, $templateid, $unitid, $courseid=FALSE) {
		$this->action = $action;
		//$this->courseid = self::convert_value($courseid);
		//$this->unitid = self::convert_value($unitid);
		$this->courseid = $courseid;
		$this->unitid = $unitid;
		$this->templateid = $templateid;
	}

	function set_value($property, $value) {
		if ( !property_exists($this, $property) ) {
			return FALSE;
		}
		/*
		if ( $property == 'unitid' || $property == 'courseid' ) {
			$this->$property = self::convert_value($value);
		}
		else {
			$this->$property = $value;
		}*/
		$this->$property = $value;
		return TRUE;
	}

	function check_required() {
		//if ( $this->courseid === FALSE || $this->unitid === FALSE ||
		if ( $this->unitid === FALSE || $this->templateid === FALSE || $this->action === FALSE ) {
			return FALSE;
		}
		return TRUE;
	}

	//Modify values to use less space like converting base10 to base36 for all integer numbers
	//base_convert() ignores the decimal dot for float but all values are string or integer so we don't care
	//The only string value is the template file-part, this can be long but it is repeated many times and
	//compression is very good in dealing repeated long strings. I was thinking about assigning a counter number
	//for each template but not necessary as explained.
	/*
	static function convert_value($value) {
		if ( empty($value) ) {
			return $value;
		}
		if ( osa_is_int($value) ) {
			return base_convert($value, 10, 36);
		}
		return $value;
	} */
}