<?php /** @namespace RESTAPI * * Im Namensraum RESTAPI sind alle Klassen und Funktionen versammelt, * die für die RESTful Web Services von Stud.IP benötigt werden. */ namespace RESTAPI; use RESTAPI\Renderer\DefaultRenderer; /** * Die Aufgabe des Routers ist das Anlegen und Auswerten eines * Mappings von sogenannten Routen (Tupel aus HTTP-Methode und Pfad) * auf Code. * * Dazu werden zunächst Routen mittels der Funktion * Router::registerRoutes registriert. * * Wenn dann ein HTTP-Request eingeht, kann mithilfe von * Router::dispatch und HTTP-Methode bzw. Pfad der zugehörige Code * gefunden und ausgeführt werden. Der Router bildet aus dem * Rückgabewert des Codes ein Response-Objekt, das er als Ergebnis * zurück meldet. * * @code * $router = Router::getInstance(); * * // register a sample Route * $router->registerRoutes(new ExampleRoute); * * // dispatch to therein defined Routes * $response = $router->dispatch('/example', 'GET'); * * // render response * $response->output(); * * @endcode * * @author Jan-Hendrik Willms <tleilax+studip@gmail.com> * @author <mlunzena@uos.de> * @license GPL 2 or later * @see Inspired by http://blog.sosedoff.com/2009/07/04/simpe-php-url-routing-controller/ * @since Stud.IP 3.0 * @deprecated Since Stud.IP 5.0. Will be removed in Stud.IP 5.2. */ class Router { // instances are cached here protected static $instances = []; /** * Holds the user object of the user that is accessing the API. * This is null for nobody users. */ protected $user = null; /** * Returns (and if neccessary, initializes) a (cached) router object for an * optional consumer id. * * @param mixed $consumer_id ID of the consumer (defaults to 'global') * * @return Router returns the Router instance associated to the * consumer ID (or to the 'global' ID) */ public static function getInstance($consumer_id = null) { $consumer_id = $consumer_id ?: 'global'; if (!isset(self::$instances[$consumer_id])) { self::$instances[$consumer_id] = new self($consumer_id); } return self::$instances[$consumer_id]; } // All supported method need to be defined here protected static $supported_methods = [ 'get', 'post', 'put', 'delete', 'patch', 'options', 'head' ]; /** * Returns a list of all supported methods. * * @return array of methods as strings */ public static function getSupportedMethods() { return self::$supported_methods; } // registered routes by method and uri template protected $routes = []; // registered content renderers protected $renderers = []; // identified or forced content renderer protected $content_renderer = false; // default renderer protected $default_renderer = false; // registered conditions protected $conditions = []; // registered descriptions protected $descriptions = []; // registered consumers protected $consumers = []; // associated permissions protected $permissions = false; /** * Constructs the router. * * @param mixed $consumer_id the ID of the consumer this router * should associate to */ protected function __construct($consumer_id) { $this->permissions = ConsumerPermissions::get($consumer_id); $this->registerRenderer(new Renderer\DefaultRenderer); } /** * Registers a handler for a specific combination of request method * and uri template. * * @param String $request_method expected HTTP request method * @param String $uri_template expected URI template, for * example: \code "/user/:user_id/events" \endcode * @param Array $handler request handler array: * \code array($object, "methodName") \endcode * @param Array $conditions (optional) an associative * array using the name of * parameters as keys and regexps * as value * @param string $source (optional) this denotes the * origin of a route. Usually * either 'core' or 'plugin', but * defaults to 'unknown'. * @param bool $allow_nobody Whether the route can be accessed * as nobody user (true) or not (false). * Defaults to false. * * @return Router returns itself to allow chaining * @throws \Exception if passed HTTP request method is not supported */ public function register($request_method, $uri_template, $handler, $conditions = [], $source = 'unknown', $allow_nobody = false) { // Normalize method and test whether it's supported $request_method = mb_strtolower($request_method); if (!in_array($request_method, self::$supported_methods)) { throw new \Exception('Method "' . $request_method . '" is not supported.'); } // Initialize routes storage for this method if neccessary if (!isset($this->routes[$request_method])) { $this->routes[$request_method] = []; } // Normalize uri template (always starts with a slash) if ($uri_template[0] !== '/') { $uri_template = '/' . $uri_template; } // Sanitize conditions foreach ($conditions as $var => $pattern) { if ($pattern[0] !== $pattern[mb_strlen($pattern) - 1] || ctype_alnum($pattern[0])) { $conditions[$var] = '/' . $pattern . '/'; } } $this->routes[$request_method][$uri_template] = compact( 'handler', 'conditions', 'source', 'allow_nobody' ); // Return instance to allow chaining return $this; } /** * Registers the routes defined in a RouteMap instance using * docblock annotations (like @get) of its methods. * * \code * $router = \RESTAPI\Router::getInstance(); * * $router->registerRoutes(new ExampleRouteMap()); * \endcode * * @param RouteMap $map the RouteMap instance to register * * @return Router returns itself to allow chaining */ public function registerRoutes(RouteMap $map) { // Investigate object, define whether it's located in the core system // or a plugin, respect any defined class conditions and iterate // through it's methods to find any defined route $ref = new \ReflectionClass($map); $filename = $ref->getFilename(); $source = mb_strpos($filename, 'plugins_packages') !== false ? 'plugin' : 'core'; foreach (self::$supported_methods as $http_method) { foreach ($map->getRoutes($http_method) as $uri_template => $data) { // Register (and describe) route $this->register( $http_method, $uri_template, $data['handler'], $data['conditions'], $source, $data['allow_nobody'] ); if ($data['description']) { $this->describe( $uri_template, $data['description'], $http_method ); } } } return $this; } /** * Describe one or more routes. * * \code * $router = \RESTAPI\Router::getInstance(); * * // describe a single route * $router->describe('/foo', 'returns everything about foo', 'get'); * * // describe several routes that use the same path * $router->describe('/foo', array( * 'get' => 'returns everything about foo', * 'put' => 'updates all of foo', * 'delete' => 'empty up foo' * )); * * // describe several routes * $router->describe(array( * '/foo' => array( * 'get' => 'returns everything about foo', * 'put' => 'updates all of foo', * 'delete' => 'empty up foo'), * '/bar' => array(...), * )); * \endcode * * @param String|Array $uri_template URI template to describe or pass an * array to describe multiple routes. * @param String|null $description description of the route * @param String $method method to describe. * * @return Router returns instance of itself to allow chaining */ public function describe($uri_template, $description = null, $method = 'get') { // describe multiple routes at once if (func_num_args() === 1 && is_array($uri_template)) { foreach ($uri_template as $template => $description) { $this->describe($template, $description); } } // describe routes that use the same URI template elseif (func_num_args() === 2 && is_array($description)) { foreach ($description as $method => $desc) { $this->describe($uri_template, $desc, $method); } } // describe a single route else { if (!isset($this->descriptions[$uri_template])) { $this->descriptions[$uri_template] = []; } if (isset($this->routes[$method][$uri_template])) { $this->descriptions[$uri_template][$method] = $description; } else { // Try to find route with different method foreach ($this->routes as $m => $templates) { if (isset($templates[$uri_template])) { $this->descriptions[$uri_template][$m] = $description; break; } } } } return $this; } /** * Get list of registered routes - optionally with their descriptions. * * @param bool $describe (optional) include descriptions, * defaults to `false` * @param bool $check_access (optional) only show methods this router's * consumer is authorized to, * defaults to `true` * * @return array list of registered routes */ public function getRoutes($describe = false, $check_access = true) { $this->setupRoutes(); $result = []; foreach ($this->routes as $method => $routes) { foreach ($routes as $uri => $route) { if ($check_access && !$this->permissions->check($uri, $method)) { continue; } if (!isset($result[$uri])) { $result[$uri] = []; } if ($describe) { $result[$uri][$method] = [ 'description' => $this->descriptions[$uri][$method] ?: null, 'source' => $route['source'] ?: 'unknown', ]; } else { $result[$uri][] = $method; } } } ksort($result); if ($describe) { $result = array_map(function ($item) { ksort($item); return $item; }, $result); } return $result; } /** * Dispatches an URI across the defined routes and produces a * Response object which may then be send back (using #output). * * @param mixed $uri URI to dispatch (defaults to `$_SERVER['PATH_INFO']`) * @param String $method Request method (defaults to the method * of the actual HTTP request or "GET") * * @return Response a Response object containing status, headers * and body * @throws RouterException may throw such an exception if there * is no matching route (404) or if there * is one, but the consumer is not * authorized to it (403) */ public function dispatch($uri = null, $method = null) { $this->setupRoutes(); $uri = $this->normalizeDispatchURI($uri); $method = $this->normalizeRequestMethod($method); $content_renderer = $this->negotiateContent($uri); [$route, $parameters, $allow_nobody] = $this->matchRoute($uri, $method, $content_renderer); if (!$route) { //No route found for the combination of URI and method. //We return the allowed methods for the route in the HTTP header: $methods = $this->getMethodsForUri($uri); if (count($methods) > 0) { header('Allow: ' . implode(', ', $methods)); throw new RouterException(405); } else { //Route not found. throw new RouterException(404); } } //At this point, a route is found. //We need to check if it can be used as nobody user or not. if (!$route['allow_nobody'] && !$this->user) { //Nobody users aren't allowed for this route. throw new RouterException(401, 'Unauthorized (no consumer)'); } try { $response = $this->execute($route, $parameters); } catch (RouterHalt $halt) { $response = $halt->response; } $response->finish($content_renderer); return $response; } /** * Searches and registers available routes. */ private function setupRoutes() { // A bit ugly, I confess static $was_setup = false; if ($was_setup) { return; } $was_setup = true; // Register default routes $routes = [ 'Activity', 'Blubber', 'Clipboard', 'Consultations', 'Contacts', 'Course', 'Discovery', 'Events', 'Feedback', 'FileSystem', 'Forum', 'Messages', 'News', 'ResourceBooking', 'Resources', 'ResourceCategories', 'ResourcePermissions', 'ResourceProperties', 'ResourceRequest', 'RoomClipboard', 'Schedule', 'Semester', 'Studip', 'User', 'UserConfig', 'Wiki' ]; foreach ($routes as $route) { require_once "app/routes/$route.php"; $class = "\\RESTAPI\\Routes\\$route"; $this->registerRoutes(new $class); } // Register plugin routes $router = $this; $routes = array_flatten(\PluginEngine::sendMessage('RESTAPIPlugin', 'getRouteMaps')); array_walk( $routes, function ($route) use ($router) { $router->registerRoutes($route); } ); } /** * Takes a route and the parameters out of the requested path and * executes the handler of the route. * * @param array $route the matched route out of * Router::matchRoute; an array with keys * 'handler', 'conditions' and 'source' * @param array $parameters the matched parameters out of * Router::matchRoute; something like: * `array('user_id' => '23a21d...e78f')` * @return Response the resulting Response object which is then * polished in Router::dispatch */ protected function execute($route, $parameters) { $handler = $route['handler']; if (!is_object($handler[0])) { throw new \RuntimeException("Handler is not a method."); } $handler[0]->init($this, $route); if (method_exists($handler[0], 'before')) { $handler[0]->before($this, $handler, $parameters); } $result = call_user_func_array($handler, $parameters); if (method_exists($result, 'toArray')) { $result = $result->toArray(); } // $result is stronger than $response->body if (isset($result)) { $handler[0]->body($result); } if (method_exists($handler[0], 'after')) { $handler[0]->after($this, $parameters); } return $handler[0]->getResponse(); } /** * Registers a content renderer. * * @param DefaultRenderer $renderer instance of a content renderer * @param boolean $is_default (optional) set this * renderer as default?; * defaults to `false` * * @return Router returns itself to allow chaining */ public function registerRenderer($renderer, $is_default = false) { $this->renderers[$renderer->extension()] = $renderer; if ($is_default) { $this->default_renderer = $renderer; } return $this; } private function normalizeDispatchURI($uri) { return $uri === null ? $_SERVER['PATH_INFO'] : $uri; } private function normalizeRequestMethod($method) { return mb_strtolower($method ?: \Request::method() ?: 'get'); } /** * Negotiate content using the registered content renderers. The * first ContentRenderer that returns `true` when calling * ContentRenderer::shouldRespondTo gets the job. * * @param String $uri the URI to which the content renderers may respond * * @return ContentRenderer either a ContentRenderer that responds * to the URI or the default * ContentRenderer of this router. */ protected function negotiateContent($uri) { $content_renderer = null; foreach ($this->renderers as $renderer) { if ($renderer->shouldRespondTo($uri)) { $content_renderer = $renderer; break; } } if (!$content_renderer) { $content_renderer = $this->default_renderer ?: reset($this->renderers); } return $content_renderer; } /** * Tries to match a route given a URI and a HTTP request method. * * @param String $uri the URI to match * @param String $method the HTTP request method to match * @param DefaultRenderer $content_renderer the used * ContentRenderer which * is needed to remove * a file extension * * @return array an array containing the matched route and the * found parameters */ protected function matchRoute($uri, $method, $content_renderer) { $matched = null; $parameters = []; if (isset($this->routes[$method])) { if ($content_renderer->extension() && mb_strpos($uri, $content_renderer->extension()) !== false) { $uri = mb_substr($uri, 0, -mb_strlen($content_renderer->extension())); } foreach ($this->routes[$method] as $uri_template => $route) { if (!isset($route['uri_template'])) { $route['uri_template'] = new UriTemplate($uri_template, $route['conditions']); } if ($route['uri_template']->match($uri, $prmtrs)) { if (!$this->permissions->check($uri_template, $method)) { throw new RouterException(403, "Route not activated"); } $matched = $route; $parameters = $prmtrs; break; } } } return [$matched, $parameters]; } /** * Returns all methods the given uri responds to. * * @param String $uri the URI to match * * @return array of all of responding methods */ protected function getMethodsForUri($uri) { $methods = []; foreach ($this->routes as $method => $templates) { foreach ($templates as $uri_template => $route) { if (!isset($route['uri_template'])) { $route['uri_template'] = new UriTemplate($uri_template, $route['conditions']); } if ($route['uri_template']->match($uri) && $this->permissions->check($uri_template, $method)) { $methods[] = $method; } } } return array_map('strtoupper', $methods); } /** * Sets up the authentication for the router. */ public function setupAuth() { // Detect consumer $consumer = Consumer\Base::detectConsumer(); if (!$consumer) { return null; } $this->user = $consumer->getUser(); // Set authentication if present if ($this->user) { // Skip fake authentication if user is already logged in if ($GLOBALS['user']->id !== $this->user->id) { $GLOBALS['auth'] = new \Seminar_Auth(); $GLOBALS['auth']->auth = [ 'uid' => $this->user->user_id, 'uname' => $this->user->username, 'perm' => $this->user->perms, ]; $GLOBALS['user'] = new \Seminar_User($this->user); $GLOBALS['perm'] = new \Seminar_Perm(); $GLOBALS['MAIL_VALIDATE_BOX'] = false; } setTempLanguage($GLOBALS['user']->id); } return $this->user; } }