From 03fcc80e19c8962c94804f0b0ad75897f30694f3 Mon Sep 17 00:00:00 2001 From: Ingo Schommer Date: Sat, 9 Aug 2008 03:19:54 +0000 Subject: [PATCH] (merged from branches/roa. use "svn log -c -g " for detailed commit message) git-svn-id: svn://svn.silverstripe.com/silverstripe/open/modules/sapphire/trunk@60205 467b73ca-7a2a-4603-9d3b-597d59a354a9 --- _config.php | 20 ++ api/RestfulServer.php | 54 ++- core/ViewableData.php | 1 - core/control/ContentController.php | 1 - core/control/Controller.php | 332 +++++------------- core/control/Director.php | 180 ++++------ core/control/HTTPRequest.php | 225 ++++++++++++ core/control/ModelAsController.php | 6 +- core/control/RequestHandlingData.php | 169 +++++++++ forms/Form.php | 91 ++++- forms/FormField.php | 2 +- security/Member.php | 1 + security/Security.php | 2 +- tests/ControllerTest.php | 82 +++++ tests/RequestHandlingTest.php | 154 ++++++++ tests/security/MemberTest.php | 2 +- tests/templates/ControllerTest.ss | 1 + .../ControllerTest_templateaction.ss | 1 + 18 files changed, 953 insertions(+), 371 deletions(-) create mode 100644 core/control/HTTPRequest.php create mode 100644 core/control/RequestHandlingData.php create mode 100644 tests/ControllerTest.php create mode 100644 tests/RequestHandlingTest.php create mode 100644 tests/templates/ControllerTest.ss create mode 100644 tests/templates/ControllerTest_templateaction.ss diff --git a/_config.php b/_config.php index e950cc0d7..e663c3834 100644 --- a/_config.php +++ b/_config.php @@ -17,6 +17,26 @@ * @subpackage core */ +// Default director +Director::addRules(10, array( + 'Security' => 'Security', + //'Security/$Action/$ID' => 'Security', + 'db/$Action' => 'DatabaseAdmin', + '$Controller' => array( + ), + 'images/$Action/$Class/$ID/$Field' => 'Image_Uploader', + '' => 'RootURLController', + 'sitemap.xml' => 'GoogleSitemap', + 'api/v1' => 'RestfulServer', +)); + +Director::addRules(1, array( + '$URLSegment/$Action/$ID/$OtherID' => array( + '_PopTokeniser' => 1, + 'Controller' => 'ModelAsController', + ), +)); + /** * PHP 5.2 has a namespace conflict with our datetime class, * for legacy support, we use this overload method. diff --git a/api/RestfulServer.php b/api/RestfulServer.php index a8495bcbb..87d8a0033 100644 --- a/api/RestfulServer.php +++ b/api/RestfulServer.php @@ -28,7 +28,20 @@ * @subpackage api */ class RestfulServer extends Controller { + static $url_handlers = array( + '$ClassName/#ID' => 'handleItem', + '$ClassName' => 'handleList', + ); + protected static $api_base = "api/v1/"; + + function handleItem($params) { + return new RestfulServer_Item(DataObject::get_by_id($params["ClassName"], $params["ID"])); + } + + function handleList($params) { + return new RestfulServer_List(DataObject::get($params["ClassName"],"")); + } /** * This handler acts as the switchboard for the controller. @@ -318,4 +331,43 @@ class RestfulServer extends Controller { return "That object wasn't found"; } -} \ No newline at end of file +} + + +/** + * Restful server handler for a DataObjectSet + */ +class RestfulServer_List { + static $url_handlers = array( + '#ID' => 'handleItem', + ); + + function __construct($list) { + $this->list = $list; + } + + function handleItem($params) { + return new RestulServer_Item($this->list->getById($params['ID'])); + } +} + +/** + * Restful server handler for a single DataObject + */ +class RestfulServer_Item { + static $url_handlers = array( + '$Relation' => 'handleRelation', + ); + + function __construct($item) { + $this->item = $item; + } + + function handleRelation($params) { + $funcName = $params['Relation']; + $relation = $this->item->$funcName(); + + if($relation instanceof DataObjectSet) return new RestfulServer_List($relation); + else return new RestfulServer_Item($relation)l + } +} diff --git a/core/ViewableData.php b/core/ViewableData.php index 0d52b29b6..ac8d9fe06 100644 --- a/core/ViewableData.php +++ b/core/ViewableData.php @@ -865,7 +865,6 @@ class ViewableData extends Object implements IteratorAggregate { return Convert::raw2att(implode(" ", $classes)); } - /** * Object-casting information for class methods * @var mixed diff --git a/core/control/ContentController.php b/core/control/ContentController.php index 5317451eb..5ffa40a6b 100644 --- a/core/control/ContentController.php +++ b/core/control/ContentController.php @@ -28,7 +28,6 @@ class ContentController extends Controller { public function __construct($dataRecord) { $this->dataRecord = $dataRecord; $this->failover = $this->dataRecord; - parent::__construct(); } diff --git a/core/control/Controller.php b/core/control/Controller.php index 830c8bce6..d7e2b25b9 100644 --- a/core/control/Controller.php +++ b/core/control/Controller.php @@ -9,27 +9,12 @@ * @package sapphire * @subpackage control */ -class Controller extends ViewableData { - +class Controller extends RequestHandlingData { /** - * Define a list of actions that are allowed to be called on this controller. - * The variable should be an array of action names. This sample shows the different values that it can contain: - * - * - * array( - * 'someaction', // someaction can be accessed by anyone, any time - * 'otheraction' => true, // So can otheraction - * 'restrictedaction' => 'ADMIN', // restrictedaction can only be people with ADMIN privilege - * 'complexaction' '->canComplexAction' // complexaction can only be accessed if $this->canComplexAction() returns true - * ); - * + * An array of arguments extracted from the URL */ - static $allowed_actions = null; - protected $urlParams; - protected $requestParams; - protected $action; /** @@ -50,6 +35,90 @@ class Controller extends ViewableData { */ protected $response; + /** + * Default URL handlers - (Action)/(ID)/(OtherID) + */ + static $url_handlers = array( + '$Action/$ID/$OtherID' => 'handleAction', + ); + + static $allowed_actions = array( + 'handleAction', + 'handleIndex', + ); + + /** + * Handles HTTP requests. + * @param $request The {@link HTTPRequest} object that is responsible for distributing request parsing. + */ + function handleRequest($request) { + $this->pushCurrent(); + $this->urlParams = $request->allParams(); + $this->response = new HTTPResponse(); + + // Init + $this->baseInitCalled = false; + $this->init(); + if(!$this->baseInitCalled) user_error("init() method on class '$this->class' doesn't call Controller::init(). Make sure that you have parent::init() included.", E_USER_WARNING); + + // If we had a redirection or something, halt processing. + if($this->response->isFinished()) { + $this->popCurrent(); + return $this->response; + } + + $body = parent::handleRequest($request); + if($body instanceof HTTPResponse) { + $this->response = $body; + + } else { + if(is_object($body)) $body = $body->getViewer($request->latestParam('Action'))->process($body); + $this->response->setBody($body); + } + + + ContentNegotiator::process($this->response); + HTTP::add_cache_headers($this->response); + + $this->popCurrent(); + return $this->response; + } + + /** + * Controller's default action handler. It will call the method named in $Action, if that method exists. + * If $Action isn't given, it will use "index" as a default. + */ + function handleAction($request) { + // urlParams, requestParams, and action are set for backward compatability + $this->urlParams = array_merge($this->urlParams, $request->latestParams()); + $this->action = str_replace("-","_",$request->param('Action')); + $this->requestParams = $request->requestVars(); + if(!$this->action) $this->action = 'index'; + $methodName = $this->action; + + // run & init are manually disabled, because they create infinite loops and other dodgy situations + if(!$this->checkAccessAction($this->action) || in_array(strtolower($this->action), array('run', 'init'))) { + if($this->hasMethod($methodName)) { + $result = $this->$methodName($request); + + // Method returns an array, that is used to customise the object before rendering with a template + if(is_array($result)) { + return $this->getViewer($this->action)->process($this->customise($result)); + + // Method returns a string / object, in which case we just return that + } else { + return $result; + } + + // There is no method, in which case we just render this object using a (possibly alternate) template + } else { + return $this->getViewer($this->action)->process($this); + } + } else { + return $this->httpError(403, "Action '$this->action' isn't allowed on class $this->class"); + } + } + function setURLParams($urlParams) { $this->urlParams = $urlParams; } @@ -99,185 +168,6 @@ class Controller extends ViewableData { * @param array $requestParams GET and POST variables. * @return HTTPResponse The response that this controller produces, including HTTP headers such as redirection info */ - function run($requestParams) { - if(isset($_GET['debug_profile'])) Profiler::mark("Controller", "run"); - $this->pushCurrent(); - - $this->response = new HTTPResponse(); - $this->requestParams = $requestParams; - - $this->action = isset($this->urlParams['Action']) ? str_replace("-","_",$this->urlParams['Action']) : ""; - if(!$this->action) $this->action = 'index'; - - // Check security on the controller - // run & init are manually disabled, because they create infinite loops and other dodgy situations - if(!$this->checkAccessAction($this->action) || in_array(strtolower($this->action), array('run', 'init'))) { - user_error("Disallowed action: '$this->action' on controller '$this->class'", E_USER_ERROR); - } - - // Init - $this->baseInitCalled = false; - $this->init(); - if(!$this->baseInitCalled) user_error("init() method on class '$this->class' doesn't call Controller::init(). Make sure that you have parent::init() included.", E_USER_WARNING); - - // If we had a redirection or something, halt processing. - if($this->response->isFinished()) { - $this->popCurrent(); - return $this->response; - } - - // Look at the action variables for forms - $funcName = null; - foreach($this->requestParams as $paramName => $paramVal) { - if(substr($paramName,0,7) == 'action_') { - // Cleanup action_, _x and _y from image fields - $funcName = preg_replace(array('/^action_/','/_x$|_y$/'),'',$paramName); - break; - } - } - - // Form handler - if(isset($this->requestParams['executeForm']) && is_string($this->requestParams['executeForm'])) { - if(isset($funcName)) { - Form::set_current_action($funcName); - } - - $formOwner = $this->getFormOwner(); - - // Create the form object - $form = $formOwner; - - $formObjParts = explode('.', $this->requestParams['executeForm']); - foreach($formObjParts as $formMethod){ - if(isset($_GET['debug_profile'])) Profiler::mark("Calling $formMethod", "on $form->class"); - $form = $form->$formMethod(); - if(isset($_GET['debug_profile'])) Profiler::unmark("Calling $formMethod", "on $form->class"); - if(!$form) break; //user_error("Form method '" . $this->requestParams['executeForm'] . "' returns null in controller class '$this->class' ($_SERVER[REQUEST_URI])", E_USER_ERROR); - } - - - // Populate the form - if(isset($_GET['debug_profile'])) Profiler::mark("Controller", "populate form"); - if($form){ - $form->loadDataFrom($this->requestParams, true); - // disregard validation if a single field is called - - - if(!isset($_REQUEST['action_callfieldmethod'])) { - $valid = $form->beforeProcessing(); - if(!$valid) { - $this->popCurrent(); - return $this->response; - } - }else{ - $fieldcaller = $form->dataFieldByName($requestParams['fieldName']); - if(is_a($fieldcaller, "TableListField")){ - if($fieldcaller->hasMethod('php')){ - $valid = $fieldcaller->php($requestParams); - if(!$valid) exit(); - } - } - } - - // If the action wasnt' set, choose the default on the form. - if(!isset($funcName) && $defaultAction = $form->defaultAction()){ - $funcName = $defaultAction->actionName(); - } - - if(isset($funcName)) { - $form->setButtonClicked($funcName); - } - - }else{ - user_error("No form (" . Session::get('CMSMain.currentPage') . ") returned by $formOwner->class->$_REQUEST[executeForm]", E_USER_WARNING); - } - if(isset($_GET['debug_profile'])) Profiler::unmark("Controller", "populate form"); - - if(!isset($funcName)) { - user_error("No action button has been clicked in this form executon, and no default has been allowed", E_USER_ERROR); - } - - // Protection against CSRF attacks - if($form->securityTokenEnabled()) { - $securityID = Session::get('SecurityID'); - - if(!$securityID || !isset($this->requestParams['SecurityID']) || $securityID != $this->requestParams['SecurityID']) { - // Don't show error on live sites, as spammers create a million of these - if(!Director::isLive()) { - trigger_error("Security ID doesn't match, possible CRSF attack.", E_USER_ERROR); - } else { - die(); - } - } - } - - - // First, try a handler method on the controller - if($this->hasMethod($funcName) || !$form) { - if(isset($_GET['debug_controller'])){ - Debug::show("Found function $funcName on the controller"); - } - - if(isset($_GET['debug_profile'])) Profiler::mark("$this->class::$funcName (controller action)"); - $result = $this->$funcName($this->requestParams, $form); - if(isset($_GET['debug_profile'])) Profiler::unmark("$this->class::$funcName (controller action)"); - - } else if(isset($formOwner) && $formOwner->hasMethod($funcName)) { - $result = $formOwner->$funcName($this->requestParams, $form); - - // Otherwise, try a handler method on the form object - } else { - if(isset($_GET['debug_controller'])) { - Debug::show("Found function $funcName on the form object"); - } - - if(isset($_GET['debug_profile'])) Profiler::mark("$form->class::$funcName (form action)"); - $result = $form->$funcName($this->requestParams, $form); - if(isset($_GET['debug_profile'])) Profiler::unmark("$form->class::$funcName (form action)"); - } - - // Normal action - } else { - if(!isset($funcName)) $funcName = $this->action; - - if($this->hasMethod($funcName)) { - if(isset($_GET['debug_controller'])) Debug::show("Found function $funcName on the $this->class controller"); - - if(isset($_GET['debug_profile'])) Profiler::mark("$this->class::$funcName (controller action)"); - - $result = $this->$funcName($this->urlParams); - if(isset($_GET['debug_profile'])) Profiler::unmark("$this->class::$funcName (controller action)"); - - } else { - if(isset($_GET['debug_controller'])) Debug::show("Running default action for $funcName on the $this->class controller" ); - if(isset($_GET['debug_profile'])) Profiler::mark("Controller::defaultAction($funcName)"); - $result = $this->defaultAction($funcName, $this->urlParams); - if(isset($_GET['debug_profile'])) Profiler::unmark("Controller::defaultAction($funcName)"); - } - } - - // If your controller function returns an array, then add that data to the - // default template - - if(is_array($result)) { - $extended = $this->customise($result); - $viewer = $this->getViewer($funcName); - - $result = $viewer->process($extended); - } - - $this->response->setBody($result); - - if($result) ContentNegotiator::process($this->response); - - // Set up HTTP cache headers - HTTP::add_cache_headers($this->response); - - if(isset($_GET['debug_profile'])) Profiler::unmark("Controller", "run"); - - $this->popCurrent(); - return $this->response; - } /** * Return the object that is going to own a form that's being processed, and handle its execution. @@ -552,56 +442,6 @@ class Controller extends ViewableData { ); } - /** - * Check that the given action is allowed to be called on this controller. - * This method is called by run() and makes use of {@link self::$allowed_actions}. - */ - function checkAccessAction($action) { - $action = strtolower($action); - - // Collate self::$allowed_actions from this class and all parent classes - $access = null; - $className = $this->class; - while($className != 'Controller') { - // Merge any non-null parts onto $access. - $accessPart = eval("return $className::\$allowed_actions;"); - if($accessPart !== null) $access = array_merge((array)$access, $accessPart); - - // Build an array of parts for checking if part[0] == part[1], which means that this class doesn't directly define it. - $accessParts[] = $accessPart; - - $className = get_parent_class($className); - } - - // Add $allowed_actions from extensions - if($this->extension_instances) { - foreach($this->extension_instances as $inst) { - $accessPart = $inst->stat('allowed_actions'); - if($accessPart !== null) $access = array_merge((array)$access, $accessPart); - } - } - - if($access === null || (isset($accessParts[1]) && $accessParts[0] === $accessParts[1])) { - // user_error("Deprecated: please define static \$allowed_actions on your Controllers for security purposes", E_USER_NOTICE); - return true; - } - - if($action == 'index') return true; - - if(isset($access[$action])) { - $test = $access[$action]; - if($test === true) return true; - if(substr($test,0,2) == '->') { - $funcName = substr($test,2); - return $this->$funcName(); - } - if(Permission::check($test)) return true; - } else if((($key = array_search($action, $access)) !== false) && is_numeric($key)) { - return true; - } - return false; - } - } ?> diff --git a/core/control/Director.php b/core/control/Director.php index a3f2be670..578273e3c 100644 --- a/core/control/Director.php +++ b/core/control/Director.php @@ -71,41 +71,51 @@ class Director { /** * Process the given URL, creating the appropriate controller and executing it. * - * This method will: - * - iterate over all of the rules given in {@link Director::addRules()}, and find the first one that matches. - * - instantiate the {@link Controller} object required by that rule, and call {@link Controller::setURLParams()} to give the URL paramters to the controller. - * - link the Controller's session to PHP's main session, using {@link Controller::setSession()}. - * - call {@link Controller::run()} on that controller - * - save the Controller's session back into PHP's main session. - * - output the response to the browser, using {@link HTTPResponse::output()}. + * Request processing is handled as folows: + * - Director::direct() creates a new HTTPResponse object and passes this to Director::handleRequest(). + * - Director::handleRequest($request) checks each of the Director rules and identifies a controller to handle this + * request. + * - Controller::handleRequest($request) is then called. This will find a rule to handle the URL, and call the rule + * handling method. + * - RequestHandlingData::handleRequest($request) is recursively called whenever a rule handling method returns a + * RequestHandlingData object. + * + * In addition to request processing, Director will manage the session, and perform the output of the actual response + * to the browser. * * @param $url String, the URL the user is visiting, without the querystring. - * @uses getControllerForURL() rule-lookup logic is handled by this. + * @uses handleRequest() rule-lookup logic is handled by this. * @uses Controller::run() Controller::run() handles the page logic for a Director::direct() call. */ function direct($url) { - if(isset($_GET['debug_profile'])) Profiler::mark("Director","direct"); - $controllerObj = Director::getControllerForURL($url); - - if(is_string($controllerObj) && substr($controllerObj,0,9) == 'redirect:') { - $response = new HTTPResponse(); - $response->redirect(substr($controllerObj, 9)); - $response->output(); - } else if($controllerObj) { - // Load the session into the controller - $controllerObj->setSession(new Session($_SESSION)); - - $response = $controllerObj->run(array_merge((array)$_GET, (array)$_POST, (array)$_FILES)); - - - $controllerObj->getSession()->inst_save(); + $req = new HTTPRequest($_SERVER['REQUEST_METHOD'], $url, $_GET, array_merge((array)$_POST, (array)$_FILES)); - if(isset($_GET['debug_profile'])) Profiler::mark("Outputting to browser"); + // Load the session into the controller + $session = new Session($_SESSION); + $result = Director::handleRequest($req, $session); + $session->inst_save(); + + // Return code for a redirection request + if(is_string($result) && substr($result,0,9) == 'redirect:') { + $response = new HTTPResponse(); + $response->redirect(substr($result, 9)); $response->output(); - if(isset($_GET['debug_profile'])) Profiler::unmark("Outputting to browser"); + + // Handle a controller + } else if($result) { + if($result instanceof HTTPResponse) { + $response = $result; + + } else { + $response = new HTTPResponse(); + $response->setBody($result); + } + + $response->output(); + + //$controllerObj->getSession()->inst_save(); } - if(isset($_GET['debug_profile'])) Profiler::unmark("Director","direct"); } /** @@ -114,117 +124,79 @@ class Director { * This method is the counterpart of Director::direct() that is used in functional testing. It will execute the URL given, * * @param $url The URL to visit - * @param $post The $_POST & $_FILES variables + * @param $postVars The $_POST & $_FILES variables * @param $session The {@link Session} object representing the current session. By passing the same object to multiple * calls of Director::test(), you can simulate a peristed session. + * @param $httpMethod The HTTP method, such as GET or POST. It will default to POST if postVars is set, GET otherwise * * @uses getControllerForURL() The rule-lookup logic is handled by this. * @uses Controller::run() Controller::run() handles the page logic for a Director::direct() call. */ - function test($url, $post = null, $session = null) { + function test($url, $postVars = null, $session = null, $httpMethod = null) { + if(!$httpMethod) $httpMethod = $postVars ? "POST" : "GET"; + $getVars = array(); if(strpos($url,'?') !== false) { list($url, $getVarsEncoded) = explode('?', $url, 2); parse_str($getVarsEncoded, $getVars); } - $existingRequestVars = $_REQUEST; - $existingGetVars = $_GET; - $existingPostVars = $_POST; - $existingSessionVars = $_SESSION; - - $_REQUEST = $existingRequestVars; - $_GET = $existingGetVars; - $_POST = $existingPostVars; - $_SESSION = $existingSessionVars; - - $_REQUEST = array_merge((array)$getVars, (array)$post); - $_GET = (array)$getVars; - $_POST = (array)$post; - $_SESSION = $session ? $session->inst_getAll() : array(); + if(!$session) $session = new Session(null); - $controllerObj = Director::getControllerForURL($url); + $req = new HTTPRequest($httpMethod, $url, $getVars, $postVars); + $result = Director::handleRequest($req, $session); - // Load the session into the controller - $controllerObj->setSession($session ? $session : new Session(null)); - - if(is_string($controllerObj) && substr($controllerObj,0,9) == 'redirect:') { - user_error("Redirection not implemented in Director::test", E_USER_ERROR); - - } else if($controllerObj) { - $response = $controllerObj->run( array_merge($getVars, (array)$post) ); - $_REQUEST = $existingRequestVars; - $_GET = $existingGetVars; - $_POST = $existingPostVars; - $_SESSION = $existingSessionVars; - return $response; - } + return $result; } - /** - * Returns the controller that should be used to handle the given URL. - * @todo More information about director rules. + * Handle an HTTP request, defined with a HTTPRequest object. */ - static function getControllerForURL($url) { - if(isset($_GET['debug_profile'])) Profiler::mark("Director","getControllerForURL"); - $url = preg_replace( array( '/\/+/','/^\//', '/\/$/'),array('/','',''),$url); - $urlParts = split('/+', $url); - + protected static function handleRequest(HTTPRequest $request, Session $session) { krsort(Director::$rules); if(isset($_REQUEST['debug'])) Debug::show(Director::$rules); - foreach(Director::$rules as $priority => $rules) { - foreach($rules as $pattern => $controller) { - $patternParts = explode('/', $pattern); - $matched = true; - $arguments = array(); - foreach($patternParts as $i => $part) { - $part = trim($part); - if(isset($part[0]) && $part[0] == '$') { - $arguments[substr($part,1)] = isset($urlParts[$i]) ? $urlParts[$i] : null; - if($part == '$Controller' && !class_exists($arguments['Controller'])) { - $matched = false; - break; - } - - } else if(!isset($urlParts[$i]) || $urlParts[$i] != $part) { - $matched = false; - break; - } + foreach($rules as $pattern => $controllerOptions) { + if(is_string($controllerOptions)) { + if(substr($controllerOptions,0,2) == '->') $controllerOptions = array('Redirect' => substr($controllerOptions,2)); + else $controllerOptions = array('Controller' => $controllerOptions); } - if($matched) { - - if(substr($controller,0,2) == '->') { - if(isset($_REQUEST['debug']) && $_REQUEST['debug'] == 1) Debug::message("Redirecting to $controller"); - - if(isset($_GET['debug_profile'])) Profiler::unmark("Director","getControllerForURL"); - - return "redirect:" . Director::absoluteURL(substr($controller,2), true); + + if(($arguments = $request->match($pattern, true)) !== false) { + // controllerOptions provide some default arguments + $arguments = array_merge($controllerOptions, $arguments); + + // Find the controller name + if(isset($arguments['Controller'])) $controller = $arguments['Controller']; + + // Pop additional tokens from the tokeniser if necessary + if(isset($controllerOptions['_PopTokeniser'])) { + $request->shift($controllerOptions['_PopTokeniser']); + } + + // Handle redirections + if(isset($arguments['Redirect'])) { + return "redirect:" . Director::absoluteURL($arguments['Redirect'], true); } else { - if(isset($arguments['Controller']) && $controller == "*") { - $controller = $arguments['Controller']; - } - - if(isset($_REQUEST['debug'])) Debug::message("Using controller $controller"); + /* if(isset($arguments['Action'])) { $arguments['Action'] = str_replace('-','',$arguments['Action']); } + if(isset($arguments['Action']) && ClassInfo::exists($controller.'_'.$arguments['Action'])) $controller = $controller.'_'.$arguments['Action']; - - Director::$urlParams = $arguments; - $controllerObj = new $controller(); - - $controllerObj->setURLParams($arguments); + */ if(isset($arguments['URLSegment'])) self::$urlSegment = $arguments['URLSegment'] . "/"; - - if(isset($_GET['debug_profile'])) Profiler::unmark("Director","getControllerForURL"); - return $controllerObj; + Director::$urlParams = $arguments; + + $controllerObj = new $controller(); + $controllerObj->setSession($session); + + return $controllerObj->handleRequest($request); } } } diff --git a/core/control/HTTPRequest.php b/core/control/HTTPRequest.php new file mode 100644 index 000000000..b0900a580 --- /dev/null +++ b/core/control/HTTPRequest.php @@ -0,0 +1,225 @@ +getVars; + } + function postVars() { + return $this->postVars; + } + function requestVars() { + return array_merge($this->getVars, $this->postVars); + } + + function getVar($name) { + if(isset($this->getVars[$name])) return $this->getVars[$name]; + } + function postVar($name) { + if(isset($this->postVars[$name])) return $this->postVars[$name]; + } + function requestVar($name) { + if(isset($this->postVars[$name])) return $this->postVars[$name]; + if(isset($this->getVars[$name])) return $this->getVars[$name]; + } + + /** + * Construct a HTTPRequest from a URL relative to the site root. + */ + function __construct($httpMethod, $url, $getVars = array(), $postVars = array()) { + $this->httpMethod = $httpMethod; + + $url = preg_replace(array('/\/+/','/^\//', '/\/$/'),array('/','',''), $url); + + if(preg_match('/^(.*)\.([A-Za-z][A-Za-z0-9]*)$/', $url, $matches)) { + $url = $matches[1]; + $this->extension = $matches[2]; + } + if($url) $this->dirParts = split('/+', $url); + else $this->dirParts = array(); + + $this->getVars = (array)$getVars; + $this->postVars = (array)$postVars; + + parent::__construct(); + } + + /** + * Matches a URL pattern + * The pattern can contain a number of segments, separted by / (and an extension indicated by a .) + * + * The parts can be either literals, or, if they start with a $ they are interpreted as variables. + * - Literals must be provided in order to match + * - $Variables are optional + * - However, if you put ! at the end of a variable, then it becomes mandatory. + * + * For example: + * - admin/crm/list will match admin/crm/$Action/$ID/$OtherID, but it won't match admin/crm/$Action!/$ClassName! + * + * The pattern can optionally start with an HTTP method and a space. For example, "POST $Controller/$Action". + * This is used to define a rule that only matches on a specific HTTP method. + */ + function match($pattern, $shiftOnSuccess = false) { + // Check if a specific method is required + if(preg_match('/^([A-Za-z]+) +(.*)$/', $pattern, $matches)) { + $requiredMethod = $matches[1]; + if($requiredMethod != $this->httpMethod) return false; + + // If we get this far, we can match the URL pattern as usual. + $pattern = $matches[2]; + } + + // Special case for the root URL controller + if(!$pattern) { + return ($this->dirParts == array()) ? array('Matched' => true) : false; + } + + // Check for the '//' marker that represents the "shifting point" + $doubleSlashPoint = strpos($pattern, '//'); + if($doubleSlashPoint !== false) { + $shiftCount = substr_count($pattern, '/', 0, $doubleSlashPoint) + 1; + $pattern = str_replace('//', '/', $pattern); + $patternParts = explode('/', $pattern); + + } else { + $patternParts = explode('/', $pattern); + $shiftCount = sizeof($patternParts); + } + + $matched = true; + $arguments = array(); + + foreach($patternParts as $i => $part) { + $part = trim($part); + + // Match a variable + if(isset($part[0]) && $part[0] == '$') { + // A variable ending in ! is required + if(substr($part,-1) == '!') { + $varRequired = true; + $varName = substr($part,1,-1); + } else { + $varRequired = false; + $varName = substr($part,1); + } + + // Fail if a required variable isn't populated + if($varRequired && !isset($this->dirParts[$i])) return false; + + $arguments[$varName] = isset($this->dirParts[$i]) ? $this->dirParts[$i] : null; + if($part == '$Controller' && !class_exists($arguments['Controller'])) { + return false; + } + + // Literal parts must always be there + } else if(!isset($this->dirParts[$i]) || $this->dirParts[$i] != $part) { + return false; + } + } + + if($shiftOnSuccess) { + $this->shift($shiftCount); + // We keep track of pattern parts that we looked at but didn't shift off. + // This lets us say that we have *parsed* the whole URL even when we haven't *shifted* it all + $this->unshiftedButParsedParts = sizeof($patternParts) - $shiftCount; + } + + $this->latestParams = $arguments; + + // Load the arguments that actually have a value into $this->allParams + // This ensures that previous values aren't overridden with blanks + foreach($arguments as $k => $v) { + if($v) $this->allParams[$k] = $v; + } + + return $arguments; + } + + function allParams() { + return $this->allParams; + } + function latestParams() { + return $this->latestParams; + } + function latestParam($name) { + if(isset($this->latestParams[$name])) + return $this->latestParams[$name]; + else + return null; + } + function param($name) { + if(isset($this->allParams[$name])) + return $this->allParams[$name]; + else + return null; + } + + function remaining() { + return implode("/", $this->dirParts); + } + + /** + * Returns true if the give pattern is an empty pattern - that is, one that only matches completely parsed + * URLs. It will also return true if this is a completely parsed URL and the pattern contains only variable + * references. + */ + function isEmptyPattern($pattern) { + if(preg_match('/^([A-Za-z]+) +(.*)$/', $pattern, $matches)) { + $pattern = $matches[2]; + } + + if(trim($pattern) == "") return true; + + if(!$this->dirParts) { + return preg_replace('/\$[A-Za-z][A-Za-z0-9]*(\/|$)/','',$pattern) == ""; + } + } + + /** + * Shift one or more parts off the beginning of the URL. + * If you specify shifting more than 1 item off, then the items will be returned as an array + */ + function shift($count = 1) { + if($count == 1) return array_shift($this->dirParts); + else for($i=0;$i<$count;$i++) $return[] = array_shift($this->dirParts); + } + + /** + * Returns true if the URL has been completely parsed. + * This will respect parsed but unshifted directory parts. + */ + function allParsed() { + return sizeof($this->dirParts) <= $this->unshiftedButParsedParts; + + } +} \ No newline at end of file diff --git a/core/control/ModelAsController.php b/core/control/ModelAsController.php index 1db627d75..e5211d6c2 100644 --- a/core/control/ModelAsController.php +++ b/core/control/ModelAsController.php @@ -8,13 +8,14 @@ */ class ModelAsController extends Controller implements NestedController { - public function run($requestParams) { + public function handleRequest($request) { $this->pushCurrent(); + $this->urlParams = $request->allParams(); $this->init(); $nested = $this->getNestedController(); if(is_object($nested)) { - $result = $nested->run($requestParams); + $result = $nested->handleRequest($requestParams); } else { $result = $nested; } @@ -55,7 +56,6 @@ class ModelAsController extends Controller implements NestedController { } else { $controller = $child; } - $controller->setURLParams($this->urlParams); return $controller; } else { diff --git a/core/control/RequestHandlingData.php b/core/control/RequestHandlingData.php new file mode 100644 index 000000000..b940688a8 --- /dev/null +++ b/core/control/RequestHandlingData.php @@ -0,0 +1,169 @@ +SearchForm(), and pass control to that. + * - Form will determine that fields/Groups is controlled by the Groups field, a TreeMultiselectField, and pass control to that. + * - TreeMultiselectField will determine that treesegment/36 is handled by its treesegment() method. This method will return an HTML fragment that is output to the screen. + * + * {@link RequestHandlingData::handleRequest()} is where this behaviour is implemented. + */ +class RequestHandlingData extends ViewableData { + /** + * The default URL handling rules. This specifies that the next component of the URL corresponds to a method to + * be called on this RequestHandlingData object. + * + * The keys of this array are parse rules. See {@link HTTPRequest::match()} for a description of the rules available. + * + * The values of the array are the method to be called if the rule matches. If this value starts with a '$', then the + * named parameter of the parsed URL wil be used to determine the method name. + */ + static $url_handlers = array( + '$Action' => '$Action', + ); + + + /** + * Define a list of action handling methods that are allowed to be called directly by URLs. + * The variable should be an array of action names. This sample shows the different values that it can contain: + * + * + * array( + * 'someaction', // someaction can be accessed by anyone, any time + * 'otheraction' => true, // So can otheraction + * 'restrictedaction' => 'ADMIN', // restrictedaction can only be people with ADMIN privilege + * 'complexaction' '->canComplexAction' // complexaction can only be accessed if $this->canComplexAction() returns true + * ); + * + */ + static $allowed_actions = null; + /** + * Handles URL requests. + * + * - ViewableData::handleRequest() iterates through each rule in {@link self::$url_handlers}. + * - If the rule matches, the named method will be called. + * - If there is still more URL to be processed, then handleRequest() is called on the object that that method returns. + * + * Once all of the URL has been processed, the final result is returned. However, if the final result is an array, this + * array is interpreted as being additional template data to customise the 2nd to last result with, rather than an object + * in its own right. This is most frequently used when a Controller's action will return an array of data with which to + * customise the controller. + * + * @param $params The parameters taken from the parsed URL of the parent url handler + * @param $request The {@link HTTPRequest} object that is reponsible for distributing URL parsing + * @uses HTTPRequest + */ + function handleRequest($request) { + foreach($this->stat('url_handlers') as $rule => $action) { + if($params = $request->match($rule, true)) { + + // Actions can reference URL parameters, eg, '$Action/$ID/$OtherID' => '$Action', + if($action[0] == '$') $action = $params[substr($action,1)]; + + if($this->checkAccessAction($action)) { + $result = $this->$action($request); + } else { + return $this->httpError(403, "Action '$action' isn't allowed on class $this->class"); + } + + // If we return a RequestHandlingData, call handleRequest() on that, even if there is no more URL to parse. + // It might have its own handler. However, we only do this if we haven't just parsed an empty rule ourselves, + // to prevent infinite loops + if(!$request->isEmptyPattern($rule) && is_object($result) && $result instanceof RequestHandlingData) { + $returnValue = $result->handleRequest($request); + + // Array results can be used to handle + if(is_array($returnValue)) $returnValue = $this->customise($returnValue); + + return $returnValue; + + // If we return some other data, and all the URL is parsed, then return that + } else if($request->allParsed()) { + return $result; + + // But if we have more content on the URL and we don't know what to do with it, return an error. + } else { + return $this->httpError(400, "I can't handle sub-URLs of a $this->class object."); + } + + break; + } + } + + // If nothing matches, return this object + return $this; + } + + /** + * Check that the given action is allowed to be called from a URL. + * It will interrogate {@link self::$allowed_actions} to determine this. + */ + function checkAccessAction($action) { + // Collate self::$allowed_actions from this class and all parent classes + $access = null; + $className = $this->class; + while($className != 'RequestHandlingData') { + // Merge any non-null parts onto $access. + $accessPart = eval("return $className::\$allowed_actions;"); + if($accessPart !== null) $access = array_merge((array)$access, $accessPart); + + // Build an array of parts for checking if part[0] == part[1], which means that this class doesn't directly define it. + $accessParts[] = $accessPart; + + $className = get_parent_class($className); + } + + // Add $allowed_actions from extensions + if($this->extension_instances) { + foreach($this->extension_instances as $inst) { + $accessPart = $inst->stat('allowed_actions'); + if($accessPart !== null) $access = array_merge((array)$access, $accessPart); + } + } + + if($access === null || (isset($accessParts[1]) && $accessParts[0] === $accessParts[1])) { + // user_error("Deprecated: please define static \$allowed_actions on your Controllers for security purposes", E_USER_NOTICE); + return true; + } + + if($action == 'index') return true; + + if(isset($access[$action])) { + $test = $access[$action]; + if($test === true) return true; + if(substr($test,0,2) == '->') { + $funcName = substr($test,2); + return $this->$funcName(); + } + if(Permission::check($test)) return true; + } else if((($key = array_search($action, $access)) !== false) && is_numeric($key)) { + return true; + } + return false; + } + + /** + * Throw an HTTP error instead of performing the normal processing + * @todo This doesn't work properly right now. :-( + */ + function httpError($errorCode, $errorMessage = null) { + $r = new HTTPResponse(); + $r->setBody($errorMessage); + $r->setStatuscode($errorCode); + return $r; + } +} \ No newline at end of file diff --git a/forms/Form.php b/forms/Form.php index a79bd26bb..4990a2c40 100644 --- a/forms/Form.php +++ b/forms/Form.php @@ -15,7 +15,7 @@ * @package forms * @subpackage core */ -class Form extends ViewableData { +class Form extends RequestHandlingData { public static $backup_post_data = false; @@ -115,6 +115,80 @@ class Form extends ViewableData { $this->setMessage($errorInfo['message'],$errorInfo['type']); } } + + static $url_handlers = array( + 'POST ' => 'httpSubmission', + 'GET ' => 'httpSubmission', + ); + + /** + * Handle a form submission. GET and POST requests behave identically + */ + function httpSubmission($request) { + $vars = $request->requestVars(); + + if(isset($funcName)) { + Form::set_current_action($funcName); + } + + // Populate the form + $this->loadDataFrom($vars, true); + + // Validate the form + if(!$this->validate()) { + if(Director::is_ajax()) { + return FormResponse::respond(); + } else { + Director::redirectBack(); + return; + } + } + + // Protection against CSRF attacks + if($this->securityTokenEnabled()) { + $securityID = Session::get('SecurityID'); + + if(!$securityID || !isset($vars['SecurityID']) || $securityID != $vars['SecurityID']) { + $this->httpError(400, "SecurityID doesn't match, possible CRSF attack."); + } + } + + // Determine the action button clicked + $funcName = null; + foreach($vars as $paramName => $paramVal) { + if(substr($paramName,0,7) == 'action_') { + // Break off querystring arguments included in the action + if(strpos($paramName,'?') !== false) { + list($paramName, $paramVars) = explode('?', $paramName, 2); + $newRequestParams = array(); + parse_str($paramVars, $newRequestParams); + $vars = array_merge((array)$vars, (array)$newRequestParams); + } + + // Cleanup action_, _x and _y from image fields + $funcName = preg_replace(array('/^action_/','/_x$|_y$/'),'',$paramName); + break; + } + } + + // If the action wasnt' set, choose the default on the form. + if(!isset($funcName) && $defaultAction = $this->defaultAction()){ + $funcName = $defaultAction->actionName(); + } + + if(isset($funcName)) { + $this->setButtonClicked($funcName); + } + + // First, try a handler method on the controller + if($this->controller->hasMethod($funcName)) { + return $this->controller->$funcName($vars, $this); + + // Otherwise, try a handler method on the form object + } else { + return $this->$funcName($vars, $this); + } + } /** * Convert this form into a readonly form @@ -385,15 +459,10 @@ class Form extends ViewableData { * @return string */ function FormAction() { - // "get" form needs ?executeForm added as a hidden field - if($this->formMethod == 'post') { - if($this->controller->hasMethod("FormObjectLink")) { - return $this->controller->FormObjectLink($this->name); - } else { - return $this->controller->Link() . "?executeForm=" . $this->name; - } + if($this->controller->hasMethod("FormObjectLink")) { + return $this->controller->FormObjectLink($this->name); } else { - return $this->controller->Link(); + return $this->controller->Link() . $this->name; } } @@ -509,7 +578,7 @@ class Form extends ViewableData { * This includes form validation, if it fails, we redirect back * to the form with appropriate error messages */ - function beforeProcessing(){ + function validate(){ if($this->validator){ $errors = $this->validator->validate(); @@ -526,7 +595,6 @@ class Form extends ViewableData { Convert::raw2js($error['messageType']) )); } - echo FormResponse::respond(); return false; } else { $data = $this->getData(); @@ -541,7 +609,6 @@ class Form extends ViewableData { 'data' => $data, )); - Director::redirectBack(); } return false; } diff --git a/forms/FormField.php b/forms/FormField.php index 66e3eef31..4f4d02e76 100644 --- a/forms/FormField.php +++ b/forms/FormField.php @@ -7,7 +7,7 @@ * @package forms * @subpackage core */ -class FormField extends ViewableData { +class FormField extends RequestHandlingData { protected $form; protected $name, $title, $value ,$message, $messageType, $extraClass; diff --git a/security/Member.php b/security/Member.php index f84f5933b..bb0e95734 100644 --- a/security/Member.php +++ b/security/Member.php @@ -542,6 +542,7 @@ class Member extends DataObject { parent::onAfterWrite(); if(isset($this->changed['Password']) && $this->changed['Password']) { +$_REQUEST['showqueries'] = 1; MemberPassword::log($this); } } diff --git a/security/Security.php b/security/Security.php index 92581f9da..7ed8906f0 100644 --- a/security/Security.php +++ b/security/Security.php @@ -269,7 +269,7 @@ class Security extends Controller { $tmpPage->URLSegment = "Security"; $tmpPage->ID = -1; // Set the page ID to -1 so we dont get the top level pages as its children - $controller = new Page_Controller($tmpPage); + $controller = new Page_Controller($this->urlParams, $this->urlTokeniser, $tmpPage); $controller->init(); //Controller::$currentController = $controller; diff --git a/tests/ControllerTest.php b/tests/ControllerTest.php new file mode 100644 index 000000000..31d9b1cca --- /dev/null +++ b/tests/ControllerTest.php @@ -0,0 +1,82 @@ +assertEquals("This is the main template. Content is 'default content'.", $response->getBody()); + } + + function testMethodActions() { + /* The Action can refer to a method that is called on the object. If a method returns an array, then it will be + used to customise the template data */ + $response = Director::test("ControllerTest_Controller/methodaction"); + $this->assertEquals("This is the main template. Content is 'methodaction content'.", $response->getBody()); + + /* If the method just returns a string, then that will be used as the response */ + $response = Director::test("ControllerTest_Controller/stringaction"); + $this->assertEquals("stringaction was called.", $response->getBody()); + } + + function testTemplateActions() { + /* If there is no method, it can be used to point to an alternative template. */ + $response = Director::test("ControllerTest_Controller/templateaction"); + $this->assertEquals("This is the template for templateaction. Content is 'default content'.", $response->getBody()); + } + + function testAllowedActions() { + $response = Director::test("ControllerTest_SecuredController/methodaction"); + $this->assertEquals(200, $response->getStatusCode()); + + $response = Director::test("ControllerTest_SecuredController/stringaction"); + $this->assertEquals(403, $response->getStatusCode()); + + $response = Director::test("ControllerTest_SecuredController/adminonly"); + $this->assertEquals(403, $response->getStatusCode()); + } +} + +/** + * Simple controller for testing + */ +class ControllerTest_Controller extends Controller { + public $Content = "default content"; + + function methodaction() { + return array( + "Content" => "methodaction content" + ); + } + + function stringaction() { + return "stringaction was called."; + } +} + +/** + * Controller with an $allowed_actions value + */ +class ControllerTest_SecuredController extends Controller { + static $allowed_actions = array( + "methodaction", + "adminonly" => "ADMIN", + ); + + public $Content = "default content"; + + function methodaction() { + return array( + "Content" => "methodaction content" + ); + } + + function stringaction() { + return "stringaction was called."; + } + + function adminonly() { + return "You must be an admin!"; + } +} \ No newline at end of file diff --git a/tests/RequestHandlingTest.php b/tests/RequestHandlingTest.php new file mode 100644 index 000000000..a6d7a533f --- /dev/null +++ b/tests/RequestHandlingTest.php @@ -0,0 +1,154 @@ +assertEquals("This is the controller", $response->getBody()); + + /* ID and OtherID are extracted from the URL and passed in $request->params. */ + $response = Director::test("testGoodBase1/method/1/2"); + $this->assertEquals("This is a method on the controller: 1, 2", $response->getBody()); + + /* In addition, these values are availalbe in $controller->urlParams. This is mainly for backward compatability. */ + $response = Director::test("testGoodBase1/legacymethod/3/4"); + $this->assertEquals("\$this->urlParams can be used, for backward compatibility: 3, 4", $response->getBody()); + } + + function testPostRequests() { + /* The HTTP Request handler can trigger special behaviour for GET and POST. */ + $response = Director::test("testGoodBase1/TestForm", array("MyField" => 3), null, "POST"); + $this->assertEquals("Form posted", $response->getBody()); + + $response = Director::test("testGoodBase1/TestForm"); + $this->assertEquals("Get request on form", $response->getBody()); + } + + function testRequestHandlerChaining() { + /* Request handlers can be chained, from Director to Controller to Form to FormField. Here, we can make a get + request on a FormField. */ + $response = Director::test("testGoodBase1/TestForm/fields/MyField"); + $this->assertEquals("MyField requested", $response->getBody()); + + /* We can also make a POST request on a form field, which could be used for in-place editing, for example. */ + $response = Director::test("testGoodBase1/TestForm/fields/MyField" ,array("MyField" => 5)); + $this->assertEquals("MyField posted, update to 5", $response->getBody()); + } + + function testBadBase() { + /* Without a double-slash indicator in the URL, the entire URL is popped off the stack. The controller's default + action handlers have been designed for this to an extend: simple actions can still be called. This is the set-up + of URL rules written before this new request handler. */ + $response = Director::test("testBadBase/method/1/2"); + $this->assertEquals("This is a method on the controller: 1, 2", $response->getBody()); + + $response = Director::test("testBadBase/TestForm", array("MyField" => 3), null, "POST"); + $this->assertEquals("Form posted", $response->getBody()); + + /* It won't, however, let you chain requests to access methods on forms, or form fields. In order to do that, + you need to have a // marker in your URL parsing rule */ + $response = Director::test("testBadBase/TestForm/fields/MyField"); + $this->assertNotEquals("MyField requested", $response->getBody()); + } +} + +/** + * Director rules for the test + */ +Director::addRules(50, array( + // If we don't request any variables, then the whole URL will get shifted off. This is fine, but it means that the + // controller will have to parse the Action from the URL itself. + 'testGoodBase1' => "RequestHandlingTest_Controller", + + // The double-slash indicates how much of the URL should be shifted off the stack. This is important for dealing + // with nested request handlers appropriately. + 'testGoodBase2//$Action/$ID/$OtherID' => "RequestHandlingTest_Controller", + + // By default, the entire URL will be shifted off. This creates a bit of backward-incompatability, but makes the + // URL rules much more explicit. + 'testBadBase/$Action/$ID/$OtherID' => "RequestHandlingTest_Controller", +)); + +/** + * Controller for the test + */ +class RequestHandlingTest_Controller extends Controller { + static $url_handlers = array( + // The double-slash is need here to ensure that + '$Action//$ID/$OtherID' => "handleAction", + ); + + function index($request) { + return "This is the controller"; + } + + function method($request) { + return "This is a method on the controller: " . $request->param('ID') . ', ' . $request->param('OtherID'); + } + + function legacymethod($request) { + return "\$this->urlParams can be used, for backward compatibility: " . $this->urlParams['ID'] . ', ' . $this->urlParams['OtherID']; + } + + function TestForm() { + return new RequestHandlingTest_Form($this, "TestForm", new FieldSet( + new RequestHandlingTest_FormField("MyField") + ), new FieldSet( + new FormAction("myAction") + )); + } +} + +/** + * Form for the test + */ +class RequestHandlingTest_Form extends Form { + static $url_handlers = array( + 'fields/$FieldName' => 'handleField', + "POST " => "handleSubmission", + "GET " => "handleGet", + ); + + function handleField($request) { + return $this->dataFieldByName($request->param('FieldName')); + } + + function handleSubmission($request) { + return "Form posted"; + } + + function handleGet($request) { + return "Get request on form"; + } +} + + +/** + * Form field for the test + */ +class RequestHandlingTest_FormField extends FormField { + static $url_handlers = array( + "POST " => "handleInPlaceEdit", + '' => 'handleField', + '$Action' => '$Action', + ); + + function test() { + return "Test method on $this->name"; + } + + function handleField() { + return "$this->name requested"; + } + + function handleInPlaceEdit($request) { + return "$this->name posted, update to " . $request->postVar($this->name); + } +} + diff --git a/tests/security/MemberTest.php b/tests/security/MemberTest.php index a2681769d..4323599f5 100644 --- a/tests/security/MemberTest.php +++ b/tests/security/MemberTest.php @@ -21,7 +21,7 @@ class MemberTest extends SapphireTest { $member->Password = "test3"; $member->write(); - + $passwords = DataObject::get("MemberPassword", "MemberID = $member->ID", "Created DESC, ID DESC")->getIterator(); $this->assertNotNull($passwords); $record = $passwords->rewind(); diff --git a/tests/templates/ControllerTest.ss b/tests/templates/ControllerTest.ss new file mode 100644 index 000000000..1d77261af --- /dev/null +++ b/tests/templates/ControllerTest.ss @@ -0,0 +1 @@ +This is the main template. Content is '$Content'. \ No newline at end of file diff --git a/tests/templates/ControllerTest_templateaction.ss b/tests/templates/ControllerTest_templateaction.ss new file mode 100644 index 000000000..64a166ab7 --- /dev/null +++ b/tests/templates/ControllerTest_templateaction.ss @@ -0,0 +1 @@ +This is the template for templateaction. Content is '$Content'. \ No newline at end of file