Skip to content

Conversation

@thorsten
Copy link
Owner

@thorsten thorsten commented Feb 8, 2026

Summary by CodeRabbit

  • New Features

    • Added a storage abstraction with filesystem and S3 backends, a factory to create them, and a dedicated storage exception.
    • Storage is now provided as an injectable service.
  • Bug Fixes

    • Improved file-reading and error handling when computing file hashes and reading attachments.
  • Tests

    • Added comprehensive tests for the storage interface, filesystem and S3 adapters, and the factory.
  • Chores

    • Added AWS SDK as a runtime dependency.

@coderabbitai
Copy link

coderabbitai bot commented Feb 8, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a storage abstraction (StorageInterface, StorageException), filesystem and S3 implementations, a StorageFactory with DI wiring, AWS SDK dependency, PHPUnit tests for interface/implementations/factory, and hardens System::createHashes and Attachment\File::get() file handling.

Changes

Cohort / File(s) Summary
Storage core
phpmyfaq/src/phpMyFAQ/Storage/StorageException.php, phpmyfaq/src/phpMyFAQ/Storage/StorageInterface.php
Introduce StorageException and StorageInterface with methods: put, putStream, get, delete, exists, url, size.
Filesystem implementation
phpmyfaq/src/phpMyFAQ/Storage/FilesystemStorage.php
Add FilesystemStorage implementing StorageInterface: path normalization, directory creation, stream support, URL handling, and StorageException-based error handling.
S3 implementation
phpmyfaq/src/phpMyFAQ/Storage/S3Storage.php
Add S3Storage implementing StorageInterface: key validation/sanitization, AWS client call wrapper (run()), consistent StorageException translation, optional prefix and publicBaseUrl support.
Factory & DI wiring
phpmyfaq/src/phpMyFAQ/Storage/StorageFactory.php, phpmyfaq/src/services.php
Add StorageFactory to construct filesystem or S3 backends from config; register phpmyfaq.storage.factory and phpmyfaq.storage services.
Composer
composer.json
Add runtime dependency aws/aws-sdk-php (^3.0) for S3 support.
Tests
tests/phpMyFAQ/Storage/StorageInterfaceTest.php, tests/phpMyFAQ/Storage/FilesystemStorageTest.php, tests/phpMyFAQ/Storage/S3StorageTest.php, tests/phpMyFAQ/Storage/StorageFactoryTest.php
Add PHPUnit tests for interface signatures, FilesystemStorage behavior, S3Storage behavior (fake clients and ArrayAccess results), and StorageFactory selection and error cases.
Misc
phpmyfaq/src/phpMyFAQ/System.php, phpmyfaq/src/phpMyFAQ/Attachment/File.php
Harden createHashes to skip non-files/unreadable files and validate reads before hashing; implement Attachment\File::get() to read file contents in chunks.

Sequence Diagram(s)

sequenceDiagram
  participant App as Application
  participant Factory as phpMyFAQ\Storage\StorageFactory
  participant Config as Configuration
  participant FS as FilesystemStorage
  participant S3 as S3Storage
  participant S3Client as AWS S3 Client

  rect rgba(200,230,255,0.5)
    App->>Factory: create()
    Factory->>Config: read storage.type (default "filesystem")
    alt type == "filesystem"
      Factory->>FS: new FilesystemStorage(rootPath, publicBaseUrl)
      Factory-->>App: return FilesystemStorage (StorageInterface)
    else type == "s3"
      Factory->>Config: read S3 settings (bucket, region, endpoint, creds)
      Factory->>S3Client: instantiate/configure client
      S3Client-->>Factory: client instance
      Factory->>S3: new S3Storage(client, bucket, prefix, publicBaseUrl)
      Factory-->>App: return S3Storage (StorageInterface)
    end
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐇 I hopped through files and clouds today,

New storerooms nested in tidy array,
Disk crumbs and S3 stars align,
Exceptions keep my burrow fine,
Tests hum softly — carrot time. 🥕

🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'feat: added storage interface' is overly broad and does not capture the primary scope of this changeset, which introduces a complete storage abstraction layer including interface, implementations (filesystem and S3), factory, dependency injection wiring, and comprehensive tests. Consider a more descriptive title like 'feat: introduce storage abstraction with filesystem and S3 implementations' or 'feat: add pluggable storage interface with FilesystemStorage and S3Storage' to better reflect the scope of changes.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/storage-abstraction

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Fix all issues with AI agents
In `@phpmyfaq/src/phpMyFAQ/Storage/FilesystemStorage.php`:
- Around line 136-144: In normalizePath, don't reject any path that merely
contains the substring '..' (which disallows valid filenames like
"file..backup.txt"); instead, after normalizing separators (the existing
str_replace('\\','/')), split the normalized path into segments (e.g.,
explode('/') or preg_split) and throw StorageException only if any segment ===
'..' or any segment === '' (if you want to also guard against empty segments),
then continue to return the path with DIRECTORY_SEPARATOR; update the check that
currently uses str_contains($normalizedPath, '..') to this segment-based
validation within normalizePath.
- Around line 109-117: The url() method can return backslashes on Windows
because normalizePath() converts slashes to DIRECTORY_SEPARATOR; update url() to
use a forward-slash-normalized path when building URLs (e.g., call a
forward-slash-safe variant of normalizePath() or replace backslashes with '/' on
the normalizedPath before concatenating with publicBaseUrl), and ensure
buildFullPath() similarly returns forward-slash paths for URLs; reference
normalizePath(), url(), buildFullPath(), and publicBaseUrl when making this
change.

In `@phpmyfaq/src/phpMyFAQ/Storage/S3Storage.php`:
- Around line 134-146: The current path-traversal guard in S3Storage::buildKey
(and the similar FilesystemStorage::normalizePath) uses
str_contains($normalizedPath, '..') which wrongly rejects legitimate filenames
containing ".."; replace it with a precise check that detects path-segment
traversal only (e.g., split the normalized path on '/' and ensure no segment is
exactly '..', or check for the sequence '../' or '/..' or a path that starts
with '..') and throw StorageException only when such a traversal segment is
found; update both buildKey and normalizePath to use this segment-based check so
filenames like "file..backup.txt" remain allowed while "../secret" is blocked.
- Around line 62-75: The is_array() guard in S3Storage::get and S3Storage::size
incorrectly fails for Aws\Result objects (which implement ArrayAccess); remove
the is_array() checks and only verify the expected keys on the result (e.g. in
get() check array_key_exists('Body', $result) or isset($result['Body']) and in
size() check array_key_exists('ContentLength', $result) or
isset($result['ContentLength'])), leaving the run(...) call, buildKey(...) usage
and StorageException throw intact so valid Aws\Result responses from
client->getObject() and client->headObject() are accepted.

