artabro/wire/core/ProcessController.php
2024-08-27 11:35:37 +02:00

513 lines
15 KiB
PHP

<?php namespace ProcessWire;
/**
* ProcessWire ProcessController
*
* Loads and executes Process Module instance and determines access.
*
* ProcessWire 3.x, Copyright 2022 by Ryan Cramer
* https://processwire.com
*
*/
/**
* A Controller for Process* Modules
*
* Intended to be used by templates that call upon Process objects
*
* @method string execute()
*
*/
class ProcessController extends Wire {
/**
* The default method called upon when no method is specified in the request
*
*/
const defaultProcessMethodName = 'execute';
/**
* The Process instance to execute
*
* @var Process
*
*/
protected $process;
/**
* The name of the Process to execute (string)
*
* @var string
*
*/
protected $processName;
/**
* Error message if unable to load Process module
*
* @var string
*
*/
protected $processError = '';
/**
* The name of the method to execute in this process
*
* @var string
*
*/
protected $processMethodName;
/**
* Process verbose module info
*
* @var array
*
*/
protected $processInfo = array();
/**
* The prefix to apply to the Process name
*
* All related Processes would use the same prefix, i.e. "Admin"
*
* @var string
*
*/
protected $prefix;
/**
* Construct the ProcessController
*
*/
public function __construct() {
parent::__construct();
$this->prefix = 'Process';
$this->processMethodName = ''; // blank indicates default/index method
}
/**
* Set the Process to execute.
*
* @param Process $process
*
*/
public function setProcess(Process $process) {
$this->process = $process;
}
/**
* Set the name of the Process to execute.
*
* No need to call this unless you want to override the one auto-determined from the URL.
*
* If overridden, then make sure the name includes the prefix, and don't bother calling the setPrefix() method.
*
* @param string $processName
*
*/
public function setProcessName($processName) {
$processName = (string) $processName;
if(!ctype_alnum($processName)) {
$processName = $this->wire()->sanitizer->className($processName);
}
$this->processName = $processName;
}
/**
* Set the name of the method to execute in the Process
*
* It is only necessary to call this if you want to override the default behavior.
* The default behavior is to execute a method called "execute()" OR "executeSegment()" where "Segment" is
* the last URL segment in the request URL.
*
* @param string $processMethod
*
*/
public function setProcessMethodName($processMethod) {
$processMethod = (string) $processMethod;
if(!ctype_alnum($processMethod)) {
$processMethod = $this->wire()->sanitizer->name($processMethod);
}
$this->processMethodName = $processMethod;
}
/**
* Set the class name prefix used by all related Processes
*
* This is prepended to the class name determined from the URL.
* For example, if the URL indicates a process name is "PageEdit", then we would need a prefix of "Admin"
* to fully resolve the class name.
*
* @param string $prefix
*
*/
public function setPrefix($prefix) {
$prefix = (string) $prefix;
if(!ctype_alpha($prefix)) $prefix = $this->wire()->sanitizer->name($prefix);
$this->prefix = $prefix;
}
/**
* Determine and return the Process to execute
*
* @return Process
*
*/
public function getProcess() {
$modules = $this->wire()->modules;
if($this->process) {
$processName = $this->process->className();
} else if($this->processName) {
$processName = $this->processName;
} else {
return null;
}
// verify that there is adequate permission to execute the Process
$permissionName = '';
$info = $modules->getModuleInfoVerbose($processName);
$this->processInfo = $info;
if(!empty($info['permission'])) $permissionName = $info['permission'];
$this->hasPermission($permissionName, true); // throws exception if no permission
if(!$this->process) {
$module = $modules->getModule($processName, array('returnError' => true));
if(is_string($module)) {
$this->processError = $module;
$this->process = null;
} else {
$this->process = $module;
}
}
// set a process fuel, primarily so that certain Processes can determine if they are the root Process
// example: PageList when in PageEdit
$this->wire('process', $this->process);
return $this->process;
}
/**
* Does the current user have permission to execute the given process name?
*
* Note: an empty permission name is accessible only by the superuser
*
* @param string $permissionName
* @param bool $throw Whether to throw an Exception if the user does not have permission
* @return bool
* @throws ProcessControllerPermissionException
*
*/
protected function hasPermission($permissionName, $throw = true) {
$user = $this->wire()->user;
if($user->isSuperuser()) return true;
if($permissionName && $user->hasPermission($permissionName)) return true;
if($throw) {
throw new ProcessControllerPermissionException(
sprintf($this->_('You do not have “%s” permission'), $permissionName)
);
}
return false;
}
/**
* Does user have permission for the given $method name in the current Process?
*
* @param string $method
* @param bool $throw Throw exception if not permission?
* @return bool
* @throws ProcessControllerPermissionException
*
*/
protected function hasMethodPermission($method, $throw = true) {
// i.e. executeHelloWorld => helloWorld
$urlSegment = $method;
if(strpos($method, 'execute') === 0) list(,$urlSegment) = explode('execute', $method, 2);
$urlSegment = $this->wire()->sanitizer->hyphenCase($urlSegment);
if(!$this->hasUrlSegmentPermission($urlSegment, $throw)) return false;
return true;
}
/**
* Does user have permission for the given urlSegment in the current Process?
*
* @param string $urlSegment
* @param bool $throw Throw exception if not permission?
* @return bool
* @throws ProcessControllerPermissionException
*
*/
protected function hasUrlSegmentPermission($urlSegment, $throw = true) {
if(empty($this->processInfo['nav']) || $this->wire()->user->isSuperuser()) return true;
$hasPermission = true;
$urlSegment = trim(strtolower($urlSegment), '.-_');
foreach($this->processInfo['nav'] as $navItem) {
if(empty($navItem['permission'])) continue;
$navSegment = strtolower(trim($navItem['url'], './'));
if(empty($navSegment)) continue;
if(strpos($navSegment, '/') !== false) list($navSegment,) = explode($navSegment, '/', 2);
$navSegmentAlt = str_replace('-', '', $navSegment);
if($urlSegment === $navSegment || $urlSegment === $navSegmentAlt) {
$hasPermission = $this->hasPermission($navItem['permission'], $throw);
break;
}
}
return $hasPermission;
}
/**
* Get the name of the method to execute with the Process
*
* @param Process @process
* @return string
* @throws ProcessControllerPermissionException
*
*/
public function getProcessMethodName(Process $process) {
$sanitizer = $this->wire()->sanitizer;
$forceFail = false;
$urlSegment1 = $this->wire()->input->urlSegment1;
$method = self::defaultProcessMethodName;
if($this->processMethodName) {
// the method to use has been preset with the setProcessMethodName() function
$method = $this->processMethodName;
if($method !== self::defaultProcessMethodName) {
$this->hasMethodPermission($method);
}
} else if(strlen($urlSegment1) && !$this->wire()->user->isGuest()) {
// determine requested method from urlSegment1
// $urlSegment1 = trim($this->wire('sanitizer')->hyphenCase($urlSegment1, array('allow' => 'a-z0-9_')), '_');
if(ctype_alpha($urlSegment1)) {
$methodName = ucfirst($urlSegment1);
$hyphenName = $urlSegment1;
} else {
$methodName = trim($sanitizer->pascalCase($urlSegment1, array('allowUnderscore' => true)), '_');
$hyphenName = trim($sanitizer->hyphenCase($methodName, array('allowUnderscore' => true)), '_');
}
if($hyphenName != strtolower($urlSegment1) && strtolower($methodName) != strtolower($urlSegment1)) {
// if urlSegment changed from sanitization, likely not in valid format
$forceFail = true;
} else {
// valid
$method .= $methodName; // execute => executeHelloWorld
$this->hasUrlSegmentPermission($hyphenName);
}
}
if(!$forceFail) {
if($method === 'executed') return '';
if(method_exists($process, $method)) return $method;
if(method_exists($process, "___$method")) return $method;
if($process->hasHook($method . '()')) return $method;
}
// fallback to the unknown, if there is an unknown (you never know)
$method = 'executeUnknown';
if(method_exists($process, $method) || method_exists($process, "___$method")) return $method;
return '';
}
/**
* Execute the process and return the resulting content generated by the process
*
* @return string
* @throws ProcessController404Exception
*
*/
public function ___execute() {
$debug = $this->wire()->config->debug;
$breadcrumbs = $this->wire()->breadcrumbs;
$headline = $this->wire('processHeadline');
$numBreadcrumbs = $breadcrumbs ? count($breadcrumbs) : null;
$process = $this->getProcess();
if(!$process) {
throw new ProcessController404Exception("Process does not exist: $this->processError");
}
// determine method (throws ProcessControllerPermissionException if no access)
$method = $this->getProcessMethodName($process);
if(!$method) {
throw new ProcessController404Exception("Unrecognized path");
}
if($method === 'executeNavJSON' && !$this->wire()->config->ajax && !$debug) {
// disallow navJSON output when not ajax and not debug mode
if(!$this->wire()->user->isLoggedin()) wire404();
$navJSON = substr($this->wire()->input->url(), -8);
if($navJSON === 'navJSON/') {
$this->wire()->session->location('../');
} else if($navJSON === '/navJSON') {
$this->wire()->session->location('./');
}
}
// call method from Process (and time it if debug mode enabled)
$className = $process->className();
if($debug) Debug::timer("$className.$method()");
$content = $process->$method();
if($debug) Debug::saveTimer("$className.$method()");
// setup breadcrumbs if in some method other than the main execute() method
if($method !== 'execute') {
// some method other than the main one
if($numBreadcrumbs === count($breadcrumbs)) {
// process added no breadcrumbs, but there should be more
if($headline === $this->wire('processHeadline')) {
$process->headline(str_replace('execute', '', $method));
}
$href = substr($this->wire()->input->url(), -1) == '/' ? '../' : './';
$process->breadcrumb($href, $this->processInfo['title']);
}
}
// triggered "executed" (execute done) hook
$process->executed($method);
if(empty($content) || is_bool($content)) {
$content = $process->getViewVars();
}
if(is_array($content)) {
// array of returned content indicates variables to send to a view
if(count($content) || $process->getViewFile()) {
$viewFile = $this->getViewFile($process, $method);
if($viewFile) {
// get output from a separate view file
/** @var TemplateFile $template */
$template = $this->wire(new TemplateFile($viewFile));
foreach($content as $key => $value) {
$template->set($key, $value);
}
$content = $template->render();
}
} else {
$content = '';
}
}
return $content;
}
/**
* Given a process and method name, return the first matching valid view file for it
*
* @param Process $process
* @param string $method If omitted, 'execute' is assumed
* @return string
*
*/
protected function getViewFile(Process $process, $method = '') {
$viewFile = $process->getViewFile();
if($viewFile) return $viewFile;
if(empty($method)) $method = 'execute';
$className = $process->className();
$viewPath = $this->wire()->config->paths->$className;
$method2 = ''; // lowercase hyphenated version
$method3 = ''; // lowercase hyphenated, without leading execute
if(strtolower($method) != $method) {
// lowercase hyphenated version
$method2 = trim(strtolower(preg_replace('/([A-Z]+)/', '-$1', $method)), '-');
// without a leading 'execute-' or 'execute'
$method3 = str_replace(array('execute-', 'execute'), '', $method2);
}
if(is_dir($viewPath . 'views')) {
// check in a /ModuleName/views/ directory for one of the following:
// views/execute.php (only if method name is 'execute')
// views/executeSomeMethod.php
// views/execute-some-method.php
// views/some-method.php (preferable)
$_viewPath = $viewPath;
$viewPath .= 'views/';
$viewFile = $viewPath . $method . '.php'; // i.e. views/execute.php or views/executeSomething.php
if(is_file($viewFile)) return $viewFile;
if($method2) {
// convert executeSomething to execute-something or thisThat to this-that
$viewFile = $viewPath . $method2 . '.php'; // i.e. execute-something.php
if(is_file($viewFile)) return $viewFile;
}
if($method != 'execute' && $method3) {
$viewFile = $viewPath . $method3 . '.php'; // i.e. something.php or some-method.php
if(is_file($viewFile)) return $viewFile;
}
$viewPath = $_viewPath; // restore, since didn't find it in /views/
}
// look for view file in same dir as module
if($method == 'execute') {
$viewFiles = array(
"$className.view.php", // ModuleName.view.php
"$className-execute.view.php", // alt1: ModuleName-execute.view.php
"execute.view.php", // alt2: just execute.view.php (no ModuleName)
);
} else {
$viewFiles = array(
"$className-$method.view.php", // ModuleName.executeSomething.view.php
"$method.view.php", // executeSomething.view.php
);
if($method2) {
$viewFiles[] = "$className-$method2.view.php"; // ModuleName-execute-something.view.php
$viewFiles[] = "$method2.view.php"; // execute-something.view.php
}
if($method3) {
$viewFiles[] = "$className-$method3.view.php"; // ModuleName-something.view.php
$viewFiles[] = "$method3.view.php"; // something.view.php
}
}
// now determine which of the possible view files actually exists
$viewFile = '';
foreach($viewFiles as $file) {
if(is_file($viewPath . $file)) {
$viewFile = $viewPath . $file;
break;
}
}
return $viewFile;
}
/**
* Generate a message in JSON format, for use with AJAX output
*
* @param string $msg
* @param bool $error
* @param bool $allowMarkup
* @return string JSON encoded string
*
*/
public function jsonMessage($msg, $error = false, $allowMarkup = false) {
if(!$allowMarkup) $msg = $this->wire()->sanitizer->entities($msg);
return json_encode(array(
'error' => (bool) $error,
'message' => (string) $msg
));
}
/**
* Is this an AJAX request?
*
* @return bool
*
*/
public function isAjax() {
return isset($_SERVER['HTTP_X_REQUESTED_WITH']) && ($_SERVER['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest');
}
}