diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 829fb07..4a601ac 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -111,7 +111,7 @@ parameters: path: src/ImageStorage.php - - message: "#^Parameter \\#5 \\$props of class Contributte\\\\ImageStorage\\\\Image constructor expects array\\, array\\ given\\.$#" + message: "#^Parameter \\#6 \\$props of class Contributte\\\\ImageStorage\\\\Image constructor expects array\\, array\\ given\\.$#" count: 2 path: src/ImageStorage.php diff --git a/src/DI/ImageStorageExtension.php b/src/DI/ImageStorageExtension.php index 0d0d406..c3ce1b0 100644 --- a/src/DI/ImageStorageExtension.php +++ b/src/DI/ImageStorageExtension.php @@ -6,6 +6,7 @@ use Contributte\ImageStorage\Latte\LatteExtension; use Nette\DI\CompilerExtension; use Nette\DI\Definitions\FactoryDefinition; +use Nette\DI\Definitions\Statement; use Nette\Schema\Expect; use Nette\Schema\Schema; use stdClass; @@ -37,10 +38,13 @@ public function loadConfiguration(): void $config = $this->getConfig(); $config->orig_path ??= $config->data_path; + $arguments = (array) $config; + $arguments['httpRequest'] = new Statement('@Nette\Http\IRequest'); + $builder->addDefinition($this->prefix('storage')) ->setType(ImageStorage::class) ->setFactory(ImageStorage::class) - ->setArguments((array) $config); + ->setArguments($arguments); } public function beforeCompile(): void diff --git a/src/Image.php b/src/Image.php index 7874526..8c638a7 100644 --- a/src/Image.php +++ b/src/Image.php @@ -24,15 +24,18 @@ class Image private bool $friendly_url = false; + private string $basePath = ''; + /** * @param bool[]|string[]|ImageNameScript[]|null[] $props */ - public function __construct(bool $friendly_url, string $data_dir, string $data_path, string $identifier, array $props = []) + public function __construct(bool $friendly_url, string $data_dir, string $data_path, string $identifier, string $basePath = '', array $props = []) { $this->data_dir = $data_dir; $this->data_path = $data_path; $this->identifier = $identifier; $this->friendly_url = $friendly_url; + $this->basePath = $basePath; if (stripos($this->identifier, '/') === 0) { $this->identifier = substr($this->identifier, 1); @@ -67,11 +70,17 @@ public function getQuery(): string public function createLink(): string { + $parts = $this->basePath !== '' ? [$this->basePath] : []; + if ($this->friendly_url) { - return implode('/', [$this->data_dir, $this->getScript()->toQuery()]); + $parts[] = $this->data_dir; + $parts[] = $this->getScript()->toQuery(); + } else { + $parts[] = $this->data_dir; + $parts[] = $this->identifier; } - return implode('/', [$this->data_dir, $this->identifier]); + return implode('/', $parts); } public function getScript(): ImageNameScript diff --git a/src/ImageStorage.php b/src/ImageStorage.php index e34e851..2b1b78a 100644 --- a/src/ImageStorage.php +++ b/src/ImageStorage.php @@ -7,6 +7,7 @@ use Contributte\ImageStorage\Exception\ImageStorageException; use DirectoryIterator; use Nette\Http\FileUpload; +use Nette\Http\IRequest; use Nette\SmartObject; use Nette\Utils\FileSystem; use Nette\Utils\Image as NetteImage; @@ -38,6 +39,8 @@ class ImageStorage private bool $friendly_url; + private ?IRequest $httpRequest; + private int $mask = 0775; /** @var int[] */ @@ -62,7 +65,8 @@ public function __construct( int $quality, string $default_transform, string $noimage_identifier, - bool $friendly_url + bool $friendly_url, + ?IRequest $httpRequest = null ) { $this->data_path = $data_path; @@ -74,6 +78,7 @@ public function __construct( $this->default_transform = $default_transform; $this->noimage_identifier = $noimage_identifier; $this->friendly_url = $friendly_url; + $this->httpRequest = $httpRequest; } public function delete(mixed $arg, bool $onlyChangedImages = false): void @@ -125,7 +130,7 @@ public function saveUpload(FileUpload $upload, string $namespace, ?string $check $upload->move($path); - return new Image($this->friendly_url, $this->data_dir, $this->data_path, $identifier, [ + return new Image($this->friendly_url, $this->data_dir, $this->data_path, $identifier, $this->getBasePath(), [ 'sha' => $checksum, 'name' => self::fixName($upload->getUntrustedName()), ]); @@ -145,7 +150,7 @@ public function saveContent(mixed $content, string $name, string $namespace, ?st file_put_contents($path, $content, LOCK_EX); - return new Image($this->friendly_url, $this->data_dir, $this->data_path, $identifier, [ + return new Image($this->friendly_url, $this->data_dir, $this->data_path, $identifier, $this->getBasePath(), [ 'sha' => $checksum, 'name' => self::fixName($name), ]); @@ -175,7 +180,7 @@ public function fromIdentifier(mixed $args): Image @copy($orig_file, $data_file); } - return new Image($this->friendly_url, $this->data_dir, $this->data_path, $identifier); + return new Image($this->friendly_url, $this->data_dir, $this->data_path, $identifier, $this->getBasePath()); } preg_match('/(\d+)?x(\d+)?(crop(\d+)x(\d+)x(\d+)x(\d+))?/', $args[1], $matches); @@ -248,7 +253,7 @@ public function fromIdentifier(mixed $args): Image ); } - return new Image($this->friendly_url, $this->data_dir, $this->data_path, $identifier, ['script' => $script]); + return new Image($this->friendly_url, $this->data_dir, $this->data_path, $identifier, $this->getBasePath(), ['script' => $script]); } /** @@ -282,7 +287,7 @@ public function getNoImage(bool $return_image = false): Image|array } if ($return_image) { - return new Image($this->friendly_url, $this->data_dir, $this->data_path, $identifier); + return new Image($this->friendly_url, $this->data_dir, $this->data_path, $identifier, $this->getBasePath()); } $script = ImageNameScript::fromIdentifier($identifier); @@ -291,7 +296,7 @@ public function getNoImage(bool $return_image = false): Image|array } if ($return_image) { - return new Image($this->friendly_url, $this->data_dir, $this->data_path, $this->noimage_identifier); + return new Image($this->friendly_url, $this->data_dir, $this->data_path, $this->noimage_identifier, $this->getBasePath()); } return [$script, $file]; @@ -302,6 +307,15 @@ public function setFriendlyUrl(bool $friendly_url = true): void $this->friendly_url = $friendly_url; } + public function getBasePath(): string + { + if ($this->httpRequest === null) { + return ''; + } + + return rtrim($this->httpRequest->getUrl()->getBasePath(), '/'); + } + private static function fixName(string $name): string { return Strings::webalize($name, '._'); diff --git a/src/Latte/LatteExtension.php b/src/Latte/LatteExtension.php index e22cb00..f71925c 100644 --- a/src/Latte/LatteExtension.php +++ b/src/Latte/LatteExtension.php @@ -32,7 +32,7 @@ public function tagImg(Tag $tag): Node $args = $tag->parser->parseArguments(); return new AuxiliaryNode( - fn (PrintContext $context) => $context->format('$_img = $imageStorage->fromIdentifier(%node); echo "createLink() . "\">";', $args) + fn (PrintContext $context) => $context->format('$_img = $imageStorage->fromIdentifier(%node); echo "createLink() . "\">";', $args) ); } @@ -50,7 +50,7 @@ public function attrImg(Tag $tag): Node $args = $tag->parser->parseArguments(); return new AuxiliaryNode( - fn (PrintContext $context) => $context->format('$_img = $imageStorage->fromIdentifier(%node); echo \' src="\' . $basePath . "/" . $_img->createLink() . \'"\';', $args) + fn (PrintContext $context) => $context->format('$_img = $imageStorage->fromIdentifier(%node); echo \' src="\' . $_img->createLink() . \'"\';', $args) ); } @@ -68,7 +68,7 @@ public function linkImg(Tag $tag): Node $args = $tag->parser->parseArguments(); return new AuxiliaryNode( - fn (PrintContext $context) => $context->format('$_img = $imageStorage->fromIdentifier(%node); echo $basePath . "/" . $_img->createLink();', $args) + fn (PrintContext $context) => $context->format('$_img = $imageStorage->fromIdentifier(%node); echo $_img->createLink();', $args) ); } diff --git a/tests/Cases/BasePathIntegrationTest.php b/tests/Cases/BasePathIntegrationTest.php new file mode 100644 index 0000000..72096c9 --- /dev/null +++ b/tests/Cases/BasePathIntegrationTest.php @@ -0,0 +1,201 @@ +createHttpRequest('/my-app/'); + + $storage = new ImageStorage( + __DIR__ . '/__files__', + 'data', + __DIR__ . '/__files__', + 'sha1_file', + 'sha1', + 85, + 'fit', + 'noimage/03/no-image.png', + false, + $httpRequest + ); + + Assert::equal('/my-app', $storage->getBasePath()); + } + + public function testGetBasePathWithRootPath(): void + { + $httpRequest = $this->createHttpRequest('/'); + + $storage = new ImageStorage( + __DIR__ . '/__files__', + 'data', + __DIR__ . '/__files__', + 'sha1_file', + 'sha1', + 85, + 'fit', + 'noimage/03/no-image.png', + false, + $httpRequest + ); + + Assert::equal('', $storage->getBasePath()); + } + + public function testGetBasePathWithNestedPath(): void + { + $httpRequest = $this->createHttpRequest('/foo/bar/baz/'); + + $storage = new ImageStorage( + __DIR__ . '/__files__', + 'data', + __DIR__ . '/__files__', + 'sha1_file', + 'sha1', + 85, + 'fit', + 'noimage/03/no-image.png', + false, + $httpRequest + ); + + Assert::equal('/foo/bar/baz', $storage->getBasePath()); + } + + public function testGetBasePathWithoutHttpRequest(): void + { + $storage = new ImageStorage( + __DIR__ . '/__files__', + 'data', + __DIR__ . '/__files__', + 'sha1_file', + 'sha1', + 85, + 'fit', + 'noimage/03/no-image.png', + false, + null + ); + + Assert::equal('', $storage->getBasePath()); + } + + public function testImageCreateLinkIncludesBasePath(): void + { + $image = new Image(false, 'data', '/path', 'namespace/47/img.jpg', '/my-app'); + Assert::equal('/my-app/data/namespace/47/img.jpg', $image->createLink()); + } + + public function testImageCreateLinkWithEmptyBasePath(): void + { + $image = new Image(false, 'data', '/path', 'namespace/47/img.jpg', ''); + Assert::equal('data/namespace/47/img.jpg', $image->createLink()); + } + + public function testImageCreateLinkWithFriendlyUrl(): void + { + $image = new Image(true, 'data', '/path', 'namespace/47/img.jpg', '/my-app'); + $link = $image->createLink(); + + Assert::true(str_starts_with($link, '/my-app/data/')); + } + + public function testFromIdentifierReturnsImageWithBasePath(): void + { + $httpRequest = $this->createHttpRequest('/my-app/'); + + // Create test image file + $testDir = __DIR__ . '/__files__/test/ab'; + $testFile = $testDir . '/test.jpg'; + @mkdir($testDir, 0777, true); + file_put_contents($testFile, 'fake image content'); + + try { + $storage = new ImageStorage( + __DIR__ . '/__files__', + 'data', + __DIR__ . '/__files__', + 'sha1_file', + 'sha1', + 85, + 'fit', + 'noimage/03/no-image.png', + false, + $httpRequest + ); + + $image = $storage->fromIdentifier('test/ab/test.jpg'); + + // The createLink should include basePath + $link = $image->createLink(); + Assert::true(str_starts_with($link, '/my-app/'), "Link should start with basePath, got: $link"); + Assert::equal('/my-app/data/test/ab/test.jpg', $link); + } finally { + // Cleanup + @unlink($testFile); + @unlink(__DIR__ . '/__files__/test/ab/test.jpg'); + @rmdir(__DIR__ . '/__files__/test/ab'); + @rmdir(__DIR__ . '/__files__/test'); + } + } + + public function testFromIdentifierWithoutHttpRequest(): void + { + // Create test image file + $testDir = __DIR__ . '/__files__/test2/cd'; + $testFile = $testDir . '/test2.jpg'; + @mkdir($testDir, 0777, true); + file_put_contents($testFile, 'fake image content'); + + try { + $storage = new ImageStorage( + __DIR__ . '/__files__', + 'data', + __DIR__ . '/__files__', + 'sha1_file', + 'sha1', + 85, + 'fit', + 'noimage/03/no-image.png', + false, + null + ); + + $image = $storage->fromIdentifier('test2/cd/test2.jpg'); + $link = $image->createLink(); + + // Without httpRequest, basePath should be empty, so link starts with data_dir + Assert::true(str_starts_with($link, 'data/'), "Link should start with data_dir, got: $link"); + Assert::equal('data/test2/cd/test2.jpg', $link); + } finally { + // Cleanup + @unlink($testFile); + @unlink(__DIR__ . '/__files__/test2/cd/test2.jpg'); + @rmdir(__DIR__ . '/__files__/test2/cd'); + @rmdir(__DIR__ . '/__files__/test2'); + } + } + + private function createHttpRequest(string $basePath): Request + { + $url = new UrlScript('http://example.com' . $basePath . 'index.php'); + $url = $url->withPath($basePath . 'index.php'); + + return new Request($url); + } + +} + +(new BasePathIntegrationTest())->run(); diff --git a/tests/Cases/ImageTest.php b/tests/Cases/ImageTest.php index 59e8452..95d1876 100644 --- a/tests/Cases/ImageTest.php +++ b/tests/Cases/ImageTest.php @@ -35,6 +35,24 @@ public function testCreateLinkNested(): void Assert::equal('data/images/namespace/47/img.jpg', $image->createLink()); } + public function testCreateLinkWithBasePath(): void + { + $image = new Image(false, 'data', '', 'namespace/47/img.jpg', '/my-app'); + Assert::equal('/my-app/data/namespace/47/img.jpg', $image->createLink()); + } + + public function testCreateLinkWithBasePathNested(): void + { + $image = new Image(false, 'data/images', '', 'namespace/47/img.jpg', '/my-app'); + Assert::equal('/my-app/data/images/namespace/47/img.jpg', $image->createLink()); + } + + public function testCreateLinkWithEmptyBasePath(): void + { + $image = new Image(false, 'data', '', 'namespace/47/img.jpg', ''); + Assert::equal('data/namespace/47/img.jpg', $image->createLink()); + } + } (new ImageTest())->run(); diff --git a/tests/Cases/LatteIntegrationTest.php b/tests/Cases/LatteIntegrationTest.php new file mode 100644 index 0000000..2230b17 --- /dev/null +++ b/tests/Cases/LatteIntegrationTest.php @@ -0,0 +1,212 @@ +tempDir = __DIR__ . '/__temp__'; + @mkdir($this->tempDir, 0777, true); + } + + public function tearDown(): void + { + // Clean up temp files + $files = glob($this->tempDir . '/*'); + if ($files !== false) { + foreach ($files as $file) { + @unlink($file); + } + } + } + + public function testLatteImgTagIncludesBasePath(): void + { + $httpRequest = $this->createHttpRequest('/my-app/'); + $storage = $this->createImageStorage($httpRequest); + + // Create test image + $this->createTestImage('test/ab/photo.jpg'); + + try { + $latte = $this->createLatteEngine(); + + $output = $latte->renderToString( + __DIR__ . '/__templates__/image-test.latte', + [ + 'imageStorage' => $storage, + 'basePath' => '/my-app', + 'baseUrl' => 'http://example.com/my-app', + 'identifier' => 'test/ab/photo.jpg', + ] + ); + + // Verify img tag contains basePath + Assert::contains('/my-app/data/test/ab/photo.jpg', $output); + Assert::contains('', $output); + } finally { + $this->cleanupTestImage('test/ab/photo.jpg'); + } + } + + public function testLatteImgTagWithRootBasePath(): void + { + $httpRequest = $this->createHttpRequest('/'); + $storage = $this->createImageStorage($httpRequest); + + $this->createTestImage('test/cd/image.jpg'); + + try { + $latte = $this->createLatteEngine(); + + $output = $latte->renderToString( + __DIR__ . '/__templates__/image-test.latte', + [ + 'imageStorage' => $storage, + 'basePath' => '', + 'baseUrl' => 'http://example.com', + 'identifier' => 'test/cd/image.jpg', + ] + ); + + // With root basePath, link should start with data_dir directly + Assert::contains('data/test/cd/image.jpg', $output); + Assert::contains('', $output); + } finally { + $this->cleanupTestImage('test/cd/image.jpg'); + } + } + + public function testLatteImgLinkIncludesBasePath(): void + { + $httpRequest = $this->createHttpRequest('/subdir/app/'); + $storage = $this->createImageStorage($httpRequest); + + $this->createTestImage('gallery/ef/pic.jpg'); + + try { + $latte = $this->createLatteEngine(); + + $output = $latte->renderToString( + __DIR__ . '/__templates__/image-test.latte', + [ + 'imageStorage' => $storage, + 'basePath' => '/subdir/app', + 'baseUrl' => 'http://example.com/subdir/app', + 'identifier' => 'gallery/ef/pic.jpg', + ] + ); + + // imgLink should output the full path with basePath + Assert::contains('/subdir/app/data/gallery/ef/pic.jpg', $output); + } finally { + $this->cleanupTestImage('gallery/ef/pic.jpg'); + } + } + + public function testLatteNImgAttributeIncludesBasePath(): void + { + $httpRequest = $this->createHttpRequest('/my-app/'); + $storage = $this->createImageStorage($httpRequest); + + $this->createTestImage('users/gh/avatar.jpg'); + + try { + $latte = $this->createLatteEngine(); + + $output = $latte->renderToString( + __DIR__ . '/__templates__/image-test.latte', + [ + 'imageStorage' => $storage, + 'basePath' => '/my-app', + 'baseUrl' => 'http://example.com/my-app', + 'identifier' => 'users/gh/avatar.jpg', + ] + ); + + // n:img attribute should include basePath + Assert::contains('src="/my-app/data/users/gh/avatar.jpg"', $output); + Assert::contains('alt="test"', $output); + } finally { + $this->cleanupTestImage('users/gh/avatar.jpg'); + } + } + + private function createLatteEngine(): Engine + { + $latte = new Engine(); + $latte->setTempDirectory($this->tempDir); + $latte->addExtension(new LatteExtension()); + + return $latte; + } + + private function createImageStorage(?Request $httpRequest): ImageStorage + { + return new ImageStorage( + __DIR__ . '/__files__', + 'data', + __DIR__ . '/__files__', + 'sha1_file', + 'sha1', + 85, + 'fit', + 'noimage/03/no-image.png', + false, + $httpRequest + ); + } + + private function createHttpRequest(string $basePath): Request + { + $url = new UrlScript('http://example.com' . $basePath . 'index.php'); + $url = $url->withPath($basePath . 'index.php'); + + return new Request($url); + } + + private function createTestImage(string $identifier): void + { + $path = __DIR__ . '/__files__/' . $identifier; + $dir = dirname($path); + @mkdir($dir, 0777, true); + file_put_contents($path, 'fake image content'); + } + + private function cleanupTestImage(string $identifier): void + { + $basePath = __DIR__ . '/__files__/'; + $filePath = $basePath . $identifier; + $dataFilePath = $basePath . $identifier; + + @unlink($filePath); + @unlink($dataFilePath); + + // Clean up directories + $parts = explode('/', $identifier); + array_pop($parts); // Remove filename + + while (count($parts) > 0) { + $dir = $basePath . implode('/', $parts); + @rmdir($dir); + array_pop($parts); + } + } + +} + +(new LatteIntegrationTest())->run(); diff --git a/tests/Cases/__templates__/image-test.latte b/tests/Cases/__templates__/image-test.latte new file mode 100644 index 0000000..5695d05 --- /dev/null +++ b/tests/Cases/__templates__/image-test.latte @@ -0,0 +1,14 @@ +{* Test basic img tag *} +
+{img $identifier} +
+ +{* Test img link *} + + +{* Test n:img attribute *} +
+test +
diff --git a/tests/php.ini b/tests/php.ini new file mode 100644 index 0000000..37bc454 --- /dev/null +++ b/tests/php.ini @@ -0,0 +1,3 @@ +; PHP configuration for tests +extension_dir = "/usr/lib/php/20240924" +extension = tokenizer.so