In `@phpmyfaq/src/phpMyFAQ/Storage/StorageFactory.php`:
- Around line 67-74: The current code in StorageFactory that reads
storage.s3.key and storage.s3.secret using readStringConfig silently omits
credentials if only one value is provided; update the logic in StorageFactory
(where $key and $secret are read and $s3Config is built) to validate that either
both $key and $secret are non-null or both are null, and if exactly one is
provided throw a StorageException with a clear message (e.g., "Both
storage.s3.key and storage.s3.secret must be provided together"); leave the
existing credentials assignment to $s3Config unchanged when both are present.

In `@tests/phpMyFAQ/Storage/FilesystemStorageTest.php`:
- Around line 26-37: The test
FilesystemStorageTest::testPutGetExistsSizeDeleteAndUrl fails on Windows because
FilesystemStorage::url() uses normalizePath that yields backslashes; update
FilesystemStorage::url() to produce a web-friendly path by normalizing the
stored path to use forward slashes (replace backslashes with '/'), ensure proper
trimming/adding of slashes between the base URL and the normalized path, and
return the combined URL with forward slashes so the test expectation in
testPutGetExistsSizeDeleteAndUrl passes cross-platform.
🧹 Nitpick comments (4)
composer.json (1)

31-31: Consider making aws/aws-sdk-php a suggested (optional) dependency instead of required.

The AWS SDK for PHP is a very large package (~100 MB+). Requiring it unconditionally means every phpMyFAQ installation pulls it in, even if they only use filesystem storage. The StorageFactory::createS3Storage() already has a class_exists(S3Client::class) guard, so the code is already structured to handle its absence.

Move aws/aws-sdk-php from require to suggest and throw StorageException in createS3Storage() when the SDK is missing (which it already does).

Additionally, ^3.0 is an extremely broad constraint — it spans releases from 2015 to the present. Consider pinning to a more recent minimum (e.g., ^3.300) for predictability.

phpmyfaq/src/phpMyFAQ/Storage/S3Storage.php (1)

26-32: Consider type-hinting $client as S3Client instead of object.

Using object loses static analysis benefits and allows injection of any object, which would fail at runtime on the first S3 call. If testability is the concern, you could use an interface or keep object but document the expected contract.

This is a minor observation — the current approach works given that StorageFactory creates the real S3Client.

phpmyfaq/src/phpMyFAQ/Storage/StorageFactory.php (1)

81-84: The @var object annotation suppresses the actual S3Client type.

This annotation exists to satisfy S3Storage's object type hint. If S3Storage::$client is updated to accept S3Client (as suggested in the S3Storage review), this annotation can be removed and the types will flow naturally.

phpmyfaq/src/phpMyFAQ/Storage/FilesystemStorage.php (1)

89-102: Minor TOCTOU in delete(): file could disappear between file_exists and unlink.

If another process deletes the file between the file_exists check (line 92) and the unlink call (line 96), unlink will fail and throw StorageException. For a more resilient approach, attempt unlink directly and catch the warning using @unlink or check the result.

This is a low-probability edge case with a safe fallback (the exception), so it's not urgent.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@phpmyfaq/src/phpMyFAQ/Storage/StorageFactory.php`:
- Around line 91-103: resolveFilesystemRoot and the FilesystemStorage
constructor currently only trim/return a path, deferring failures until
put()/putStream(); validate the filesystem root early by checking the resolved
root (from resolveFilesystemRoot or inside StorageFactory.create) exists and is
writable (or attempt to create it with mkdir(..., recursive:true) and verify
success), and if validation fails throw a clear exception (or propagate a
Storage-related exception) so misconfiguration surfaces at initialization rather
than runtime; update FilesystemStorage::__construct or StorageFactory::create to
perform this validation on the root returned by resolveFilesystemRoot and
include the directory path in the error message.

In `@tests/phpMyFAQ/Storage/FilesystemStorageTest.php`:
- Around line 39-50: The test testPutStreamWritesContents currently assumes
fopen returns a resource and may leak the stream if an assertion fails; update
it to check fopen succeeded (e.g., assertIsResource or if ($stream === false)
$this->fail(...)) and wrap use of the stream in a try/finally so fclose($stream)
is always called. Locate the test method testPutStreamWritesContents and adjust
the logic around fopen('php://memory', 'rb+') / fwrite / rewind so you validate
the $stream resource before using it and ensure fclose runs in a finally block
after the storage assertions (referencing FilesystemStorage and the
putStream/get methods).
🧹 Nitpick comments (6)
tests/phpMyFAQ/Storage/FilesystemStorageTest.php (1)

123-149: removeDirectory helper is correct and defensive.

Handles scandir failure, skips ./.., and recurses safely. Consider using PHP's RecursiveDirectoryIterator + RecursiveIteratorIterator for brevity, but this is fine as-is.

phpmyfaq/src/phpMyFAQ/Storage/S3Storage.php (2)

24-32: Loose object type hint for $client — consider a narrow interface.

The object type on $client allows any object, including ones that don't implement the required S3 methods (putObject, getObject, etc.). Errors will only surface at runtime as "method not found" calls. A lightweight interface (or at least a union type with S3Client) would catch misconfigurations earlier.

That said, I understand this simplifies testing with fakes, so this is optional.


125-132: run() catches all Throwable — consider preserving the exception code.

The wrapper discards the original exception's code. While not critical, forwarding $throwable->getCode() can be useful for debugging AWS-specific error codes.

Optional improvement
-            throw new StorageException($throwable->getMessage(), previous: $throwable);
+            throw new StorageException($throwable->getMessage(), (int) $throwable->getCode(), $throwable);
tests/phpMyFAQ/Storage/S3StorageTest.php (2)

11-27: Good comprehensive integration test, but consider testing exists() after delete().

The test calls delete() at the end but doesn't assert exists() returns false afterward. Adding one line would verify the full lifecycle.

Suggested addition
         $this->assertTrue($storage->delete('docs/readme.txt'));
+        $this->assertFalse($storage->exists('docs/readme.txt'));

100-103: getObject returns empty string for missing keys — doesn't simulate S3 errors.

The real S3 client throws NoSuchKeyException when the key doesn't exist. The fake returns ['Body' => ''], which means get() on a missing object silently returns an empty string instead of throwing. Consider adding a test for the error path with a throwing fake, or adjusting the fake to throw for missing keys.

phpmyfaq/src/phpMyFAQ/Storage/StorageFactory.php (1)

57-59: Pin the S3 API model version to avoid breaking changes during SDK updates.

The 'version' => 'latest' configuration uses AWS's latest service API model, which can change when you update the AWS SDK for PHP, potentially introducing breaking changes in production. AWS explicitly recommends pinning the API version for production deployments. For S3, use 'version' => '2006-03-01':

Suggested change
$s3Config = [
    'version' => '2006-03-01',
    'region' => $region,
];

@thorsten thorsten force-pushed the feat/storage-abstraction branch from 81df663 to c8e8399 Compare February 9, 2026 08:52
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@phpmyfaq/src/phpMyFAQ/Attachment/File.php`:
- Around line 158-159: The get() method in class File is an empty stub that
declares a string return type and will cause a TypeError under strict_types;
replace the empty body with an explicit behavior (e.g., throw a descriptive
exception such as BadMethodCallException or return an empty string) so the
method always returns a string or fails explicitly, and update the method brace
placement to follow PER Coding Style 3.0 (opening brace on the next line and
method body non-empty) — locate the public function get(): string declaration in
File.php and implement one of these two fixes consistently.
🧹 Nitpick comments (4)
phpmyfaq/src/phpMyFAQ/Storage/StorageFactory.php (2)

85-86: Misleading @var object annotation on a typed S3Client instantiation.

The S3Client constructor returns an S3Client instance, not a generic object. This annotation was likely added to satisfy the S3Storage constructor which accepts object, but it's misleading at the call site.


57-59: Consider pinning the S3 SDK API version instead of using 'latest'.

Using 'version' => 'latest' means the SDK will use whatever API version it ships with, which could change behavior on composer update. Pinning to a specific API version (e.g., '2006-03-01') improves reproducibility.

phpmyfaq/src/phpMyFAQ/Storage/S3Storage.php (1)

26-32: Consider using a narrower type or an interface for $client instead of object.

Typing the S3 client as object sacrifices all static analysis and IDE support. An interface or the concrete S3Client type (with mocking via createMock in tests) would be safer. If the goal is testability with fakes, a thin adapter interface would preserve type safety.

tests/phpMyFAQ/Storage/S3StorageTest.php (1)

29-41: Stream is not closed if an assertion fails before fclose.

Same pattern as the past review on FilesystemStorageTest — wrap in try/finally to ensure the stream resource is always cleaned up.

Proposed fix
     public function testPutStreamWritesObject(): void
     {
         $client = new FakeS3Client();
         $storage = new S3Storage($client, 'pmf-bucket', 'tenant/attachments');
         $stream = fopen('php://memory', 'rb+');
+        $this->assertIsResource($stream);
-        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);
+        try {
+            fwrite($stream, 'stream-data');
+            rewind($stream);
+            $this->assertTrue($storage->putStream('stream.txt', $stream));
+            $this->assertSame('stream-data', (string) $client->objects['tenant/attachments/stream.txt']);
+        } finally {
+            fclose($stream);
+        }
     }

@thorsten thorsten merged commit eb6c6ce into main Feb 9, 2026
10 checks passed
@thorsten thorsten deleted the feat/storage-abstraction branch February 9, 2026 19:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant