From 132705d15b8f99ce71e3c66e11cbed45510b21c7 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Sun, 8 Feb 2026 21:48:41 +0100 Subject: [PATCH 1/9] feat: added storage interface --- .../src/phpMyFAQ/Storage/StorageException.php | 27 +++++ .../src/phpMyFAQ/Storage/StorageInterface.php | 85 +++++++++++++++ .../phpMyFAQ/Storage/StorageInterfaceTest.php | 103 ++++++++++++++++++ 3 files changed, 215 insertions(+) create mode 100644 phpmyfaq/src/phpMyFAQ/Storage/StorageException.php create mode 100644 phpmyfaq/src/phpMyFAQ/Storage/StorageInterface.php create mode 100644 tests/phpMyFAQ/Storage/StorageInterfaceTest.php diff --git a/phpmyfaq/src/phpMyFAQ/Storage/StorageException.php b/phpmyfaq/src/phpMyFAQ/Storage/StorageException.php new file mode 100644 index 0000000000..05f1bfd2e7 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Storage/StorageException.php @@ -0,0 +1,27 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-08 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Storage; + +/** + * Class StorageException + * + * @package phpMyFAQ\Storage + */ +class StorageException extends \RuntimeException {} diff --git a/phpmyfaq/src/phpMyFAQ/Storage/StorageInterface.php b/phpmyfaq/src/phpMyFAQ/Storage/StorageInterface.php new file mode 100644 index 0000000000..78d6bccc8d --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Storage/StorageInterface.php @@ -0,0 +1,85 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-08 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Storage; + +/** + * Interface StorageInterface + * + * @package phpMyFAQ\Storage + */ +interface StorageInterface +{ + /** + * Write string contents to a path. + * + * @param string $path the storage path + * @param string $contents the file contents + * @throws StorageException + */ + public function put(string $path, string $contents): bool; + + /** + * Write from a stream resource for large files. + * + * @param string $path the storage path + * @param mixed $stream a readable stream resource + * @throws StorageException + */ + public function putStream(string $path, mixed $stream): bool; + + /** + * Read the entire file contents. + * + * @param string $path the storage path + * @throws StorageException + */ + public function get(string $path): string; + + /** + * Delete a file. + * + * @param string $path the storage path + * @throws StorageException + */ + public function delete(string $path): bool; + + /** + * Check if a file exists. + * + * @param string $path the storage path + */ + public function exists(string $path): bool; + + /** + * Get the public/accessible URL for a file. + * + * @param string $path the storage path + * @throws StorageException + */ + public function url(string $path): string; + + /** + * Get file size in bytes. + * + * @param string $path the storage path + * @throws StorageException + */ + public function size(string $path): int; +} diff --git a/tests/phpMyFAQ/Storage/StorageInterfaceTest.php b/tests/phpMyFAQ/Storage/StorageInterfaceTest.php new file mode 100644 index 0000000000..af8a2840e4 --- /dev/null +++ b/tests/phpMyFAQ/Storage/StorageInterfaceTest.php @@ -0,0 +1,103 @@ +reflection = new ReflectionClass(StorageInterface::class); + } + + public function testIsInterface(): void + { + $this->assertTrue($this->reflection->isInterface()); + } + + public function testHasPutMethod(): void + { + $method = $this->reflection->getMethod('put'); + $this->assertCount(2, $method->getParameters()); + $this->assertEquals('bool', $method->getReturnType()->getName()); + $this->assertEquals('path', $method->getParameters()[0]->getName()); + $this->assertEquals('contents', $method->getParameters()[1]->getName()); + } + + public function testHasPutStreamMethod(): void + { + $method = $this->reflection->getMethod('putStream'); + $this->assertCount(2, $method->getParameters()); + $this->assertEquals('bool', $method->getReturnType()->getName()); + $this->assertEquals('path', $method->getParameters()[0]->getName()); + $this->assertEquals('stream', $method->getParameters()[1]->getName()); + $this->assertEquals('mixed', $method->getParameters()[1]->getType()->getName()); + } + + public function testHasGetMethod(): void + { + $method = $this->reflection->getMethod('get'); + $this->assertCount(1, $method->getParameters()); + $this->assertEquals('string', $method->getReturnType()->getName()); + $this->assertEquals('path', $method->getParameters()[0]->getName()); + } + + public function testHasDeleteMethod(): void + { + $method = $this->reflection->getMethod('delete'); + $this->assertCount(1, $method->getParameters()); + $this->assertEquals('bool', $method->getReturnType()->getName()); + $this->assertEquals('path', $method->getParameters()[0]->getName()); + } + + public function testHasExistsMethod(): void + { + $method = $this->reflection->getMethod('exists'); + $this->assertCount(1, $method->getParameters()); + $this->assertEquals('bool', $method->getReturnType()->getName()); + $this->assertEquals('path', $method->getParameters()[0]->getName()); + } + + public function testHasUrlMethod(): void + { + $method = $this->reflection->getMethod('url'); + $this->assertCount(1, $method->getParameters()); + $this->assertEquals('string', $method->getReturnType()->getName()); + $this->assertEquals('path', $method->getParameters()[0]->getName()); + } + + public function testHasSizeMethod(): void + { + $method = $this->reflection->getMethod('size'); + $this->assertCount(1, $method->getParameters()); + $this->assertEquals('int', $method->getReturnType()->getName()); + $this->assertEquals('path', $method->getParameters()[0]->getName()); + } + + #[AllowMockObjectsWithoutExpectations] + public function testMockImplementation(): void + { + $mock = $this->createMock(StorageInterface::class); + + $mock->method('put')->willReturn(true); + $mock->method('putStream')->willReturn(true); + $mock->method('get')->willReturn('file contents'); + $mock->method('delete')->willReturn(true); + $mock->method('exists')->willReturn(true); + $mock->method('url')->willReturn('https://example.com/file.txt'); + $mock->method('size')->willReturn(1024); + + $this->assertTrue($mock->put('test/file.txt', 'contents')); + $this->assertTrue($mock->putStream('test/file.txt', fopen('php://memory', 'r'))); + $this->assertEquals('file contents', $mock->get('test/file.txt')); + $this->assertTrue($mock->delete('test/file.txt')); + $this->assertTrue($mock->exists('test/file.txt')); + $this->assertEquals('https://example.com/file.txt', $mock->url('test/file.txt')); + $this->assertEquals(1024, $mock->size('test/file.txt')); + } +} From dcc07677e362932b0b4c94f3de54661de13ca076 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Mon, 9 Feb 2026 07:22:40 +0100 Subject: [PATCH 2/9] feat: added storage classes for filesystem and AWS S3 (#1069) # Conflicts: # composer.lock # phpmyfaq/src/phpMyFAQ/System.php # phpmyfaq/src/services.php --- composer.json | 1 + composer.lock | 269 ++++++++++++++++-- .../phpMyFAQ/Storage/FilesystemStorage.php | 145 ++++++++++ phpmyfaq/src/phpMyFAQ/Storage/S3Storage.php | 147 ++++++++++ .../src/phpMyFAQ/Storage/StorageFactory.php | 120 ++++++++ phpmyfaq/src/phpMyFAQ/System.php | 9 +- phpmyfaq/src/services.php | 10 + .../Storage/FilesystemStorageTest.php | 95 +++++++ tests/phpMyFAQ/Storage/S3StorageTest.php | 97 +++++++ tests/phpMyFAQ/Storage/StorageFactoryTest.php | 86 ++++++ 10 files changed, 951 insertions(+), 28 deletions(-) create mode 100644 phpmyfaq/src/phpMyFAQ/Storage/FilesystemStorage.php create mode 100644 phpmyfaq/src/phpMyFAQ/Storage/S3Storage.php create mode 100644 phpmyfaq/src/phpMyFAQ/Storage/StorageFactory.php create mode 100644 tests/phpMyFAQ/Storage/FilesystemStorageTest.php create mode 100644 tests/phpMyFAQ/Storage/S3StorageTest.php create mode 100644 tests/phpMyFAQ/Storage/StorageFactoryTest.php diff --git a/composer.json b/composer.json index ab373bc3f3..7d0411af13 100644 --- a/composer.json +++ b/composer.json @@ -28,6 +28,7 @@ "ext-xmlwriter": "*", "ext-zip": "*", "2tvenom/cborencode": "^1.0", + "aws/aws-sdk-php": "^3.0", "elasticsearch/elasticsearch": "8.*", "endroid/qr-code": "^6.0.2", "guzzlehttp/guzzle": "^7.5", diff --git a/composer.lock b/composer.lock index 2aa78e68a2..0cf54f1a49 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6c7a5e91acefb50746730761d44b4a27", + "content-hash": "aef090d412f5e249fd8c6457c572ebcc", "packages": [ { "name": "2tvenom/cborencode", @@ -51,6 +51,157 @@ }, "time": "2020-10-27T07:22:41+00:00" }, + { + "name": "aws/aws-crt-php", + "version": "v1.2.7", + "source": { + "type": "git", + "url": "https://github.com/awslabs/aws-crt-php.git", + "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e", + "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e", + "shasum": "" + }, + "require": { + "php": ">=5.5" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35||^5.6.3||^9.5", + "yoast/phpunit-polyfills": "^1.0" + }, + "suggest": { + "ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality." + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "AWS SDK Common Runtime Team", + "email": "aws-sdk-common-runtime@amazon.com" + } + ], + "description": "AWS Common Runtime for PHP", + "homepage": "https://github.com/awslabs/aws-crt-php", + "keywords": [ + "amazon", + "aws", + "crt", + "sdk" + ], + "support": { + "issues": "https://github.com/awslabs/aws-crt-php/issues", + "source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7" + }, + "time": "2024-10-18T22:15:13+00:00" + }, + { + "name": "aws/aws-sdk-php", + "version": "3.369.29", + "source": { + "type": "git", + "url": "https://github.com/aws/aws-sdk-php.git", + "reference": "068195b2980cf5cf4ade2515850d461186db3310" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/068195b2980cf5cf4ade2515850d461186db3310", + "reference": "068195b2980cf5cf4ade2515850d461186db3310", + "shasum": "" + }, + "require": { + "aws/aws-crt-php": "^1.2.3", + "ext-json": "*", + "ext-pcre": "*", + "ext-simplexml": "*", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/promises": "^2.0", + "guzzlehttp/psr7": "^2.4.5", + "mtdowling/jmespath.php": "^2.8.0", + "php": ">=8.1", + "psr/http-message": "^1.0 || ^2.0", + "symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0" + }, + "require-dev": { + "andrewsville/php-token-reflection": "^1.4", + "aws/aws-php-sns-message-validator": "~1.0", + "behat/behat": "~3.0", + "composer/composer": "^2.7.8", + "dms/phpunit-arraysubset-asserts": "^0.4.0", + "doctrine/cache": "~1.4", + "ext-dom": "*", + "ext-openssl": "*", + "ext-sockets": "*", + "phpunit/phpunit": "^9.6", + "psr/cache": "^2.0 || ^3.0", + "psr/simple-cache": "^2.0 || ^3.0", + "sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0", + "yoast/phpunit-polyfills": "^2.0" + }, + "suggest": { + "aws/aws-php-sns-message-validator": "To validate incoming SNS notifications", + "doctrine/cache": "To use the DoctrineCacheAdapter", + "ext-curl": "To send requests using cURL", + "ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages", + "ext-pcntl": "To use client-side monitoring", + "ext-sockets": "To use client-side monitoring" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Aws\\": "src/" + }, + "exclude-from-classmap": [ + "src/data/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Amazon Web Services", + "homepage": "http://aws.amazon.com" + } + ], + "description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project", + "homepage": "http://aws.amazon.com/sdkforphp", + "keywords": [ + "amazon", + "aws", + "cloud", + "dynamodb", + "ec2", + "glacier", + "s3", + "sdk" + ], + "support": { + "forum": "https://github.com/aws/aws-sdk-php/discussions", + "issues": "https://github.com/aws/aws-sdk-php/issues", + "source": "https://github.com/aws/aws-sdk-php/tree/3.369.29" + }, + "time": "2026-02-06T19:08:50+00:00" + }, { "name": "bacon/bacon-qr-code", "version": "v3.0.3", @@ -108,16 +259,16 @@ }, { "name": "brick/math", - "version": "0.14.7", + "version": "0.14.6", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "07ff363b16ef8aca9692bba3be9e73fe63f34e50" + "reference": "32498d5e1897e7642c0b961ace2df6d7dc9a3bc3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/07ff363b16ef8aca9692bba3be9e73fe63f34e50", - "reference": "07ff363b16ef8aca9692bba3be9e73fe63f34e50", + "url": "https://api.github.com/repos/brick/math/zipball/32498d5e1897e7642c0b961ace2df6d7dc9a3bc3", + "reference": "32498d5e1897e7642c0b961ace2df6d7dc9a3bc3", "shasum": "" }, "require": { @@ -156,7 +307,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.14.7" + "source": "https://github.com/brick/math/tree/0.14.6" }, "funding": [ { @@ -164,7 +315,7 @@ "type": "github" } ], - "time": "2026-02-07T10:57:35+00:00" + "time": "2026-02-05T07:59:58+00:00" }, { "name": "dasprid/enum", @@ -1431,16 +1582,16 @@ }, { "name": "minishlink/web-push", - "version": "v10.0.1", + "version": "v9.0.4", "source": { "type": "git", "url": "https://github.com/web-push-libs/web-push-php.git", - "reference": "08463189d3501cbd78a8625c87ab6680a7397aad" + "reference": "f979f40b0017d2f86d82b9f21edbc515d031cc23" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-push-libs/web-push-php/zipball/08463189d3501cbd78a8625c87ab6680a7397aad", - "reference": "08463189d3501cbd78a8625c87ab6680a7397aad", + "url": "https://api.github.com/repos/web-push-libs/web-push-php/zipball/f979f40b0017d2f86d82b9f21edbc515d031cc23", + "reference": "f979f40b0017d2f86d82b9f21edbc515d031cc23", "shasum": "" }, "require": { @@ -1449,15 +1600,15 @@ "ext-mbstring": "*", "ext-openssl": "*", "guzzlehttp/guzzle": "^7.9.2", - "php": ">=8.2", + "php": ">=8.1", "spomky-labs/base64url": "^2.0.4", - "web-token/jwt-library": "^3.4.9|^4.0.6" + "symfony/polyfill-php82": "^v1.31.0", + "web-token/jwt-library": "^3.3.0|^4.0.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "^v3.91.3", - "phpstan/phpstan": "^2.1.33", - "phpstan/phpstan-strict-rules": "^2.0", - "phpunit/phpunit": "^11.5.46|^12.5.2", + "phpstan/phpstan": "^2.1.2", + "phpunit/phpunit": "^10.5.44|^11.5.6", "symfony/polyfill-iconv": "^1.33" }, "suggest": { @@ -1492,9 +1643,9 @@ ], "support": { "issues": "https://github.com/web-push-libs/web-push-php/issues", - "source": "https://github.com/web-push-libs/web-push-php/tree/v10.0.1" + "source": "https://github.com/web-push-libs/web-push-php/tree/v9.0.4" }, - "time": "2025-12-15T10:04:28+00:00" + "time": "2025-12-10T14:00:12+00:00" }, { "name": "monolog/monolog", @@ -1599,6 +1750,72 @@ ], "time": "2026-01-02T08:56:05+00:00" }, + { + "name": "mtdowling/jmespath.php", + "version": "2.8.0", + "source": { + "type": "git", + "url": "https://github.com/jmespath/jmespath.php.git", + "reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/a2a865e05d5f420b50cc2f85bb78d565db12a6bc", + "reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-mbstring": "^1.17" + }, + "require-dev": { + "composer/xdebug-handler": "^3.0.3", + "phpunit/phpunit": "^8.5.33" + }, + "bin": [ + "bin/jp.php" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.8-dev" + } + }, + "autoload": { + "files": [ + "src/JmesPath.php" + ], + "psr-4": { + "JmesPath\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Declaratively specify how to extract elements from a JSON document", + "keywords": [ + "json", + "jsonpath" + ], + "support": { + "issues": "https://github.com/jmespath/jmespath.php/issues", + "source": "https://github.com/jmespath/jmespath.php/tree/2.8.0" + }, + "time": "2024-09-04T18:46:31+00:00" + }, { "name": "myclabs/deep-copy", "version": "1.13.4", @@ -7514,21 +7731,21 @@ }, { "name": "rector/rector", - "version": "2.3.6", + "version": "2.3.5", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "ca9ebb81d280cd362ea39474dabd42679e32ca6b" + "reference": "9442f4037de6a5347ae157fe8e6c7cda9d909070" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/ca9ebb81d280cd362ea39474dabd42679e32ca6b", - "reference": "ca9ebb81d280cd362ea39474dabd42679e32ca6b", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/9442f4037de6a5347ae157fe8e6c7cda9d909070", + "reference": "9442f4037de6a5347ae157fe8e6c7cda9d909070", "shasum": "" }, "require": { "php": "^7.4|^8.0", - "phpstan/phpstan": "^2.1.38" + "phpstan/phpstan": "^2.1.36" }, "conflict": { "rector/rector-doctrine": "*", @@ -7562,7 +7779,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.3.6" + "source": "https://github.com/rectorphp/rector/tree/2.3.5" }, "funding": [ { @@ -7570,7 +7787,7 @@ "type": "github" } ], - "time": "2026-02-06T14:25:06+00:00" + "time": "2026-01-28T15:22:48+00:00" }, { "name": "sebastian/cli-parser", @@ -8899,5 +9116,5 @@ "platform-overrides": { "php": "8.4.0" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/phpmyfaq/src/phpMyFAQ/Storage/FilesystemStorage.php b/phpmyfaq/src/phpMyFAQ/Storage/FilesystemStorage.php new file mode 100644 index 0000000000..8b91e0e099 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Storage/FilesystemStorage.php @@ -0,0 +1,145 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-08 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Storage; + +final readonly class FilesystemStorage implements StorageInterface +{ + private string $rootPath; + + public function __construct( + string $rootPath, + private ?string $publicBaseUrl = null, + ) { + $this->rootPath = rtrim($rootPath, DIRECTORY_SEPARATOR); + } + + public function put(string $path, string $contents): bool + { + $fullPath = $this->buildFullPath($path); + $directory = dirname($fullPath); + if (!is_dir($directory) && !mkdir($directory, 0o775, true) && !is_dir($directory)) { + throw new StorageException('Unable to create directory for storage path: ' . $directory); + } + + $result = file_put_contents($fullPath, $contents, LOCK_EX); + if ($result === false) { + throw new StorageException('Unable to write file to storage path: ' . $path); + } + + return true; + } + + public function putStream(string $path, mixed $stream): bool + { + if (!is_resource($stream)) { + throw new StorageException('Stream must be a valid resource.'); + } + + $fullPath = $this->buildFullPath($path); + $directory = dirname($fullPath); + if (!is_dir($directory) && !mkdir($directory, 0o775, true) && !is_dir($directory)) { + throw new StorageException('Unable to create directory for storage path: ' . $directory); + } + + $target = fopen($fullPath, 'wb'); + if ($target === false) { + throw new StorageException('Unable to open file for writing: ' . $path); + } + + try { + if (stream_copy_to_stream($stream, $target) === false) { + throw new StorageException('Unable to write stream contents: ' . $path); + } + } finally { + fclose($target); + } + + return true; + } + + public function get(string $path): string + { + $fullPath = $this->buildFullPath($path); + $contents = file_get_contents($fullPath); + + if ($contents === false) { + throw new StorageException('Unable to read file from storage path: ' . $path); + } + + return $contents; + } + + public function delete(string $path): bool + { + $fullPath = $this->buildFullPath($path); + if (!file_exists($fullPath)) { + return false; + } + + $result = unlink($fullPath); + if ($result === false) { + throw new StorageException('Unable to delete file from storage path: ' . $path); + } + + return true; + } + + public function exists(string $path): bool + { + return file_exists($this->buildFullPath($path)); + } + + public function url(string $path): string + { + $normalizedPath = $this->normalizePath($path); + if ($this->publicBaseUrl !== null && $this->publicBaseUrl !== '') { + return rtrim($this->publicBaseUrl, '/') . '/' . $normalizedPath; + } + + return $this->buildFullPath($path); + } + + public function size(string $path): int + { + $fullPath = $this->buildFullPath($path); + $size = filesize($fullPath); + + if ($size === false) { + throw new StorageException('Unable to fetch file size for storage path: ' . $path); + } + + return $size; + } + + private function buildFullPath(string $path): string + { + return $this->rootPath . DIRECTORY_SEPARATOR . $this->normalizePath($path); + } + + private function normalizePath(string $path): string + { + $normalizedPath = ltrim(str_replace('\\', '/', trim($path)), '/'); + if ($normalizedPath === '' || str_contains($normalizedPath, '..')) { + throw new StorageException('Invalid storage path.'); + } + + return str_replace('/', DIRECTORY_SEPARATOR, $normalizedPath); + } +} diff --git a/phpmyfaq/src/phpMyFAQ/Storage/S3Storage.php b/phpmyfaq/src/phpMyFAQ/Storage/S3Storage.php new file mode 100644 index 0000000000..ec11ed1286 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Storage/S3Storage.php @@ -0,0 +1,147 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-08 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Storage; + +use Throwable; + +final readonly class S3Storage implements StorageInterface +{ + public function __construct( + private object $client, + private string $bucket, + private string $prefix = '', + private ?string $publicBaseUrl = null, + ) { + } + + public function put(string $path, string $contents): bool + { + $key = $this->buildKey($path); + $this->run(fn(): mixed => $this->client->putObject([ + 'Bucket' => $this->bucket, + 'Key' => $key, + 'Body' => $contents, + ])); + + return true; + } + + public function putStream(string $path, mixed $stream): bool + { + if (!is_resource($stream)) { + throw new StorageException('Stream must be a valid resource.'); + } + + $key = $this->buildKey($path); + $this->run(fn(): mixed => $this->client->putObject([ + 'Bucket' => $this->bucket, + 'Key' => $key, + 'Body' => $stream, + ])); + + return true; + } + + public function get(string $path): string + { + $key = $this->buildKey($path); + $result = $this->run(fn(): mixed => $this->client->getObject([ + 'Bucket' => $this->bucket, + 'Key' => $key, + ])); + + if (!is_array($result) || !array_key_exists('Body', $result)) { + throw new StorageException('Invalid S3 response while reading object: ' . $key); + } + + return (string) $result['Body']; + } + + public function delete(string $path): bool + { + $key = $this->buildKey($path); + $this->run(fn(): mixed => $this->client->deleteObject([ + 'Bucket' => $this->bucket, + 'Key' => $key, + ])); + + return true; + } + + public function exists(string $path): bool + { + $key = $this->buildKey($path); + + return (bool) $this->run(fn(): mixed => $this->client->doesObjectExistV2($this->bucket, $key)); + } + + public function url(string $path): string + { + $key = $this->buildKey($path); + if ($this->publicBaseUrl !== null && $this->publicBaseUrl !== '') { + return rtrim($this->publicBaseUrl, '/') . '/' . $key; + } + + $result = $this->run(fn(): mixed => $this->client->getObjectUrl($this->bucket, $key)); + + return (string) $result; + } + + public function size(string $path): int + { + $key = $this->buildKey($path); + $result = $this->run(fn(): mixed => $this->client->headObject([ + 'Bucket' => $this->bucket, + 'Key' => $key, + ])); + + if (!is_array($result) || !isset($result['ContentLength'])) { + throw new StorageException('Invalid S3 response while reading object size: ' . $key); + } + + return (int) $result['ContentLength']; + } + + /** + * @param callable(): mixed $callback + */ + private function run(callable $callback): mixed + { + try { + return $callback(); + } catch (Throwable $throwable) { + throw new StorageException($throwable->getMessage(), previous: $throwable); + } + } + + private function buildKey(string $path): string + { + $normalizedPath = ltrim(str_replace('\\', '/', trim($path)), '/'); + if ($normalizedPath === '' || str_contains($normalizedPath, '..')) { + throw new StorageException('Invalid storage path.'); + } + + if ($this->prefix === '') { + return $normalizedPath; + } + + return trim($this->prefix, '/') . '/' . $normalizedPath; + } +} diff --git a/phpmyfaq/src/phpMyFAQ/Storage/StorageFactory.php b/phpmyfaq/src/phpMyFAQ/Storage/StorageFactory.php new file mode 100644 index 0000000000..7647389e81 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Storage/StorageFactory.php @@ -0,0 +1,120 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-08 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Storage; + +use Aws\S3\S3Client; +use phpMyFAQ\Configuration; + +final readonly class StorageFactory +{ + public function __construct( + private Configuration $configuration, + ) { + } + + public function create(): StorageInterface + { + $type = strtolower((string) ($this->configuration->get('storage.type') ?? 'filesystem')); + + return match ($type) { + 'filesystem' => new FilesystemStorage( + $this->resolveFilesystemRoot(), + $this->readStringConfig('storage.filesystem.publicBaseUrl'), + ), + 's3' => $this->createS3Storage(), + default => throw new StorageException('Unsupported storage type: ' . $type), + }; + } + + private function createS3Storage(): S3Storage + { + $bucket = $this->readRequiredConfig('storage.s3.bucket'); + $prefix = $this->readStringConfig('storage.s3.prefix') ?? ''; + $publicBaseUrl = $this->readStringConfig('storage.s3.publicBaseUrl'); + $region = $this->readStringConfig('storage.s3.region') ?? 'us-east-1'; + + if (!class_exists(S3Client::class)) { + throw new StorageException('AWS SDK for PHP is required for S3 storage.'); + } + + $s3Config = [ + 'version' => 'latest', + 'region' => $region, + ]; + + $endpoint = $this->readStringConfig('storage.s3.endpoint'); + if ($endpoint !== null && $endpoint !== '') { + $s3Config['endpoint'] = $endpoint; + } + + $key = $this->readStringConfig('storage.s3.key'); + $secret = $this->readStringConfig('storage.s3.secret'); + if ($key !== null && $secret !== null) { + $s3Config['credentials'] = [ + 'key' => $key, + 'secret' => $secret, + ]; + } + + $usePathStyle = $this->configuration->get('storage.s3.usePathStyle'); + if ($usePathStyle !== null) { + $s3Config['use_path_style_endpoint'] = filter_var($usePathStyle, FILTER_VALIDATE_BOOL); + } + + /** @var object $client */ + $client = new S3Client($s3Config); + + return new S3Storage($client, $bucket, $prefix, $publicBaseUrl); + } + + private function resolveFilesystemRoot(): string + { + $configuredRoot = $this->readStringConfig('storage.filesystem.root'); + if ($configuredRoot !== null && $configuredRoot !== '') { + return $configuredRoot; + } + + if (defined('PMF_ATTACHMENTS_DIR') && PMF_ATTACHMENTS_DIR !== false) { + return (string) PMF_ATTACHMENTS_DIR; + } + + return PMF_ROOT_DIR . '/content/user/attachments'; + } + + private function readRequiredConfig(string $key): string + { + $value = $this->readStringConfig($key); + if ($value === null || $value === '') { + throw new StorageException('Missing required storage configuration key: ' . $key); + } + + return $value; + } + + private function readStringConfig(string $key): ?string + { + $value = $this->configuration->get($key); + if ($value === null) { + return null; + } + + return trim((string) $value); + } +} diff --git a/phpmyfaq/src/phpMyFAQ/System.php b/phpmyfaq/src/phpMyFAQ/System.php index 85a457acce..23597504e4 100644 --- a/phpmyfaq/src/phpMyFAQ/System.php +++ b/phpmyfaq/src/phpMyFAQ/System.php @@ -407,7 +407,7 @@ public function createHashes(): string try { foreach ($files as $file) { - if ($file->isDir()) { + if (!$file->isFile() || !$file->isReadable()) { continue; } @@ -433,7 +433,12 @@ public function createHashes(): string continue; } - $hashes[$current] = sha1(file_get_contents($file->getPathname())); + $contents = file_get_contents($file->getPathname()); + if ($contents === false) { + continue; + } + + $hashes[$current] = sha1($contents); } } catch (UnexpectedValueException $unexpectedValueException) { $hashes[$current . ' failed'] = $unexpectedValueException->getMessage(); diff --git a/phpmyfaq/src/services.php b/phpmyfaq/src/services.php index a2dbb08293..742d034b03 100644 --- a/phpmyfaq/src/services.php +++ b/phpmyfaq/src/services.php @@ -79,6 +79,8 @@ use phpMyFAQ\Setup\Update; use phpMyFAQ\Setup\Upgrade; use phpMyFAQ\Sitemap; +use phpMyFAQ\Storage\StorageFactory; +use phpMyFAQ\Storage\StorageInterface; use phpMyFAQ\StopWords; use phpMyFAQ\System; use phpMyFAQ\Tags; @@ -223,6 +225,14 @@ service('phpmyfaq.comment.comments-repository'), ]); + $services->set('phpmyfaq.storage.factory', StorageFactory::class)->args([ + service('phpmyfaq.configuration'), + ]); + $services->set('phpmyfaq.storage', StorageInterface::class)->factory([ + service('phpmyfaq.storage.factory'), + 'create', + ]); + $services->set('phpmyfaq.tenant.context-resolver', TenantContextResolver::class); $services->set('phpmyfaq.tenant.context', TenantContext::class)->factory([ service('phpmyfaq.tenant.context-resolver'), diff --git a/tests/phpMyFAQ/Storage/FilesystemStorageTest.php b/tests/phpMyFAQ/Storage/FilesystemStorageTest.php new file mode 100644 index 0000000000..3fc476272b --- /dev/null +++ b/tests/phpMyFAQ/Storage/FilesystemStorageTest.php @@ -0,0 +1,95 @@ +tmpDir = sys_get_temp_dir() . '/phpmyfaq-storage-' . uniqid('', true); + mkdir($this->tmpDir, 0777, true); + } + + protected function tearDown(): void + { + parent::tearDown(); + $this->removeDirectory($this->tmpDir); + } + + public function testPutGetExistsSizeDeleteAndUrl(): void + { + $storage = new FilesystemStorage($this->tmpDir, 'https://cdn.example.com/storage'); + + $this->assertTrue($storage->put('foo/bar.txt', 'hello world')); + $this->assertTrue($storage->exists('foo/bar.txt')); + $this->assertSame('hello world', $storage->get('foo/bar.txt')); + $this->assertSame(strlen('hello world'), $storage->size('foo/bar.txt')); + $this->assertSame('https://cdn.example.com/storage/foo/bar.txt', $storage->url('foo/bar.txt')); + $this->assertTrue($storage->delete('foo/bar.txt')); + $this->assertFalse($storage->exists('foo/bar.txt')); + } + + public function testPutStreamWritesContents(): void + { + $storage = new FilesystemStorage($this->tmpDir); + $stream = fopen('php://memory', 'rb+'); + fwrite($stream, 'stream content'); + rewind($stream); + + $this->assertTrue($storage->putStream('stream/file.txt', $stream)); + $this->assertSame('stream content', $storage->get('stream/file.txt')); + + fclose($stream); + } + + public function testPutStreamThrowsForInvalidStream(): void + { + $storage = new FilesystemStorage($this->tmpDir); + + $this->expectException(StorageException::class); + $storage->putStream('invalid/file.txt', 'not-a-stream'); + } + + public function testInvalidPathThrowsException(): void + { + $storage = new FilesystemStorage($this->tmpDir); + + $this->expectException(StorageException::class); + $storage->put('../escape.txt', 'invalid'); + } + + private function removeDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + + $entries = scandir($directory); + if ($entries === false) { + return; + } + + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $path = $directory . '/' . $entry; + if (is_dir($path)) { + $this->removeDirectory($path); + continue; + } + + unlink($path); + } + + rmdir($directory); + } +} diff --git a/tests/phpMyFAQ/Storage/S3StorageTest.php b/tests/phpMyFAQ/Storage/S3StorageTest.php new file mode 100644 index 0000000000..b81c29fba7 --- /dev/null +++ b/tests/phpMyFAQ/Storage/S3StorageTest.php @@ -0,0 +1,97 @@ +assertTrue($storage->put('docs/readme.txt', 'hello')); + $this->assertSame('tenant/attachments/docs/readme.txt', $client->lastPutObject['Key']); + + $this->assertSame('hello', $storage->get('docs/readme.txt')); + $this->assertTrue($storage->exists('docs/readme.txt')); + $this->assertSame(5, $storage->size('docs/readme.txt')); + $this->assertSame( + 'https://cdn.example.com/tenant/attachments/docs/readme.txt', + $storage->url('docs/readme.txt'), + ); + $this->assertTrue($storage->delete('docs/readme.txt')); + } + + public function testPutStreamWritesObject(): void + { + $client = new FakeS3Client(); + $storage = new S3Storage($client, 'pmf-bucket', 'tenant/attachments'); + $stream = fopen('php://memory', 'rb+'); + fwrite($stream, 'stream-data'); + rewind($stream); + + $this->assertTrue($storage->putStream('stream.txt', $stream)); + $this->assertSame('stream-data', (string) $client->objects['tenant/attachments/stream.txt']); + + fclose($stream); + } + + public function testInvalidPathThrowsException(): void + { + $client = new FakeS3Client(); + $storage = new S3Storage($client, 'pmf-bucket'); + + $this->expectException(StorageException::class); + $storage->put('../escape.txt', 'x'); + } +} + +final class FakeS3Client +{ + public array $objects = []; + + public array $lastPutObject = []; + + public function putObject(array $args): array + { + $this->lastPutObject = $args; + $body = $args['Body']; + if (is_resource($body)) { + $body = stream_get_contents($body) ?: ''; + } + + $this->objects[$args['Key']] = $body; + + return ['ObjectURL' => $this->getObjectUrl($args['Bucket'], $args['Key'])]; + } + + public function getObject(array $args): array + { + return ['Body' => $this->objects[$args['Key']] ?? '']; + } + + public function deleteObject(array $args): array + { + unset($this->objects[$args['Key']]); + return []; + } + + public function doesObjectExistV2(string $bucket, string $key): bool + { + return isset($this->objects[$key]); + } + + public function getObjectUrl(string $bucket, string $key): string + { + return sprintf('https://%s.s3.local/%s', $bucket, $key); + } + + public function headObject(array $args): array + { + return ['ContentLength' => strlen((string) ($this->objects[$args['Key']] ?? ''))]; + } +} diff --git a/tests/phpMyFAQ/Storage/StorageFactoryTest.php b/tests/phpMyFAQ/Storage/StorageFactoryTest.php new file mode 100644 index 0000000000..18ee544cc2 --- /dev/null +++ b/tests/phpMyFAQ/Storage/StorageFactoryTest.php @@ -0,0 +1,86 @@ +createStub(Configuration::class); + $configuration + ->method('get') + ->willReturnMap([ + ['storage.type', null], + ['storage.filesystem.root', null], + ['storage.filesystem.publicBaseUrl', null], + ]); + + $factory = new StorageFactory($configuration); + $storage = $factory->create(); + + $this->assertInstanceOf(FilesystemStorage::class, $storage); + } + + public function testCreateReturnsFilesystemStorageWhenConfigured(): void + { + $configuration = $this->createStub(Configuration::class); + $configuration + ->method('get') + ->willReturnMap([ + ['storage.type', 'filesystem'], + ['storage.filesystem.root', '/tmp/phpmyfaq-storage'], + ['storage.filesystem.publicBaseUrl', 'https://cdn.example.com/files'], + ]); + + $factory = new StorageFactory($configuration); + $storage = $factory->create(); + + $this->assertInstanceOf(FilesystemStorage::class, $storage); + } + + public function testCreateThrowsForUnsupportedStorageType(): void + { + $configuration = $this->createStub(Configuration::class); + $configuration + ->method('get') + ->willReturnMap([ + ['storage.type', 'unsupported'], + ]); + + $factory = new StorageFactory($configuration); + + $this->expectException(StorageException::class); + $this->expectExceptionMessage('Unsupported storage type: unsupported'); + $factory->create(); + } + + public function testCreateReturnsS3StorageWhenConfigured(): void + { + $configuration = $this->createStub(Configuration::class); + $configuration + ->method('get') + ->willReturnMap([ + ['storage.type', 's3'], + ['storage.s3.bucket', 'pmf-bucket'], + ['storage.s3.prefix', null], + ['storage.s3.publicBaseUrl', null], + ['storage.s3.region', null], + ['storage.s3.endpoint', null], + ['storage.s3.key', null], + ['storage.s3.secret', null], + ['storage.s3.usePathStyle', null], + ]); + + $factory = new StorageFactory($configuration); + $storage = $factory->create(); + $this->assertInstanceOf(S3Storage::class, $storage); + } +} From 27e7b26449ebb69cf677089fe68489a5a7e3f068 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Mon, 9 Feb 2026 08:26:37 +0100 Subject: [PATCH 3/9] fix: corrected review notes --- .../phpMyFAQ/Storage/FilesystemStorage.php | 24 +++- phpmyfaq/src/phpMyFAQ/Storage/S3Storage.php | 13 ++- .../src/phpMyFAQ/Storage/StorageFactory.php | 4 + .../Storage/FilesystemStorageTest.php | 57 ++++++++- tests/phpMyFAQ/Storage/S3StorageTest.php | 108 +++++++++++++++++- tests/phpMyFAQ/Storage/StorageFactoryTest.php | 48 ++++++++ 6 files changed, 243 insertions(+), 11 deletions(-) diff --git a/phpmyfaq/src/phpMyFAQ/Storage/FilesystemStorage.php b/phpmyfaq/src/phpMyFAQ/Storage/FilesystemStorage.php index 8b91e0e099..d7cabf78ba 100644 --- a/phpmyfaq/src/phpMyFAQ/Storage/FilesystemStorage.php +++ b/phpmyfaq/src/phpMyFAQ/Storage/FilesystemStorage.php @@ -27,7 +27,7 @@ public function __construct( string $rootPath, private ?string $publicBaseUrl = null, ) { - $this->rootPath = rtrim($rootPath, DIRECTORY_SEPARATOR); + $this->rootPath = rtrim($rootPath, '/\\'); } public function put(string $path, string $contents): bool @@ -108,12 +108,12 @@ public function exists(string $path): bool public function url(string $path): string { - $normalizedPath = $this->normalizePath($path); + $normalizedPath = $this->normalizePathForUrl($path); if ($this->publicBaseUrl !== null && $this->publicBaseUrl !== '') { return rtrim($this->publicBaseUrl, '/') . '/' . $normalizedPath; } - return $this->buildFullPath($path); + return str_replace('\\', '/', $this->buildFullPath($path)); } public function size(string $path): int @@ -133,13 +133,25 @@ private function buildFullPath(string $path): string return $this->rootPath . DIRECTORY_SEPARATOR . $this->normalizePath($path); } - private function normalizePath(string $path): string + private function normalizePathForUrl(string $path): string { $normalizedPath = ltrim(str_replace('\\', '/', trim($path)), '/'); - if ($normalizedPath === '' || str_contains($normalizedPath, '..')) { + if ($normalizedPath === '') { throw new StorageException('Invalid storage path.'); } - return str_replace('/', DIRECTORY_SEPARATOR, $normalizedPath); + $segments = explode('/', $normalizedPath); + foreach ($segments as $segment) { + if ($segment === '..' || $segment === '') { + throw new StorageException('Invalid storage path.'); + } + } + + return $normalizedPath; + } + + private function normalizePath(string $path): string + { + return str_replace('/', DIRECTORY_SEPARATOR, $this->normalizePathForUrl($path)); } } diff --git a/phpmyfaq/src/phpMyFAQ/Storage/S3Storage.php b/phpmyfaq/src/phpMyFAQ/Storage/S3Storage.php index ec11ed1286..0140a01c4d 100644 --- a/phpmyfaq/src/phpMyFAQ/Storage/S3Storage.php +++ b/phpmyfaq/src/phpMyFAQ/Storage/S3Storage.php @@ -67,7 +67,7 @@ public function get(string $path): string 'Key' => $key, ])); - if (!is_array($result) || !array_key_exists('Body', $result)) { + if (!isset($result['Body'])) { throw new StorageException('Invalid S3 response while reading object: ' . $key); } @@ -112,7 +112,7 @@ public function size(string $path): int 'Key' => $key, ])); - if (!is_array($result) || !isset($result['ContentLength'])) { + if (!isset($result['ContentLength'])) { throw new StorageException('Invalid S3 response while reading object size: ' . $key); } @@ -134,10 +134,17 @@ private function run(callable $callback): mixed private function buildKey(string $path): string { $normalizedPath = ltrim(str_replace('\\', '/', trim($path)), '/'); - if ($normalizedPath === '' || str_contains($normalizedPath, '..')) { + if ($normalizedPath === '') { throw new StorageException('Invalid storage path.'); } + $segments = explode('/', $normalizedPath); + foreach ($segments as $segment) { + if ($segment === '..' || $segment === '') { + throw new StorageException('Invalid storage path.'); + } + } + if ($this->prefix === '') { return $normalizedPath; } diff --git a/phpmyfaq/src/phpMyFAQ/Storage/StorageFactory.php b/phpmyfaq/src/phpMyFAQ/Storage/StorageFactory.php index 7647389e81..a3790ee1e4 100644 --- a/phpmyfaq/src/phpMyFAQ/Storage/StorageFactory.php +++ b/phpmyfaq/src/phpMyFAQ/Storage/StorageFactory.php @@ -66,6 +66,10 @@ private function createS3Storage(): S3Storage $key = $this->readStringConfig('storage.s3.key'); $secret = $this->readStringConfig('storage.s3.secret'); + if (($key === null) !== ($secret === null)) { + throw new StorageException('Both storage.s3.key and storage.s3.secret must be provided together.'); + } + if ($key !== null && $secret !== null) { $s3Config['credentials'] = [ 'key' => $key, diff --git a/tests/phpMyFAQ/Storage/FilesystemStorageTest.php b/tests/phpMyFAQ/Storage/FilesystemStorageTest.php index 3fc476272b..f3553c33be 100644 --- a/tests/phpMyFAQ/Storage/FilesystemStorageTest.php +++ b/tests/phpMyFAQ/Storage/FilesystemStorageTest.php @@ -57,7 +57,54 @@ public function testPutStreamThrowsForInvalidStream(): void $storage->putStream('invalid/file.txt', 'not-a-stream'); } - public function testInvalidPathThrowsException(): void + public function testUrlAlwaysUsesForwardSlashes(): void + { + $storage = new FilesystemStorage($this->tmpDir, 'https://cdn.example.com/files'); + + // Even with backslashes in the input path, the URL should use forward slashes + $this->assertSame( + 'https://cdn.example.com/files/attachments/123/file.pdf', + $storage->url('attachments\\123\\file.pdf'), + ); + } + + public function testUrlWithoutBaseUrlUsesForwardSlashes(): void + { + $storage = new FilesystemStorage($this->tmpDir); + + $url = $storage->url('sub/dir/file.txt'); + + $this->assertStringNotContainsString('\\', $url); + $this->assertStringContainsString('sub/dir/file.txt', $url); + } + + public function testUrlWithTrailingSlashInBaseUrl(): void + { + $storage = new FilesystemStorage($this->tmpDir, 'https://cdn.example.com/storage/'); + + $this->assertSame( + 'https://cdn.example.com/storage/foo/bar.txt', + $storage->url('foo/bar.txt'), + ); + } + + public function testConstructorTrimsTrailingSlashes(): void + { + // Forward slash trailing + $storage = new FilesystemStorage($this->tmpDir . '/', 'https://cdn.example.com'); + $this->assertTrue($storage->put('trim-test.txt', 'ok')); + $this->assertSame('ok', $storage->get('trim-test.txt')); + } + + public function testDoubleDotInFilenameIsAllowed(): void + { + $storage = new FilesystemStorage($this->tmpDir); + + $this->assertTrue($storage->put('backups/file..backup.txt', 'data')); + $this->assertSame('data', $storage->get('backups/file..backup.txt')); + } + + public function testTraversalPathThrowsException(): void { $storage = new FilesystemStorage($this->tmpDir); @@ -65,6 +112,14 @@ public function testInvalidPathThrowsException(): void $storage->put('../escape.txt', 'invalid'); } + public function testEmptySegmentPathThrowsException(): void + { + $storage = new FilesystemStorage($this->tmpDir); + + $this->expectException(StorageException::class); + $storage->put('foo//bar.txt', 'invalid'); + } + private function removeDirectory(string $directory): void { if (!is_dir($directory)) { diff --git a/tests/phpMyFAQ/Storage/S3StorageTest.php b/tests/phpMyFAQ/Storage/S3StorageTest.php index b81c29fba7..3f3c42ba40 100644 --- a/tests/phpMyFAQ/Storage/S3StorageTest.php +++ b/tests/phpMyFAQ/Storage/S3StorageTest.php @@ -40,7 +40,26 @@ public function testPutStreamWritesObject(): void fclose($stream); } - public function testInvalidPathThrowsException(): void + public function testGetAndSizeAcceptArrayAccessResults(): void + { + $client = new ArrayAccessS3Client(); + $storage = new S3Storage($client, 'pmf-bucket'); + + $storage->put('array-access.txt', 'content'); + $this->assertSame('content', $storage->get('array-access.txt')); + $this->assertSame(7, $storage->size('array-access.txt')); + } + + public function testDoubleDotInFilenameIsAllowed(): void + { + $client = new FakeS3Client(); + $storage = new S3Storage($client, 'pmf-bucket'); + + $this->assertTrue($storage->put('backups/file..backup.txt', 'data')); + $this->assertSame('data', $storage->get('backups/file..backup.txt')); + } + + public function testTraversalPathThrowsException(): void { $client = new FakeS3Client(); $storage = new S3Storage($client, 'pmf-bucket'); @@ -48,6 +67,15 @@ public function testInvalidPathThrowsException(): void $this->expectException(StorageException::class); $storage->put('../escape.txt', 'x'); } + + public function testEmptySegmentPathThrowsException(): void + { + $client = new FakeS3Client(); + $storage = new S3Storage($client, 'pmf-bucket'); + + $this->expectException(StorageException::class); + $storage->put('foo//bar.txt', 'x'); + } } final class FakeS3Client @@ -95,3 +123,81 @@ public function headObject(array $args): array return ['ContentLength' => strlen((string) ($this->objects[$args['Key']] ?? ''))]; } } + +/** + * S3 client that returns ArrayAccess results, mimicking Aws\Result. + */ +final class ArrayAccessS3Client +{ + public array $objects = []; + + public function putObject(array $args): ArrayAccessResult + { + $body = $args['Body']; + if (is_resource($body)) { + $body = stream_get_contents($body) ?: ''; + } + + $this->objects[$args['Key']] = $body; + + return new ArrayAccessResult([]); + } + + public function getObject(array $args): ArrayAccessResult + { + return new ArrayAccessResult(['Body' => $this->objects[$args['Key']] ?? '']); + } + + public function headObject(array $args): ArrayAccessResult + { + return new ArrayAccessResult([ + 'ContentLength' => strlen((string) ($this->objects[$args['Key']] ?? '')), + ]); + } + + public function doesObjectExistV2(string $bucket, string $key): bool + { + return isset($this->objects[$key]); + } + + public function deleteObject(array $args): ArrayAccessResult + { + unset($this->objects[$args['Key']]); + return new ArrayAccessResult([]); + } + + public function getObjectUrl(string $bucket, string $key): string + { + return sprintf('https://%s.s3.local/%s', $bucket, $key); + } +} + +/** + * Minimal ArrayAccess implementation mimicking Aws\Result behavior. + */ +final class ArrayAccessResult implements \ArrayAccess +{ + public function __construct(private array $data) + { + } + + public function offsetExists(mixed $offset): bool + { + return array_key_exists($offset, $this->data); + } + + public function offsetGet(mixed $offset): mixed + { + return $this->data[$offset] ?? null; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + $this->data[$offset] = $value; + } + + public function offsetUnset(mixed $offset): void + { + unset($this->data[$offset]); + } +} diff --git a/tests/phpMyFAQ/Storage/StorageFactoryTest.php b/tests/phpMyFAQ/Storage/StorageFactoryTest.php index 18ee544cc2..fe5f920026 100644 --- a/tests/phpMyFAQ/Storage/StorageFactoryTest.php +++ b/tests/phpMyFAQ/Storage/StorageFactoryTest.php @@ -83,4 +83,52 @@ public function testCreateReturnsS3StorageWhenConfigured(): void $storage = $factory->create(); $this->assertInstanceOf(S3Storage::class, $storage); } + + public function testCreateThrowsWhenOnlyS3KeyProvided(): void + { + $configuration = $this->createStub(Configuration::class); + $configuration + ->method('get') + ->willReturnMap([ + ['storage.type', 's3'], + ['storage.s3.bucket', 'pmf-bucket'], + ['storage.s3.prefix', null], + ['storage.s3.publicBaseUrl', null], + ['storage.s3.region', null], + ['storage.s3.endpoint', null], + ['storage.s3.key', 'AKIAIOSFODNN7EXAMPLE'], + ['storage.s3.secret', null], + ['storage.s3.usePathStyle', null], + ]); + + $factory = new StorageFactory($configuration); + + $this->expectException(StorageException::class); + $this->expectExceptionMessage('Both storage.s3.key and storage.s3.secret must be provided together.'); + $factory->create(); + } + + public function testCreateThrowsWhenOnlyS3SecretProvided(): void + { + $configuration = $this->createStub(Configuration::class); + $configuration + ->method('get') + ->willReturnMap([ + ['storage.type', 's3'], + ['storage.s3.bucket', 'pmf-bucket'], + ['storage.s3.prefix', null], + ['storage.s3.publicBaseUrl', null], + ['storage.s3.region', null], + ['storage.s3.endpoint', null], + ['storage.s3.key', null], + ['storage.s3.secret', 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY'], + ['storage.s3.usePathStyle', null], + ]); + + $factory = new StorageFactory($configuration); + + $this->expectException(StorageException::class); + $this->expectExceptionMessage('Both storage.s3.key and storage.s3.secret must be provided together.'); + $factory->create(); + } } From c8e839935bc87a1d370f25cfd53f5cfabdb23d4c Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Mon, 9 Feb 2026 09:44:50 +0100 Subject: [PATCH 4/9] fix: corrected review notes --- composer.lock | 126 +++++++++--------- phpmyfaq/src/phpMyFAQ/Attachment/File.php | 4 +- .../src/phpMyFAQ/Storage/StorageFactory.php | 16 ++- .../Storage/FilesystemStorageTest.php | 14 +- tests/phpMyFAQ/Storage/StorageFactoryTest.php | 50 +++++++ 5 files changed, 135 insertions(+), 75 deletions(-) diff --git a/composer.lock b/composer.lock index 0cf54f1a49..d176f4272d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "aef090d412f5e249fd8c6457c572ebcc", + "content-hash": "2bd472121bb2963a7f03d0c57c9497e9", "packages": [ { "name": "2tvenom/cborencode", @@ -259,16 +259,16 @@ }, { "name": "brick/math", - "version": "0.14.6", + "version": "0.14.7", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "32498d5e1897e7642c0b961ace2df6d7dc9a3bc3" + "reference": "07ff363b16ef8aca9692bba3be9e73fe63f34e50" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/32498d5e1897e7642c0b961ace2df6d7dc9a3bc3", - "reference": "32498d5e1897e7642c0b961ace2df6d7dc9a3bc3", + "url": "https://api.github.com/repos/brick/math/zipball/07ff363b16ef8aca9692bba3be9e73fe63f34e50", + "reference": "07ff363b16ef8aca9692bba3be9e73fe63f34e50", "shasum": "" }, "require": { @@ -307,7 +307,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.14.6" + "source": "https://github.com/brick/math/tree/0.14.7" }, "funding": [ { @@ -315,7 +315,7 @@ "type": "github" } ], - "time": "2026-02-05T07:59:58+00:00" + "time": "2026-02-07T10:57:35+00:00" }, { "name": "dasprid/enum", @@ -1582,16 +1582,16 @@ }, { "name": "minishlink/web-push", - "version": "v9.0.4", + "version": "v10.0.1", "source": { "type": "git", "url": "https://github.com/web-push-libs/web-push-php.git", - "reference": "f979f40b0017d2f86d82b9f21edbc515d031cc23" + "reference": "08463189d3501cbd78a8625c87ab6680a7397aad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-push-libs/web-push-php/zipball/f979f40b0017d2f86d82b9f21edbc515d031cc23", - "reference": "f979f40b0017d2f86d82b9f21edbc515d031cc23", + "url": "https://api.github.com/repos/web-push-libs/web-push-php/zipball/08463189d3501cbd78a8625c87ab6680a7397aad", + "reference": "08463189d3501cbd78a8625c87ab6680a7397aad", "shasum": "" }, "require": { @@ -1600,15 +1600,15 @@ "ext-mbstring": "*", "ext-openssl": "*", "guzzlehttp/guzzle": "^7.9.2", - "php": ">=8.1", + "php": ">=8.2", "spomky-labs/base64url": "^2.0.4", - "symfony/polyfill-php82": "^v1.31.0", - "web-token/jwt-library": "^3.3.0|^4.0.0" + "web-token/jwt-library": "^3.4.9|^4.0.6" }, "require-dev": { "friendsofphp/php-cs-fixer": "^v3.91.3", - "phpstan/phpstan": "^2.1.2", - "phpunit/phpunit": "^10.5.44|^11.5.6", + "phpstan/phpstan": "^2.1.33", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^11.5.46|^12.5.2", "symfony/polyfill-iconv": "^1.33" }, "suggest": { @@ -1643,9 +1643,9 @@ ], "support": { "issues": "https://github.com/web-push-libs/web-push-php/issues", - "source": "https://github.com/web-push-libs/web-push-php/tree/v9.0.4" + "source": "https://github.com/web-push-libs/web-push-php/tree/v10.0.1" }, - "time": "2025-12-10T14:00:12+00:00" + "time": "2025-12-15T10:04:28+00:00" }, { "name": "monolog/monolog", @@ -1878,16 +1878,16 @@ }, { "name": "nette/schema", - "version": "v1.3.3", + "version": "v1.3.4", "source": { "type": "git", "url": "https://github.com/nette/schema.git", - "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004" + "reference": "086497a2f34b82fede9b5a41cc8e131d087cd8f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/schema/zipball/2befc2f42d7c715fd9d95efc31b1081e5d765004", - "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004", + "url": "https://api.github.com/repos/nette/schema/zipball/086497a2f34b82fede9b5a41cc8e131d087cd8f7", + "reference": "086497a2f34b82fede9b5a41cc8e131d087cd8f7", "shasum": "" }, "require": { @@ -1895,8 +1895,8 @@ "php": "8.1 - 8.5" }, "require-dev": { - "nette/tester": "^2.5.2", - "phpstan/phpstan-nette": "^2.0@stable", + "nette/tester": "^2.6", + "phpstan/phpstan": "^2.0@stable", "tracy/tracy": "^2.8" }, "type": "library", @@ -1937,9 +1937,9 @@ ], "support": { "issues": "https://github.com/nette/schema/issues", - "source": "https://github.com/nette/schema/tree/v1.3.3" + "source": "https://github.com/nette/schema/tree/v1.3.4" }, - "time": "2025-10-30T22:57:59+00:00" + "time": "2026-02-08T02:54:00+00:00" }, { "name": "nette/utils", @@ -6537,16 +6537,16 @@ "packages-dev": [ { "name": "carthage-software/mago", - "version": "1.5.0", + "version": "1.6.0", "source": { "type": "git", "url": "https://github.com/carthage-software/mago.git", - "reference": "0ce3f5d230f8ae330617b04299700347d3ca6806" + "reference": "089c4257ef6e854d5ec04d9cf3000cc92caea443" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/carthage-software/mago/zipball/0ce3f5d230f8ae330617b04299700347d3ca6806", - "reference": "0ce3f5d230f8ae330617b04299700347d3ca6806", + "url": "https://api.github.com/repos/carthage-software/mago/zipball/089c4257ef6e854d5ec04d9cf3000cc92caea443", + "reference": "089c4257ef6e854d5ec04d9cf3000cc92caea443", "shasum": "" }, "require": { @@ -6584,7 +6584,7 @@ ], "support": { "issues": "https://github.com/carthage-software/mago/issues", - "source": "https://github.com/carthage-software/mago/tree/1.5.0" + "source": "https://github.com/carthage-software/mago/tree/1.6.0" }, "funding": [ { @@ -6592,33 +6592,33 @@ "type": "github" } ], - "time": "2026-02-04T21:37:39+00:00" + "time": "2026-02-07T00:46:32+00:00" }, { "name": "doctrine/deprecations", - "version": "1.1.5", + "version": "1.1.6", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38" + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, "conflict": { - "phpunit/phpunit": "<=7.5 || >=13" + "phpunit/phpunit": "<=7.5 || >=14" }, "require-dev": { - "doctrine/coding-standard": "^9 || ^12 || ^13", - "phpstan/phpstan": "1.4.10 || 2.1.11", + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", "psr/log": "^1 || ^2 || ^3" }, "suggest": { @@ -6638,9 +6638,9 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.5" + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" }, - "time": "2025-04-07T20:06:18+00:00" + "time": "2026-02-07T07:09:04+00:00" }, { "name": "doctrine/instantiator", @@ -7563,16 +7563,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.9", + "version": "12.5.10", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "83d4c158526c879b4c5cf7149d27958b6d912373" + "reference": "1686e30f6b32d35592f878a7f56fd0421d7d56c5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/83d4c158526c879b4c5cf7149d27958b6d912373", - "reference": "83d4c158526c879b4c5cf7149d27958b6d912373", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/1686e30f6b32d35592f878a7f56fd0421d7d56c5", + "reference": "1686e30f6b32d35592f878a7f56fd0421d7d56c5", "shasum": "" }, "require": { @@ -7586,7 +7586,7 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.3", - "phpunit/php-code-coverage": "^12.5.2", + "phpunit/php-code-coverage": "^12.5.3", "phpunit/php-file-iterator": "^6.0.1", "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", @@ -7641,7 +7641,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.9" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.10" }, "funding": [ { @@ -7665,26 +7665,26 @@ "type": "tidelift" } ], - "time": "2026-02-05T08:01:09+00:00" + "time": "2026-02-08T07:06:48+00:00" }, { "name": "radebatz/type-info-extras", - "version": "1.0.4", + "version": "1.0.5", "source": { "type": "git", "url": "https://github.com/DerManoMann/type-info-extras.git", - "reference": "23b74c4690fb3d147a1b34c4dc090a9ddc6dc31a" + "reference": "217e249a35dbdbd9537f99de622cc080c3f8fb2c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/DerManoMann/type-info-extras/zipball/23b74c4690fb3d147a1b34c4dc090a9ddc6dc31a", - "reference": "23b74c4690fb3d147a1b34c4dc090a9ddc6dc31a", + "url": "https://api.github.com/repos/DerManoMann/type-info-extras/zipball/217e249a35dbdbd9537f99de622cc080c3f8fb2c", + "reference": "217e249a35dbdbd9537f99de622cc080c3f8fb2c", "shasum": "" }, "require": { "php": ">=8.2", "phpstan/phpdoc-parser": "^2.0", - "symfony/type-info": "^7.3.8 || ^7.4.1 || ^8.0" + "symfony/type-info": "^7.3.8 || ^7.4.1 || ^8.0 || ^8.1-@dev" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.70", @@ -7725,27 +7725,27 @@ ], "support": { "issues": "https://github.com/DerManoMann/type-info-extras/issues", - "source": "https://github.com/DerManoMann/type-info-extras/tree/1.0.4" + "source": "https://github.com/DerManoMann/type-info-extras/tree/1.0.5" }, - "time": "2026-01-12T21:15:50+00:00" + "time": "2026-02-07T00:19:33+00:00" }, { "name": "rector/rector", - "version": "2.3.5", + "version": "2.3.6", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "9442f4037de6a5347ae157fe8e6c7cda9d909070" + "reference": "ca9ebb81d280cd362ea39474dabd42679e32ca6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/9442f4037de6a5347ae157fe8e6c7cda9d909070", - "reference": "9442f4037de6a5347ae157fe8e6c7cda9d909070", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/ca9ebb81d280cd362ea39474dabd42679e32ca6b", + "reference": "ca9ebb81d280cd362ea39474dabd42679e32ca6b", "shasum": "" }, "require": { "php": "^7.4|^8.0", - "phpstan/phpstan": "^2.1.36" + "phpstan/phpstan": "^2.1.38" }, "conflict": { "rector/rector-doctrine": "*", @@ -7779,7 +7779,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.3.5" + "source": "https://github.com/rectorphp/rector/tree/2.3.6" }, "funding": [ { @@ -7787,7 +7787,7 @@ "type": "github" } ], - "time": "2026-01-28T15:22:48+00:00" + "time": "2026-02-06T14:25:06+00:00" }, { "name": "sebastian/cli-parser", diff --git a/phpmyfaq/src/phpMyFAQ/Attachment/File.php b/phpmyfaq/src/phpMyFAQ/Attachment/File.php index 7f9cae2d8d..d05ff17a40 100644 --- a/phpmyfaq/src/phpMyFAQ/Attachment/File.php +++ b/phpmyfaq/src/phpMyFAQ/Attachment/File.php @@ -156,9 +156,7 @@ public function delete(): bool /** * Retrieve file contents into a variable. */ - public function get(): string - { - } + public function get(): string {} /** * Output current file to stdout. diff --git a/phpmyfaq/src/phpMyFAQ/Storage/StorageFactory.php b/phpmyfaq/src/phpMyFAQ/Storage/StorageFactory.php index a3790ee1e4..4d47a96a85 100644 --- a/phpmyfaq/src/phpMyFAQ/Storage/StorageFactory.php +++ b/phpmyfaq/src/phpMyFAQ/Storage/StorageFactory.php @@ -92,14 +92,22 @@ private function resolveFilesystemRoot(): string { $configuredRoot = $this->readStringConfig('storage.filesystem.root'); if ($configuredRoot !== null && $configuredRoot !== '') { - return $configuredRoot; + $root = $configuredRoot; + } elseif (defined('PMF_ATTACHMENTS_DIR') && PMF_ATTACHMENTS_DIR !== false) { + $root = (string) PMF_ATTACHMENTS_DIR; + } else { + $root = PMF_ROOT_DIR . '/content/user/attachments'; } - if (defined('PMF_ATTACHMENTS_DIR') && PMF_ATTACHMENTS_DIR !== false) { - return (string) PMF_ATTACHMENTS_DIR; + if (!is_dir($root) && !@mkdir($root, 0o775, true) && !is_dir($root)) { + throw new StorageException('Storage root directory could not be created: ' . $root); } - return PMF_ROOT_DIR . '/content/user/attachments'; + if (!is_writable($root)) { + throw new StorageException('Storage root directory is not writable: ' . $root); + } + + return $root; } private function readRequiredConfig(string $key): string diff --git a/tests/phpMyFAQ/Storage/FilesystemStorageTest.php b/tests/phpMyFAQ/Storage/FilesystemStorageTest.php index f3553c33be..22e0652bd7 100644 --- a/tests/phpMyFAQ/Storage/FilesystemStorageTest.php +++ b/tests/phpMyFAQ/Storage/FilesystemStorageTest.php @@ -40,13 +40,17 @@ public function testPutStreamWritesContents(): void { $storage = new FilesystemStorage($this->tmpDir); $stream = fopen('php://memory', 'rb+'); - fwrite($stream, 'stream content'); - rewind($stream); + $this->assertIsResource($stream); - $this->assertTrue($storage->putStream('stream/file.txt', $stream)); - $this->assertSame('stream content', $storage->get('stream/file.txt')); + try { + fwrite($stream, 'stream content'); + rewind($stream); - fclose($stream); + $this->assertTrue($storage->putStream('stream/file.txt', $stream)); + $this->assertSame('stream content', $storage->get('stream/file.txt')); + } finally { + fclose($stream); + } } public function testPutStreamThrowsForInvalidStream(): void diff --git a/tests/phpMyFAQ/Storage/StorageFactoryTest.php b/tests/phpMyFAQ/Storage/StorageFactoryTest.php index fe5f920026..4b7aadc5a9 100644 --- a/tests/phpMyFAQ/Storage/StorageFactoryTest.php +++ b/tests/phpMyFAQ/Storage/StorageFactoryTest.php @@ -84,6 +84,56 @@ public function testCreateReturnsS3StorageWhenConfigured(): void $this->assertInstanceOf(S3Storage::class, $storage); } + public function testCreateThrowsWhenFilesystemRootIsNotWritable(): void + { + // Use a path under /proc (Linux) or /System (macOS) that exists but is not writable + $readOnlyDir = PHP_OS_FAMILY === 'Darwin' ? '/System' : '/proc'; + if (!is_dir($readOnlyDir) || is_writable($readOnlyDir)) { + $this->markTestSkipped('No read-only directory available for this test.'); + } + + $configuration = $this->createStub(Configuration::class); + $configuration + ->method('get') + ->willReturnMap([ + ['storage.type', 'filesystem'], + ['storage.filesystem.root', $readOnlyDir . '/phpmyfaq-test-unwritable'], + ['storage.filesystem.publicBaseUrl', null], + ]); + + $factory = new StorageFactory($configuration); + + $this->expectException(StorageException::class); + $this->expectExceptionMessage('Storage root directory'); + $factory->create(); + } + + public function testCreateCreatesFilesystemRootIfMissing(): void + { + $tmpDir = sys_get_temp_dir() . '/phpmyfaq-factory-test-' . uniqid('', true); + + try { + $configuration = $this->createStub(Configuration::class); + $configuration + ->method('get') + ->willReturnMap([ + ['storage.type', 'filesystem'], + ['storage.filesystem.root', $tmpDir], + ['storage.filesystem.publicBaseUrl', null], + ]); + + $factory = new StorageFactory($configuration); + $storage = $factory->create(); + + $this->assertInstanceOf(FilesystemStorage::class, $storage); + $this->assertDirectoryExists($tmpDir); + } finally { + if (is_dir($tmpDir)) { + rmdir($tmpDir); + } + } + } + public function testCreateThrowsWhenOnlyS3KeyProvided(): void { $configuration = $this->createStub(Configuration::class); From 906e3c40b16fc0c4f63c2d928d97446377cf63a9 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Mon, 9 Feb 2026 10:45:24 +0100 Subject: [PATCH 5/9] fix: implement get() method --- phpmyfaq/src/phpMyFAQ/Attachment/File.php | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/phpmyfaq/src/phpMyFAQ/Attachment/File.php b/phpmyfaq/src/phpMyFAQ/Attachment/File.php index d05ff17a40..c106168783 100644 --- a/phpmyfaq/src/phpMyFAQ/Attachment/File.php +++ b/phpmyfaq/src/phpMyFAQ/Attachment/File.php @@ -155,8 +155,19 @@ public function delete(): bool /** * Retrieve file contents into a variable. + * + * @throws AttachmentException */ - public function get(): string {} + public function get(): string + { + $file = $this->getFile(); + $contents = ''; + while (!$file->eof()) { + $contents .= $file->getChunk(); + } + + return $contents; + } /** * Output current file to stdout. From 70eec7928825d05abd700e788a8f517c6528a154 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Mon, 9 Feb 2026 10:48:06 +0100 Subject: [PATCH 6/9] fix: removed misleading @var object annotation on a typed S3Client instantiation --- phpmyfaq/src/phpMyFAQ/Storage/StorageFactory.php | 1 - 1 file changed, 1 deletion(-) diff --git a/phpmyfaq/src/phpMyFAQ/Storage/StorageFactory.php b/phpmyfaq/src/phpMyFAQ/Storage/StorageFactory.php index 4d47a96a85..b75ac77879 100644 --- a/phpmyfaq/src/phpMyFAQ/Storage/StorageFactory.php +++ b/phpmyfaq/src/phpMyFAQ/Storage/StorageFactory.php @@ -82,7 +82,6 @@ private function createS3Storage(): S3Storage $s3Config['use_path_style_endpoint'] = filter_var($usePathStyle, FILTER_VALIDATE_BOOL); } - /** @var object $client */ $client = new S3Client($s3Config); return new S3Storage($client, $bucket, $prefix, $publicBaseUrl); From 38cddccf535a4d1f3bc26e7507c17a0cbad02329 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Mon, 9 Feb 2026 11:28:32 +0100 Subject: [PATCH 7/9] feat: migrated attachment file I/O to StorageInterface --- .../phpMyFAQ/Attachment/AttachmentFactory.php | 9 +- phpmyfaq/src/phpMyFAQ/Attachment/File.php | 131 ++++++++++++++---- phpmyfaq/src/phpMyFAQ/Bootstrapper.php | 10 +- phpmyfaq/src/phpMyFAQ/Configuration.php | 4 + .../Controller/Frontend/FaqController.php | 2 +- .../phpMyFAQ/Enums/AttachmentStorageType.php | 1 + .../src/phpMyFAQ/Faq/FaqDisplayService.php | 5 +- phpmyfaq/src/phpMyFAQ/Language.php | 22 +++ .../Attachment/AttachmentFactoryTest.php | 9 ++ tests/phpMyFAQ/Attachment/FileTest.php | 19 +++ 10 files changed, 175 insertions(+), 37 deletions(-) diff --git a/phpmyfaq/src/phpMyFAQ/Attachment/AttachmentFactory.php b/phpmyfaq/src/phpMyFAQ/Attachment/AttachmentFactory.php index e12e97e4d3..07871e58e0 100644 --- a/phpmyfaq/src/phpMyFAQ/Attachment/AttachmentFactory.php +++ b/phpmyfaq/src/phpMyFAQ/Attachment/AttachmentFactory.php @@ -56,7 +56,7 @@ class AttachmentFactory public static function create(?int $attachmentId = null, ?string $key = null): File { $return = match (self::$storageType) { - AttachmentStorageType::FILESYSTEM->value => new File($attachmentId), + AttachmentStorageType::FILESYSTEM->value, AttachmentStorageType::S3->value => new File($attachmentId), default => throw new AttachmentException('Unknown attachment storage type'), }; @@ -187,8 +187,9 @@ public static function countByRecordId(Configuration $configuration, int $record * * @param string $defaultKey Default key * @param bool $encryptionEnabled Enabled encryption? + * @param int|null $storageType Optional storage type (defaults to filesystem) */ - public static function init(string $defaultKey, bool $encryptionEnabled): void + public static function init(string $defaultKey, bool $encryptionEnabled, ?int $storageType = null): void { if (null === self::$defaultKey) { self::$defaultKey = $defaultKey; @@ -197,5 +198,9 @@ public static function init(string $defaultKey, bool $encryptionEnabled): void if (null === self::$encryptionEnabled) { self::$encryptionEnabled = $encryptionEnabled; } + + if ($storageType !== null) { + self::$storageType = $storageType; + } } } diff --git a/phpmyfaq/src/phpMyFAQ/Attachment/File.php b/phpmyfaq/src/phpMyFAQ/Attachment/File.php index c106168783..5c96140231 100644 --- a/phpmyfaq/src/phpMyFAQ/Attachment/File.php +++ b/phpmyfaq/src/phpMyFAQ/Attachment/File.php @@ -23,6 +23,10 @@ use phpMyFAQ\Attachment\Filesystem\File\EncryptedFile; use phpMyFAQ\Attachment\Filesystem\File\FileException; use phpMyFAQ\Attachment\Filesystem\File\VanillaFile; +use phpMyFAQ\Configuration; +use phpMyFAQ\Storage\StorageException; +use phpMyFAQ\Storage\StorageFactory; +use phpMyFAQ\Storage\StorageInterface; /** * Class File @@ -31,6 +35,8 @@ */ class File extends AbstractAttachment implements AttachmentInterface { + private ?StorageInterface $storage = null; + /** * Build a file path under which the attachment file is accessible in filesystem * @@ -38,17 +44,31 @@ class File extends AbstractAttachment implements AttachmentInterface */ protected function buildFilePath(): string { + $storagePath = $this->buildStoragePath(); $attachmentPath = PMF_ATTACHMENTS_DIR; + + return $attachmentPath . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $storagePath); + } + + /** + * Build the storage key/path for the current attachment. + * + * @throws AttachmentException + */ + protected function buildStoragePath(): string + { $fsHash = $this->mkVirtualHash(); $subDirCount = 3; $subDirNameLength = 5; + $segments = []; for ($i = 0; $i < $subDirCount; ++$i) { - $attachmentPath .= - DIRECTORY_SEPARATOR . substr((string) $fsHash, $i * $subDirNameLength, $subDirNameLength); + $segments[] = substr((string) $fsHash, $i * $subDirNameLength, $subDirNameLength); } - return $attachmentPath . (DIRECTORY_SEPARATOR . substr((string) $fsHash, $i * $subDirNameLength)); + $segments[] = substr((string) $fsHash, $i * $subDirNameLength); + + return implode('/', $segments); } /** @@ -72,6 +92,10 @@ public function createSubDirs(string $filepath): bool */ public function isStorageOk(): bool { + if ($this->usesCloudStorage()) { + return true; + } + clearstatcache(); $attachmentDir = dirname($this->buildFilePath()); @@ -103,31 +127,36 @@ public function save(string $filePath, ?string $filename = null): bool $this->saveMeta(); - $targetFile = $this->buildFilePath(); - - if ($this->createSubDirs($targetFile)) { - // Doing this check, we're sure not to unnecessarily - // overwrite existing unencrypted file duplicates. - if (!$this->linkedRecords()) { - $vanillaFile = new VanillaFile($filePath); - $target = $this->getFile(FilesystemFile::MODE_WRITE); - - $success = $vanillaFile->moveTo($target); - } - - if ($this->linkedRecords()) { - $success = true; + if ($this->linkedRecords()) { + $success = true; + } else { + try { + if ($this->encrypted) { + $targetFile = $this->buildFilePath(); + if ($this->createSubDirs($targetFile)) { + $vanillaFile = new VanillaFile($filePath); + $target = $this->getFile(FilesystemFile::MODE_WRITE); + $success = $vanillaFile->moveTo($target); + } + } else { + $contents = file_get_contents($filePath); + if ($contents !== false) { + $success = $this->getStorage()->put($this->buildStoragePath(), $contents); + } + } + } catch (StorageException $storageException) { + throw new AttachmentException($storageException->getMessage(), 0, $storageException); } + } - if ($success) { - $this->postUpdateMeta(); - } + if ($success) { + $this->postUpdateMeta(); + } - if (!$success) { - // File wasn't saved - $this->delete(); - $success = false; - } + if (!$success) { + // File wasn't saved + $this->delete(); + $success = false; } } @@ -145,7 +174,15 @@ public function delete(): bool // Won't delete the file if there are still some records hanging on it if (!$this->linkedRecords()) { - $success &= $this->getFile()->delete(); + if ($this->encrypted) { + $success &= $this->getFile()->delete(); + } else { + try { + $this->getStorage()->delete($this->buildStoragePath()); + } catch (StorageException $storageException) { + throw new AttachmentException($storageException->getMessage(), 0, $storageException); + } + } } $this->deleteMeta(); @@ -160,6 +197,14 @@ public function delete(): bool */ public function get(): string { + if (!$this->encrypted) { + try { + return $this->getStorage()->get($this->buildStoragePath()); + } catch (StorageException $storageException) { + throw new AttachmentException($storageException->getMessage(), 0, $storageException); + } + } + $file = $this->getFile(); $contents = ''; while (!$file->eof()) { @@ -176,6 +221,11 @@ public function get(): string */ public function rawOut(): void { + if (!$this->encrypted) { + echo $this->get(); + return; + } + $file = $this->getFile(); while (!$file->eof()) { echo $file->getChunk(); @@ -196,4 +246,33 @@ private function getFile(string $mode = FilesystemFile::MODE_READ): EncryptedFil return new VanillaFile($this->buildFilePath(), $mode); } + + /** + * @throws AttachmentException + */ + private function getStorage(): StorageInterface + { + if ($this->storage instanceof StorageInterface) { + return $this->storage; + } + + $configuration = Configuration::getConfigurationInstance(); + if ($configuration === null) { + throw new AttachmentException('Storage cannot be initialized without configuration.'); + } + + $this->storage = new StorageFactory($configuration)->create(); + + return $this->storage; + } + + private function usesCloudStorage(): bool + { + $configuration = Configuration::getConfigurationInstance(); + if ($configuration === null) { + return false; + } + + return strtolower((string) $configuration->get('storage.type')) === 's3'; + } } diff --git a/phpmyfaq/src/phpMyFAQ/Bootstrapper.php b/phpmyfaq/src/phpMyFAQ/Bootstrapper.php index 9b9f7eff32..ec49b54f92 100644 --- a/phpmyfaq/src/phpMyFAQ/Bootstrapper.php +++ b/phpmyfaq/src/phpMyFAQ/Bootstrapper.php @@ -104,10 +104,12 @@ public function run(): self } // 16. Attachments directory - ConfigDirectoryResolver::resolveAttachmentsDir( - (string) $this->faqConfig->get('records.attachmentsPath'), - dirname(__DIR__, levels: 2), - ); + if (strtolower((string) $this->faqConfig->get('storage.type')) !== 's3') { + ConfigDirectoryResolver::resolveAttachmentsDir( + (string) $this->faqConfig->get('records.attachmentsPath'), + dirname(__DIR__, levels: 2), + ); + } // 17. Proxy header fix $this->fixProxyHeaders(); diff --git a/phpmyfaq/src/phpMyFAQ/Configuration.php b/phpmyfaq/src/phpMyFAQ/Configuration.php index c51f718227..9cd77ff20c 100644 --- a/phpmyfaq/src/phpMyFAQ/Configuration.php +++ b/phpmyfaq/src/phpMyFAQ/Configuration.php @@ -183,6 +183,10 @@ public function setContainer(mixed $container): void */ public function getDefaultLanguage(): string { + if (!isset($this->config['main.language'])) { + return 'en'; + } + return str_replace(['language_', '.php'], replace: '', subject: (string) $this->config['main.language']); } diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/FaqController.php b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/FaqController.php index 2c6c517458..a82dfcbb4d 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/FaqController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/FaqController.php @@ -399,7 +399,7 @@ public function show(Request $request): Response 'msgVoteSubmit' => Translation::get(key: 'msgVoteSubmit'), 'msgWriteComment' => Translation::get(key: 'msgWriteComment'), 'id' => $faqId, - 'lang' => $this->configuration->getLanguage()->getLanguage(), + 'lang' => $faq->faqRecord['lang'], 'msgNewContentName' => Translation::get(key: 'msgNewContentName'), 'msgNewContentMail' => Translation::get(key: 'msgNewContentMail'), 'defaultContentMail' => $this->currentUser->getUserId() > 0 diff --git a/phpmyfaq/src/phpMyFAQ/Enums/AttachmentStorageType.php b/phpmyfaq/src/phpMyFAQ/Enums/AttachmentStorageType.php index cef4ecf029..a16c98ec70 100644 --- a/phpmyfaq/src/phpMyFAQ/Enums/AttachmentStorageType.php +++ b/phpmyfaq/src/phpMyFAQ/Enums/AttachmentStorageType.php @@ -23,4 +23,5 @@ enum AttachmentStorageType: int { case FILESYSTEM = 0; case DATABASE = 1; // not used currently + case S3 = 2; } diff --git a/phpmyfaq/src/phpMyFAQ/Faq/FaqDisplayService.php b/phpmyfaq/src/phpMyFAQ/Faq/FaqDisplayService.php index 6e89c7fe78..d2fc4f0676 100644 --- a/phpmyfaq/src/phpMyFAQ/Faq/FaqDisplayService.php +++ b/phpmyfaq/src/phpMyFAQ/Faq/FaqDisplayService.php @@ -177,10 +177,7 @@ public function processQuestion(?string $highlight): string */ public function getAttachmentList(int $faqId): array { - if ( - !(bool) $this->configuration->get('records.disableAttachments') - || $this->faq->faqRecord['active'] !== 'yes' - ) { + if (!$this->configuration->get('records.disableAttachments') || $this->faq->faqRecord['active'] !== 'yes') { return []; } diff --git a/phpmyfaq/src/phpMyFAQ/Language.php b/phpmyfaq/src/phpMyFAQ/Language.php index f99cd7cf5d..1cbd32746d 100644 --- a/phpmyfaq/src/phpMyFAQ/Language.php +++ b/phpmyfaq/src/phpMyFAQ/Language.php @@ -133,6 +133,28 @@ public static function isASupportedLanguage(?string $langCode): bool */ public function getLanguage(): string { + // If the language is not set, try to get it from session or use default + if (self::$language === '') { + $sessionLang = $this->session->get('lang'); + if ($sessionLang !== null && self::isASupportedLanguage($sessionLang)) { + self::$language = $sessionLang; + } else { + // Try to fall back to the configured default language + try { + $defaultLang = $this->configuration->getDefaultLanguage(); + if ($defaultLang !== '' && self::isASupportedLanguage($defaultLang)) { + self::$language = $defaultLang; + } else { + // Ultimate fallback to 'en' + self::$language = 'en'; + } + } catch (\Throwable) { + // If configuration is not available or throws an error, use 'en' + self::$language = 'en'; + } + } + } + return strtolower(self::$language); } } diff --git a/tests/phpMyFAQ/Attachment/AttachmentFactoryTest.php b/tests/phpMyFAQ/Attachment/AttachmentFactoryTest.php index f7fd80fd5f..1da281bbfb 100644 --- a/tests/phpMyFAQ/Attachment/AttachmentFactoryTest.php +++ b/tests/phpMyFAQ/Attachment/AttachmentFactoryTest.php @@ -93,6 +93,15 @@ public function testCreateWithUnknownStorageType(): void AttachmentFactory::create(123); } + public function testCreateWithS3StorageType(): void + { + $this->setStorageType(AttachmentStorageType::S3->value); + + $attachment = AttachmentFactory::create(123, 'testkey'); + + $this->assertInstanceOf(File::class, $attachment); + } + public function testCreateWithEncryptionEnabled(): void { $this->setStorageType(AttachmentStorageType::FILESYSTEM->value); diff --git a/tests/phpMyFAQ/Attachment/FileTest.php b/tests/phpMyFAQ/Attachment/FileTest.php index 448d61febd..925ea7e9b4 100644 --- a/tests/phpMyFAQ/Attachment/FileTest.php +++ b/tests/phpMyFAQ/Attachment/FileTest.php @@ -221,4 +221,23 @@ public function testMkVirtualHashMethod(): void $this->assertNotNull($hash); $this->assertIsString($hash); } + + public function testBuildStoragePath(): void + { + $reflection = new ReflectionClass($this->file); + $properties = [ + 'encrypted' => false, + 'realHash' => 'abcdefghijklmnopqrstuvwxyz123456', + ]; + + foreach ($properties as $prop => $value) { + $property = $reflection->getProperty($prop); + $property->setValue($this->file, $value); + } + + $method = $reflection->getMethod('buildStoragePath'); + $storagePath = $method->invoke($this->file); + + $this->assertEquals('abcde/fghij/klmno/pqrstuvwxyz123456', $storagePath); + } } From 4da5c92d3b1836644700e83a002276d151dd0254 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Mon, 9 Feb 2026 11:45:53 +0100 Subject: [PATCH 8/9] feat: added tenant-isolated storage paths --- .../src/phpMyFAQ/Storage/StorageFactory.php | 13 ++- phpmyfaq/src/services.php | 1 + tests/phpMyFAQ/Storage/StorageFactoryTest.php | 93 ++++++++++++++++--- 3 files changed, 94 insertions(+), 13 deletions(-) diff --git a/phpmyfaq/src/phpMyFAQ/Storage/StorageFactory.php b/phpmyfaq/src/phpMyFAQ/Storage/StorageFactory.php index b75ac77879..0d1e584f4e 100644 --- a/phpmyfaq/src/phpMyFAQ/Storage/StorageFactory.php +++ b/phpmyfaq/src/phpMyFAQ/Storage/StorageFactory.php @@ -21,11 +21,13 @@ use Aws\S3\S3Client; use phpMyFAQ\Configuration; +use phpMyFAQ\Tenant\TenantContext; final readonly class StorageFactory { public function __construct( private Configuration $configuration, + private ?TenantContext $tenantContext = null, ) { } @@ -33,7 +35,7 @@ public function create(): StorageInterface { $type = strtolower((string) ($this->configuration->get('storage.type') ?? 'filesystem')); - return match ($type) { + $storage = match ($type) { 'filesystem' => new FilesystemStorage( $this->resolveFilesystemRoot(), $this->readStringConfig('storage.filesystem.publicBaseUrl'), @@ -41,6 +43,8 @@ public function create(): StorageInterface 's3' => $this->createS3Storage(), default => throw new StorageException('Unsupported storage type: ' . $type), }; + + return new TenantScopedStorage($storage, $this->tenantPrefix()); } private function createS3Storage(): S3Storage @@ -128,4 +132,11 @@ private function readStringConfig(string $key): ?string return trim((string) $value); } + + private function tenantPrefix(): string + { + $tenantId = $this->tenantContext?->getTenantId() ?? 0; + + return sprintf('%d/attachments', $tenantId); + } } diff --git a/phpmyfaq/src/services.php b/phpmyfaq/src/services.php index 742d034b03..45196e9005 100644 --- a/phpmyfaq/src/services.php +++ b/phpmyfaq/src/services.php @@ -227,6 +227,7 @@ $services->set('phpmyfaq.storage.factory', StorageFactory::class)->args([ service('phpmyfaq.configuration'), + service('phpmyfaq.tenant.context'), ]); $services->set('phpmyfaq.storage', StorageInterface::class)->factory([ service('phpmyfaq.storage.factory'), diff --git a/tests/phpMyFAQ/Storage/StorageFactoryTest.php b/tests/phpMyFAQ/Storage/StorageFactoryTest.php index 4b7aadc5a9..b459cfcd00 100644 --- a/tests/phpMyFAQ/Storage/StorageFactoryTest.php +++ b/tests/phpMyFAQ/Storage/StorageFactoryTest.php @@ -3,6 +3,8 @@ namespace phpMyFAQ\Storage; use phpMyFAQ\Configuration; +use phpMyFAQ\Tenant\TenantContext; +use phpMyFAQ\Tenant\TenantQuotas; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; @@ -10,6 +12,9 @@ #[CoversClass(StorageFactory::class)] #[UsesClass(FilesystemStorage::class)] #[UsesClass(S3Storage::class)] +#[UsesClass(TenantScopedStorage::class)] +#[UsesClass(TenantContext::class)] +#[UsesClass(TenantQuotas::class)] class StorageFactoryTest extends TestCase { public function testCreateReturnsFilesystemStorageByDefault(): void @@ -23,10 +28,10 @@ public function testCreateReturnsFilesystemStorageByDefault(): void ['storage.filesystem.publicBaseUrl', null], ]); - $factory = new StorageFactory($configuration); + $factory = new StorageFactory($configuration, $this->createTenantContext(7)); $storage = $factory->create(); - $this->assertInstanceOf(FilesystemStorage::class, $storage); + $this->assertInstanceOf(TenantScopedStorage::class, $storage); } public function testCreateReturnsFilesystemStorageWhenConfigured(): void @@ -40,10 +45,10 @@ public function testCreateReturnsFilesystemStorageWhenConfigured(): void ['storage.filesystem.publicBaseUrl', 'https://cdn.example.com/files'], ]); - $factory = new StorageFactory($configuration); + $factory = new StorageFactory($configuration, $this->createTenantContext(12)); $storage = $factory->create(); - $this->assertInstanceOf(FilesystemStorage::class, $storage); + $this->assertInstanceOf(TenantScopedStorage::class, $storage); } public function testCreateThrowsForUnsupportedStorageType(): void @@ -55,7 +60,7 @@ public function testCreateThrowsForUnsupportedStorageType(): void ['storage.type', 'unsupported'], ]); - $factory = new StorageFactory($configuration); + $factory = new StorageFactory($configuration, $this->createTenantContext(5)); $this->expectException(StorageException::class); $this->expectExceptionMessage('Unsupported storage type: unsupported'); @@ -79,9 +84,9 @@ public function testCreateReturnsS3StorageWhenConfigured(): void ['storage.s3.usePathStyle', null], ]); - $factory = new StorageFactory($configuration); + $factory = new StorageFactory($configuration, $this->createTenantContext(8)); $storage = $factory->create(); - $this->assertInstanceOf(S3Storage::class, $storage); + $this->assertInstanceOf(TenantScopedStorage::class, $storage); } public function testCreateThrowsWhenFilesystemRootIsNotWritable(): void @@ -101,7 +106,7 @@ public function testCreateThrowsWhenFilesystemRootIsNotWritable(): void ['storage.filesystem.publicBaseUrl', null], ]); - $factory = new StorageFactory($configuration); + $factory = new StorageFactory($configuration, $this->createTenantContext(10)); $this->expectException(StorageException::class); $this->expectExceptionMessage('Storage root directory'); @@ -122,10 +127,10 @@ public function testCreateCreatesFilesystemRootIfMissing(): void ['storage.filesystem.publicBaseUrl', null], ]); - $factory = new StorageFactory($configuration); + $factory = new StorageFactory($configuration, $this->createTenantContext(9)); $storage = $factory->create(); - $this->assertInstanceOf(FilesystemStorage::class, $storage); + $this->assertInstanceOf(TenantScopedStorage::class, $storage); $this->assertDirectoryExists($tmpDir); } finally { if (is_dir($tmpDir)) { @@ -151,7 +156,7 @@ public function testCreateThrowsWhenOnlyS3KeyProvided(): void ['storage.s3.usePathStyle', null], ]); - $factory = new StorageFactory($configuration); + $factory = new StorageFactory($configuration, $this->createTenantContext(11)); $this->expectException(StorageException::class); $this->expectExceptionMessage('Both storage.s3.key and storage.s3.secret must be provided together.'); @@ -175,10 +180,74 @@ public function testCreateThrowsWhenOnlyS3SecretProvided(): void ['storage.s3.usePathStyle', null], ]); - $factory = new StorageFactory($configuration); + $factory = new StorageFactory($configuration, $this->createTenantContext(11)); $this->expectException(StorageException::class); $this->expectExceptionMessage('Both storage.s3.key and storage.s3.secret must be provided together.'); $factory->create(); } + + public function testCreatePrefixesFilesystemPathsWithTenantPath(): void + { + $tmpDir = sys_get_temp_dir() . '/phpmyfaq-factory-prefix-' . uniqid('', true); + + try { + $configuration = $this->createStub(Configuration::class); + $configuration + ->method('get') + ->willReturnMap([ + ['storage.type', 'filesystem'], + ['storage.filesystem.root', $tmpDir], + ['storage.filesystem.publicBaseUrl', null], + ]); + + $factory = new StorageFactory($configuration, $this->createTenantContext(42)); + $storage = $factory->create(); + $this->assertTrue($storage->put('my/file.txt', 'content')); + + $this->assertFileExists($tmpDir . '/42/attachments/my/file.txt'); + } finally { + $this->removeDirectory($tmpDir); + } + } + + private function createTenantContext(int $tenantId): TenantContext + { + return new TenantContext( + tenantId: $tenantId, + hostname: 'tenant.example.com', + tablePrefix: '', + configDir: '/tmp', + plan: 'free', + quotas: new TenantQuotas(), + ); + } + + private function removeDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + + $entries = scandir($directory); + if ($entries === false) { + return; + } + + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $path = $directory . '/' . $entry; + if (is_dir($path)) { + $this->removeDirectory($path); + continue; + } + + unlink($path); + } + + rmdir($directory); + } } From ebd85d85d98d811278b79d3f36c40fc23de4709d Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Mon, 9 Feb 2026 11:52:42 +0100 Subject: [PATCH 9/9] fix: added missing TenantScopedStorage class --- .../phpMyFAQ/Storage/TenantScopedStorage.php | 76 ++++++++++++++++ .../Storage/TenantScopedStorageTest.php | 90 +++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 phpmyfaq/src/phpMyFAQ/Storage/TenantScopedStorage.php create mode 100644 tests/phpMyFAQ/Storage/TenantScopedStorageTest.php diff --git a/phpmyfaq/src/phpMyFAQ/Storage/TenantScopedStorage.php b/phpmyfaq/src/phpMyFAQ/Storage/TenantScopedStorage.php new file mode 100644 index 0000000000..a8b2992ad9 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Storage/TenantScopedStorage.php @@ -0,0 +1,76 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-09 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Storage; + +final readonly class TenantScopedStorage implements StorageInterface +{ + public function __construct( + private StorageInterface $storage, + private string $tenantPrefix, + ) { + } + + public function put(string $path, string $contents): bool + { + return $this->storage->put($this->prefixPath($path), $contents); + } + + public function putStream(string $path, mixed $stream): bool + { + return $this->storage->putStream($this->prefixPath($path), $stream); + } + + public function get(string $path): string + { + return $this->storage->get($this->prefixPath($path)); + } + + public function delete(string $path): bool + { + return $this->storage->delete($this->prefixPath($path)); + } + + public function exists(string $path): bool + { + return $this->storage->exists($this->prefixPath($path)); + } + + public function url(string $path): string + { + return $this->storage->url($this->prefixPath($path)); + } + + public function size(string $path): int + { + return $this->storage->size($this->prefixPath($path)); + } + + private function prefixPath(string $path): string + { + $normalizedPath = ltrim(str_replace('\\', '/', trim($path)), '/'); + $prefix = trim($this->tenantPrefix, '/'); + + if ($normalizedPath === '') { + return $prefix; + } + + return $prefix . '/' . $normalizedPath; + } +} diff --git a/tests/phpMyFAQ/Storage/TenantScopedStorageTest.php b/tests/phpMyFAQ/Storage/TenantScopedStorageTest.php new file mode 100644 index 0000000000..95b0b6f999 --- /dev/null +++ b/tests/phpMyFAQ/Storage/TenantScopedStorageTest.php @@ -0,0 +1,90 @@ +assertTrue($storage->put('a.txt', 'A')); + $this->assertTrue($storage->putStream('b.txt', $stream)); + $storage->get('c.txt'); + $storage->delete('d.txt'); + $storage->exists('e.txt'); + $storage->url('f.txt'); + $storage->size('g.txt'); + + $this->assertSame( + [ + ['put', '15/attachments/a.txt'], + ['putStream', '15/attachments/b.txt'], + ['get', '15/attachments/c.txt'], + ['delete', '15/attachments/d.txt'], + ['exists', '15/attachments/e.txt'], + ['url', '15/attachments/f.txt'], + ['size', '15/attachments/g.txt'], + ], + $inner->calls, + ); + + fclose($stream); + } +} + +final class RecordingStorage implements StorageInterface +{ + public array $calls = []; + + public function put(string $path, string $contents): bool + { + $this->calls[] = ['put', $path]; + return true; + } + + public function putStream(string $path, mixed $stream): bool + { + $this->calls[] = ['putStream', $path]; + return true; + } + + public function get(string $path): string + { + $this->calls[] = ['get', $path]; + return 'x'; + } + + public function delete(string $path): bool + { + $this->calls[] = ['delete', $path]; + return true; + } + + public function exists(string $path): bool + { + $this->calls[] = ['exists', $path]; + return true; + } + + public function url(string $path): string + { + $this->calls[] = ['url', $path]; + return 'https://example.com/' . $path; + } + + public function size(string $path): int + { + $this->calls[] = ['size', $path]; + return 1; + } +}