diff --git a/src/Control/Director.php b/src/Control/Director.php index 2514e155b58..850b42f33fa 100644 --- a/src/Control/Director.php +++ b/src/Control/Director.php @@ -4,7 +4,9 @@ use SilverStripe\CMS\Model\SiteTree; use SilverStripe\Control\Middleware\HTTPMiddlewareAware; +use SilverStripe\Control\Middleware\RegisteredEventsMiddleware; use SilverStripe\Core\Config\Configurable; +use SilverStripe\Core\CoreKernel; use SilverStripe\Core\Environment; use SilverStripe\Core\Extensible; use SilverStripe\Core\Injector\Injectable; @@ -85,6 +87,15 @@ class Director implements TemplateGlobalProvider */ private static $alternate_base_url; + /** + * Environments that forceWWW() works in + * + * @var array + */ + private static $force_www_envs = [ + CoreKernel::LIVE, + ]; + /** * Base url to populate if cannot be determined otherwise. * Supports back-ticked vars; E.g. '`SS_BASE_URL`' @@ -242,7 +253,7 @@ public static function mockRequest( // If a port is mentioned in the absolute URL, be sure to add that into the HTTP host $newVars['_SERVER']['HTTP_HOST'] = isset($bits['port']) - ? $bits['host'].':'.$bits['port'] + ? $bits['host'] . ':' . $bits['port'] : $bits['host']; } @@ -697,10 +708,10 @@ public static function is_absolute_url($url) { // Strip off the query and fragment parts of the URL before checking if (($queryPosition = strpos($url, '?')) !== false) { - $url = substr($url, 0, $queryPosition-1); + $url = substr($url, 0, $queryPosition - 1); } if (($hashPosition = strpos($url, '#')) !== false) { - $url = substr($url, 0, $hashPosition-1); + $url = substr($url, 0, $hashPosition - 1); } $colonPosition = strpos($url, ':'); $slashPosition = strpos($url, '/'); @@ -809,7 +820,7 @@ public static function absoluteBaseURLWithAuth(HTTPRequest $request = null) $login = "$_SERVER[PHP_AUTH_USER]:$_SERVER[PHP_AUTH_PW]@"; } - return Director::protocol($request) . $login . static::host($request) . Director::baseURL(); + return Director::protocol($request) . $login . static::host($request) . Director::baseURL(); } /** @@ -855,26 +866,29 @@ protected static function force_redirect($destURL) * * @param array $patterns Array of regex patterns to match URLs that should be HTTPS. * @param string $secureDomain Secure domain to redirect to. Defaults to the current domain. - * @return bool true if already on SSL, false if doesn't match patterns (or cannot redirect) - * @throws HTTPResponse_Exception Throws exception with redirect, if successful + * @param HTTPRequest|null $request Request object to check + * @return bool true if already on SSL, false if doesn't match patterns (or cannot redirect), + * or null if could not immediately redirect. */ - public static function forceSSL($patterns = null, $secureDomain = null) + public static function forceSSL($patterns = null, $secureDomain = null, HTTPRequest $request = null) { + // If request isn't available register promise + if (!$request) { + return static::whenRequestIsAvailable(function (HTTPRequest $request) use ($patterns, $secureDomain) { + return self::forceSSL($patterns, $secureDomain, $request); + }, __METHOD__); + } + // Already on SSL - if (static::is_https()) { + if (static::is_https($request)) { return true; } - // Can't redirect without a url - if (!isset($_SERVER['REQUEST_URI'])) { - return false; - } + $relativeURL = $request->getURL(true); + // protect portions of the site based on the pattern if ($patterns) { $matched = false; - $relativeURL = self::makeRelative(Director::absoluteURL($_SERVER['REQUEST_URI'])); - - // protect portions of the site based on the pattern foreach ($patterns as $pattern) { if (preg_match($pattern, $relativeURL)) { $matched = true; @@ -888,9 +902,9 @@ public static function forceSSL($patterns = null, $secureDomain = null) // if an domain is specified, redirect to that instead of the current domain if (!$secureDomain) { - $secureDomain = static::host(); + $secureDomain = static::host($request); } - $url = 'https://' . $secureDomain . $_SERVER['REQUEST_URI']; + $url = Controller::join_links("https://{$secureDomain}", self::baseURL(), $relativeURL); // Force redirect self::force_redirect($url); @@ -899,16 +913,30 @@ public static function forceSSL($patterns = null, $secureDomain = null) /** * Force a redirect to a domain starting with "www." + * + * @param HTTPRequest $request */ - public static function forceWWW() + public static function forceWWW(HTTPRequest $request = null) { - if (!Director::isDev() && !Director::isTest() && strpos(static::host(), 'www') !== 0) { - $destURL = str_replace( - Director::protocol(), - Director::protocol() . 'www.', - Director::absoluteURL($_SERVER['REQUEST_URI']) - ); + // Skip if not in allowed envs + $allowed = static::config()->get('force_www_envs'); + if ($allowed && !in_array(static::get_environment_type(), $allowed)) { + return; + } + + // If request isn't available register promise + if (!$request) { + static::whenRequestIsAvailable(function (HTTPRequest $request) { + static::forceWWW($request); + }, __METHOD__); + return; + } + $host = static::host($request); + if (strpos($host, 'www') !== 0) { + $protocol = self::protocol($request); + $url = self::absoluteURL($request->getURL(true)); + $destURL = str_replace($protocol, $protocol . 'www.', $url); self::force_redirect($destURL); } } @@ -1021,4 +1049,25 @@ protected static function currentRequest(HTTPRequest $request = null) } return $request; } + + /** + * Promise to invoke the given callback once the request is available + * + * @param callable $callback + * @param string $event Optional event name + * @return mixed Result of $callback, or null if could not be immediately called + */ + public static function whenRequestIsAvailable($callback, $event = null) + { + // Can immediately resolve + $request = static::currentRequest(); + if ($request) { + return $callback($request); + } + + // Resolve later + $handler = RegisteredEventsMiddleware::singleton(); + $handler->registerEvent($callback, $event); + return null; + } } diff --git a/tests/php/Control/DirectorTest.php b/tests/php/Control/DirectorTest.php index 01cc37d71d4..bc2b9d8a510 100644 --- a/tests/php/Control/DirectorTest.php +++ b/tests/php/Control/DirectorTest.php @@ -7,7 +7,7 @@ use SilverStripe\Control\HTTPRequestBuilder; use SilverStripe\Control\HTTPResponse; use SilverStripe\Control\HTTPResponse_Exception; -use SilverStripe\Control\Middleware\HTTPMiddleware; +use SilverStripe\Control\Middleware\RegisteredEventsMiddleware; use SilverStripe\Control\Middleware\RequestHandlerMiddlewareAdapter; use SilverStripe\Control\Middleware\TrustedProxyMiddleware; use SilverStripe\Control\RequestProcessor; @@ -412,19 +412,71 @@ public function testRouteParams() ); } + public function testForceWWW() + { + Director::config() + ->set('alternate_base_url', 'http://mysite.com/') + ->set('force_www_envs', []); + + $this->expectExceptionRedirect('http://www.mysite.com/some-url'); + Director::mockRequest(function ($request) { + Injector::inst()->registerService($request, HTTPRequest::class); + Director::forceWWW(); + }, '/some-url'); + } + + public function testPromisedForceWWW() + { + Director::config() + ->set('alternate_base_url', 'http://mysite.com/') + ->set('force_www_envs', []); + Director::forceWWW(); + + // Promise is registered but not resolved + $middleware = RegisteredEventsMiddleware::singleton(); + $this->assertTrue($middleware->hasEvent(Director::class.'::forceWWW')); + + // Middleware forces the redirection eventually + $this->expectExceptionRedirect('http://www.mysite.com/some-url'); + Director::mockRequest(function ($request) use ($middleware) { + $middleware->process($request, function ($request) { + return $request; + }); + }, '/some-url'); + } + public function testForceSSLProtectsEntireSite() { $this->expectExceptionRedirect('https://www.mysite.com/some-url'); - Director::mockRequest(function () { + Director::mockRequest(function ($request) { + Injector::inst()->registerService($request, HTTPRequest::class); Director::forceSSL(); }, '/some-url'); } + public function testPromisedForceSSL() + { + Director::forceSSL(); + + // Promise is registered but not resolved + $middleware = RegisteredEventsMiddleware::singleton(); + $this->assertTrue($middleware->hasEvent(Director::class.'::forceSSL')); + + // Middleware forces the redirection eventually + $this->expectExceptionRedirect('https://www.mysite.com/some-url'); + Director::mockRequest(function ($request) use ($middleware) { + $middleware->process($request, function ($request) { + return $request; + }); + }, '/some-url'); + } + public function testForceSSLOnTopLevelPagePattern() { // Expect admin to trigger redirect $this->expectExceptionRedirect('https://www.mysite.com/admin'); - Director::mockRequest(function () { + Director::mockRequest(function (HTTPRequest $request) { + Injector::inst()->registerService($request, HTTPRequest::class); Director::forceSSL(array('/^admin/')); }, '/admin'); } @@ -433,7 +485,8 @@ public function testForceSSLOnSubPagesPattern() { // Expect to redirect to security login page $this->expectExceptionRedirect('https://www.mysite.com/Security/login'); - Director::mockRequest(function () { + Director::mockRequest(function (HTTPRequest $request) { + Injector::inst()->registerService($request, HTTPRequest::class); Director::forceSSL(array('/^Security/')); }, '/Security/login'); } @@ -441,12 +494,14 @@ public function testForceSSLOnSubPagesPattern() public function testForceSSLWithPatternDoesNotMatchOtherPages() { // Not on same url should not trigger redirect - Director::mockRequest(function () { + Director::mockRequest(function (HTTPRequest $request) { + Injector::inst()->registerService($request, HTTPRequest::class); $this->assertFalse(Director::forceSSL(array('/^admin/'))); }, Director::baseURL() . 'normal-page'); // nested url should not triger redirect either - Director::mockRequest(function () { + Director::mockRequest(function (HTTPRequest $request) { + Injector::inst()->registerService($request, HTTPRequest::class); $this->assertFalse(Director::forceSSL(array('/^admin/', '/^Security/'))); }, Director::baseURL() . 'just-another-page/sub-url'); } @@ -456,6 +511,7 @@ public function testForceSSLAlternateDomain() // Ensure that forceSSL throws the appropriate exception $this->expectExceptionRedirect('https://secure.mysite.com/admin'); Director::mockRequest(function (HTTPRequest $request) { + Injector::inst()->registerService($request, HTTPRequest::class); return Director::forceSSL(array('/^admin/'), 'secure.mysite.com'); }, Director::baseURL() . 'admin'); }