diff --git a/app/config/default.php b/app/config/default.php index 62099034..6f530ed8 100755 --- a/app/config/default.php +++ b/app/config/default.php @@ -424,4 +424,17 @@ 'entrypoints' => 'assets://entrypoints.json', 'manifest' => 'assets://manifest.json', ], + + /** + * ---------------------------------------------------------------------- + * Writable Stream Config + * ---------------------------------------------------------------------- + * Resource stream to check for write permission. True means it should be + * writeable, false means it should not be writable. + */ + 'writable' => [ + 'logs://' => true, + 'cache://' => true, + 'sessions://' => true, + ], ]; diff --git a/app/config/testing.php b/app/config/testing.php index 86077739..e774f0ce 100755 --- a/app/config/testing.php +++ b/app/config/testing.php @@ -74,4 +74,13 @@ 'session' => [ 'handler' => env('TEST_SESSION_HANDLER', 'array'), ], + + /* + * Don't force writable directories in tests. + */ + 'writable' => [ + 'logs://' => null, + 'cache://' => null, + 'sessions://' => null, + ], ]; diff --git a/app/src/Core.php b/app/src/Core.php index 9efd5c5b..b840e50e 100644 --- a/app/src/Core.php +++ b/app/src/Core.php @@ -56,6 +56,7 @@ use UserFrosting\Sprinkle\Core\Listeners\ModelInitiated; use UserFrosting\Sprinkle\Core\Listeners\ResourceLocatorInitiated; use UserFrosting\Sprinkle\Core\Listeners\SetRouteCaching; +use UserFrosting\Sprinkle\Core\Middlewares\FilePermissionMiddleware; use UserFrosting\Sprinkle\Core\Middlewares\LocaleMiddleware; use UserFrosting\Sprinkle\Core\Middlewares\SessionMiddleware; use UserFrosting\Sprinkle\Core\Middlewares\URIMiddleware; @@ -211,6 +212,7 @@ public function getMiddlewares(): array CsrfGuardMiddleware::class, SessionMiddleware::class, URIMiddleware::class, + FilePermissionMiddleware::class, ExceptionHandlerMiddleware::class, ]; } diff --git a/app/src/Middlewares/FilePermissionMiddleware.php b/app/src/Middlewares/FilePermissionMiddleware.php new file mode 100644 index 00000000..096c31be --- /dev/null +++ b/app/src/Middlewares/FilePermissionMiddleware.php @@ -0,0 +1,65 @@ +config->get('writable') as $stream => $assertWriteable) { + // Since config can't be removed, we skip if the value is null + if ($assertWriteable === null) { + continue; + } + + // Translate stream to file path + $file = $this->locator->findResource($stream); + + // Check if file exist and is writeable + if ($file === null || $assertWriteable !== is_writable($file)) { + // If file doesn't exist, we try to find the expected path + $expectedPath = $this->locator->findResource($stream, false, true); + + throw new BadConfigException("Stream $stream doesn't exist and is not writeable. Make sure path `$expectedPath` exist and is writeable."); + } + } + + return $handler->handle($request); + } +} diff --git a/app/tests/Unit/Middlewares/FilePermissionMiddlewareTest.php b/app/tests/Unit/Middlewares/FilePermissionMiddlewareTest.php new file mode 100644 index 00000000..e2238bb1 --- /dev/null +++ b/app/tests/Unit/Middlewares/FilePermissionMiddlewareTest.php @@ -0,0 +1,155 @@ + [ + 'foo://' => true, + ], + ]); + + /** @var RequestHandlerInterface */ + $handler = Mockery::mock(RequestHandlerInterface::class) + ->shouldNotReceive('handle') + ->getMock(); + + /** @var ServerRequestInterface */ + $request = Mockery::mock(ServerRequestInterface::class); + + /** @var ResourceLocatorInterface */ + $locator = Mockery::mock(ResourceLocatorInterface::class) + ->shouldReceive('findResource')->once()->with('foo://')->andReturn(null) + ->shouldReceive('findResource')->once()->with('foo://', false, true)->andReturn('app/foo') + ->getMock(); + + // Set Expectation + $this->expectException(BadConfigException::class); + $this->expectExceptionMessage("Stream foo:// doesn't exist and is not writeable. Make sure path `app/foo` exist and is writeable."); + + $middleware = new FilePermissionMiddleware($locator, $config); + $middleware->process($request, $handler); + } + + public function testWithWritable(): void + { + $config = new Config([ + 'writable' => [ + 'foo://' => true, + ], + ]); + + // Mock built-in is_writable + $reflection_class = new ReflectionClass(FilePermissionMiddleware::class); + $namespace = $reflection_class->getNamespaceName(); + PHPMockery::mock($namespace, 'is_writable')->andReturn(true); + + /** @var RequestHandlerInterface */ + $handler = Mockery::mock(RequestHandlerInterface::class) + ->shouldReceive('handle') + ->once() + ->with(Mockery::type(ServerRequestInterface::class)) + ->andReturn(Mockery::mock(ResponseInterface::class)) + ->getMock(); + + /** @var ServerRequestInterface */ + $request = Mockery::mock(ServerRequestInterface::class); + + /** @var ResourceLocatorInterface */ + $locator = Mockery::mock(ResourceLocatorInterface::class) + ->shouldReceive('findResource')->once()->with('foo://')->andReturn('app/foo') + ->getMock(); + + $middleware = new FilePermissionMiddleware($locator, $config); + $middleware->process($request, $handler); + } + + public function testWithNotWritable(): void + { + $config = new Config([ + 'writable' => [ + 'foo://' => true, + ], + ]); + + // Mock built-in is_writable + $reflection_class = new ReflectionClass(FilePermissionMiddleware::class); + $namespace = $reflection_class->getNamespaceName(); + PHPMockery::mock($namespace, 'is_writable')->andReturn(false); + + /** @var RequestHandlerInterface */ + $handler = Mockery::mock(RequestHandlerInterface::class) + ->shouldNotReceive('handle') + ->getMock(); + + /** @var ServerRequestInterface */ + $request = Mockery::mock(ServerRequestInterface::class); + + /** @var ResourceLocatorInterface */ + $locator = Mockery::mock(ResourceLocatorInterface::class) + ->shouldReceive('findResource')->once()->with('foo://')->andReturn('app/foo') + ->shouldReceive('findResource')->once()->with('foo://', false, true)->andReturn('app/foo') + ->getMock(); + + // Set Expectation + $this->expectException(BadConfigException::class); + $this->expectExceptionMessage("Stream foo:// doesn't exist and is not writeable. Make sure path `app/foo` exist and is writeable."); + + $middleware = new FilePermissionMiddleware($locator, $config); + $middleware->process($request, $handler); + } + + public function testWithNotConfig(): void + { + $config = new Config([ + 'writable' => [ + 'foo://' => null, + ], + ]); + + /** @var RequestHandlerInterface */ + $handler = Mockery::mock(RequestHandlerInterface::class) + ->shouldNotReceive('handle') + ->once() + ->with(Mockery::type(ServerRequestInterface::class)) + ->andReturn(Mockery::mock(ResponseInterface::class)) + ->getMock(); + + /** @var ServerRequestInterface */ + $request = Mockery::mock(ServerRequestInterface::class); + + /** @var ResourceLocatorInterface */ + $locator = Mockery::mock(ResourceLocatorInterface::class); + + $middleware = new FilePermissionMiddleware($locator, $config); + $middleware->process($request, $handler); + } +}