Source of file Controller.php
Size: 16,278 Bytes - Last Modified: 2021-12-23T10:31:47+00:00
/var/www/docs.ssmods.com/process/src/src/Controller.php
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550 | <?php namespace SilverStripe\GraphQL; use Exception; use SilverStripe\Assets\Storage\GeneratedAssetHandler; use SilverStripe\Control\Controller as BaseController; use SilverStripe\Control\Director; use SilverStripe\Control\HTTPRequest; use SilverStripe\Control\HTTPResponse; use SilverStripe\Control\NullHTTPRequest; use SilverStripe\Core\Config\Config; use SilverStripe\Core\Flushable; use SilverStripe\Core\Injector\Injector; use SilverStripe\GraphQL\Auth\Handler; use SilverStripe\GraphQL\Dev\State\DisableTypeCacheState; use SilverStripe\GraphQL\Scaffolding\StaticSchema; use SilverStripe\ORM\Connect\DatabaseException; use SilverStripe\Security\Member; use SilverStripe\Security\Permission; use SilverStripe\Security\SecurityToken; use SilverStripe\Versioned\Versioned; use LogicException; /** * Top level controller for handling graphql requests. * @todo CSRF protection (or token-based auth) * @skipUpgrade */ class Controller extends BaseController implements Flushable { const CACHE_FILENAME = 'types.graphql'; /** * Cors default config * * @config * @var array */ private static $cors = [ 'Enabled' => false, // Off by default 'Allow-Origin' => [], // List of all allowed origins; Deny by default 'Allow-Headers' => 'Authorization, Content-Type', 'Allow-Methods' => 'GET, POST, OPTIONS', 'Allow-Credentials' => '', 'Max-Age' => 86400, // 86,400 seconds = 1 day. ]; /** * If true, store the fragment JSON in a flat file in assets/ * @var bool * @config */ private static $cache_types_in_filesystem = false; /** * Toggles caching types to the file system on flush * This is set to false in test state @see DisableTypeCacheState * * @var bool * @config */ private static $cache_on_flush = true; /** * @var Manager */ protected $manager; /** * @var GeneratedAssetHandler */ protected $assetHandler; /** * Override the default cors config per instance * @var array */ protected $corsConfig = []; /** * @param Manager $manager */ public function __construct(Manager $manager = null) { parent::__construct(); $this->manager = $manager; if ($this->manager && $this->manager->getSchemaKey()) { // Side effect. This isn't ideal, but having multiple instances of StaticSchema // is a massive architectural change. StaticSchema::reset(); $this->manager->configure(); } } /** * Handles requests to the index action (e.g. /graphql) * * @param HTTPRequest $request * @return HTTPResponse */ public function index(HTTPRequest $request) { if (class_exists(Versioned::class)) { $stage = $request->param('Stage'); if ($stage && in_array($stage, [Versioned::DRAFT, Versioned::LIVE])) { Versioned::set_stage($stage); } } // Check for a possible CORS preflight request and handle if necessary // Refer issue 66: https://github.com/silverstripe/silverstripe-graphql/issues/66 if ($request->httpMethod() === 'OPTIONS') { return $this->handleOptions($request); } // Main query handling try { $manager = $this->getManager($request); // Parse input list($query, $variables) = $this->getRequestQueryVariables($request); // Run query $result = $manager->query($query, $variables); } catch (Exception $exception) { $error = ['message' => $exception->getMessage()]; if (Director::isDev()) { $error['code'] = $exception->getCode(); $error['file'] = $exception->getFile(); $error['line'] = $exception->getLine(); $error['trace'] = $exception->getTrace(); } $result = [ 'errors' => [$error] ]; } $response = $this->addCorsHeaders($request, new HTTPResponse(json_encode($result))); return $response->addHeader('Content-Type', 'application/json'); } /** * @param HTTPRequest $request * @return Manager */ public function getManager($request = null) { $manager = null; if (!$request) { $request = $this->getRequest(); } if ($this->manager) { $manager = $this->manager; } else { // Get a service rather than an instance (to allow procedural configuration) $config = Config::inst()->get(static::class, 'schema'); $manager = Manager::createFromConfig($config); } $this->applyManagerContext($manager, $request); $this->setManager($manager); return $manager; } /** * @param Manager $manager * @return $this */ public function setManager($manager) { $this->manager = $manager; return $this; } /** * @param GeneratedAssetHandler $handler * @return $this */ public function setAssetHandler(GeneratedAssetHandler $handler) { $this->assetHandler = $handler; return $this; } /** * @return GeneratedAssetHandler */ public function getAssetHandler() { return $this->assetHandler; } /** * Get an instance of the authorization Handler to manage any authentication requirements * * @return Handler */ public function getAuthHandler() { return new Handler; } /** * @return string */ public function getToken() { return $this->getRequest()->getHeader('X-CSRF-TOKEN'); } /** * Process the CORS config options and add the appropriate headers to the response. * * @param HTTPRequest $request * @param HTTPResponse $response * @return HTTPResponse */ public function addCorsHeaders(HTTPRequest $request, HTTPResponse $response) { $corsConfig = $this->getMergedCorsConfig(); // If CORS is disabled don't add the extra headers. Simply return the response untouched. if (empty($corsConfig['Enabled'])) { return $response; } // Calculate origin $origin = $this->getRequestOrigin($request); // Check if valid $allowedOrigins = (array)$corsConfig['Allow-Origin']; $originAuthorised = $this->validateOrigin($origin, $allowedOrigins); if (!$originAuthorised) { $this->httpError(403, "Access Forbidden"); } $response->addHeader('Access-Control-Allow-Origin', $origin); $response->addHeader('Access-Control-Allow-Headers', $corsConfig['Allow-Headers']); $response->addHeader('Access-Control-Allow-Methods', $corsConfig['Allow-Methods']); $response->addHeader('Access-Control-Max-Age', $corsConfig['Max-Age']); if (isset($corsConfig['Allow-Credentials'])) { $response->addHeader('Access-Control-Allow-Credentials', $corsConfig['Allow-Credentials']); } return $response; } /** * @return array */ public function getCorsConfig(): array { return $this->corsConfig; } /** * @return array */ public function getMergedCorsConfig(): array { $defaults = Config::inst()->get(static::class, 'cors'); $override = $this->corsConfig; return array_merge($defaults, $override); } /** * @param array $config * @return $this */ public function setCorsConfig(array $config): self { $this->corsConfig = array_merge($this->corsConfig, $config); return $this; } /** * Validate an origin matches a set of allowed origins * * @param string $origin Origin string * @param array $allowedOrigins List of allowed origins * @return bool */ protected function validateOrigin($origin, $allowedOrigins) { if (empty($allowedOrigins) || empty($origin)) { return false; } foreach ($allowedOrigins as $allowedOrigin) { if ($allowedOrigin === '*') { return true; } if (strcasecmp($allowedOrigin, $origin) === 0) { return true; } } return false; } /** * @param Manager $manager * @param HTTPRequest $request * @throws Exception */ protected function applyManagerContext(Manager $manager, HTTPRequest $request) { // Add request context to Manager $manager->addContext('token', $this->getToken()); $method = null; if ($request->isGET()) { $method = 'GET'; } elseif ($request->isPOST()) { $method = 'POST'; } $manager->addContext('httpMethod', $method); // Check and validate user for this request $member = $this->getRequestUser($request); if ($member) { $manager->setMember($member); } } /** * Get (or infer) value of Origin header * * @param HTTPRequest $request * @return string|null */ protected function getRequestOrigin(HTTPRequest $request) { // Prefer Origin header $origin = $request->getHeader('Origin'); if ($origin) { return $origin; } // Check referer $referer = $request->getHeader('Referer'); if ($referer) { // Extract protocol, hostname, and port $refererParts = parse_url($referer); if (!$refererParts) { return null; } // Rebuild $origin = $refererParts['scheme'] . '://' . $refererParts['host']; if (isset($refererParts['port'])) { $origin .= ':' . $refererParts['port']; } return $origin; } return null; } /** * Response for HTTP OPTIONS request * * @param HTTPRequest $request * @return HTTPResponse */ protected function handleOptions(HTTPRequest $request) { $response = HTTPResponse::create(); $corsConfig = Config::inst()->get(self::class, 'cors'); if ($corsConfig['Enabled']) { // CORS config is enabled and the request is an OPTIONS pre-flight. // Process the CORS config and add appropriate headers. $this->addCorsHeaders($request, $response); } else { // CORS is disabled but we have received an OPTIONS request. This is not a valid request method in this // situation. Return a 405 Method Not Allowed response. $this->httpError(405, "Method Not Allowed"); } return $response; } /** * Parse query and variables from the given request * * @param HTTPRequest $request * @return array Array containing query and variables as a pair * @throws LogicException */ protected function getRequestQueryVariables(HTTPRequest $request) { $contentType = $request->getHeader('content-type'); $isJson = preg_match('#^application/json\b#', $contentType); if ($isJson) { $rawBody = $request->getBody(); $data = json_decode($rawBody ?: '', true); $query = isset($data['query']) ? $data['query'] : null; $id = isset($data['id']) ? $data['id'] : null; $variables = isset($data['variables']) ? (array)$data['variables'] : null; } else { $query = $request->requestVar('query'); $id = $request->requestVar('id'); $variables = json_decode($request->requestVar('variables'), true); } if ($id) { if ($query) { throw new LogicException('Cannot pass a query when an ID has been specified.'); } $query = $this->manager->getQueryFromPersistedID($id); } return [$query, $variables]; } /** * Get user and validate for this request * * @param HTTPRequest $request * @return Member * @throws Exception */ protected function getRequestUser(HTTPRequest $request) { // Check authentication $member = $this->getAuthHandler()->requireAuthentication($request); // Check authorisation $permissions = $request->param('Permissions'); if (!$permissions) { return $member; } // If permissions requested require authentication if (!$member) { throw new Exception("Authentication required"); } // Check authorisation for this member $allowed = Permission::checkMember($member, $permissions); if (!$allowed) { throw new Exception("Not authorised"); } return $member; } /** * Introspect the schema and persist it to the filesystem * @throws Exception */ public function writeSchemaToFilesystem() { if (Injector::inst()->has(HTTPRequest::class)) { $request = Injector::inst()->get(HTTPRequest::class); } else { $request = new NullHTTPRequest(); } $manager = $this->getManager($request); try { $types = StaticSchema::inst()->introspectTypes($manager); } catch (Exception $e) { throw new Exception(sprintf( 'There was an error caching the GraphQL types: %s', $e->getMessage() )); } $this->writeTypes(json_encode($types)); } public function removeSchemaFromFilesystem() { if (!$this->getAssetHandler()) { return; } $this->getAssetHandler()->removeContent($this->generateCacheFilename()); } /** * @param string $content */ public function writeTypes($content) { if (!$this->getAssetHandler()) { return; } $this->getAssetHandler()->setContent($this->generateCacheFilename(), $content); } /** * Write the types json to a flat file, if silverstripe/assets is available */ public function processTypeCaching() { if ($this->config()->cache_types_in_filesystem) { $this->writeSchemaToFilesystem(); } else { $this->removeSchemaFromFilesystem(); } } public static function flush() { if (!self::config()->get('cache_on_flush')) { return; } // This is a bit of a hack to find all registered GraphQL servers. Depends on them // being routed through Director. $routes = Director::config()->get('rules'); foreach ($routes as $pattern => $controllerInfo) { $routeClass = (is_string($controllerInfo)) ? $controllerInfo : $controllerInfo['Controller']; if (stristr($routeClass, Controller::class) !== false) { try { $inst = Injector::inst()->convertServiceProperty($routeClass); if ($inst instanceof Controller) { /* @var Controller $inst */ $inst->processTypeCaching(); } } catch (DatabaseException $e) { // Allow failures on table doesn't exist or no database selected as we're flushing in first DB build $messageByLine = explode(PHP_EOL, $e->getMessage()); // Get the last line $last = array_pop($messageByLine); if (strpos($last, 'No database selected') === false && !preg_match('/\s*(table|relation) .* does(n\'t| not) exist/i', $last) ) { throw $e; } } } } } /** * @return string */ protected function generateCacheFilename() { return $this->getManager()->getSchemaKey() . '.' . self::CACHE_FILENAME; } } |