diff --git a/.phive/phars.xml b/.phive/phars.xml
index 17093fd..7ef9317 100644
--- a/.phive/phars.xml
+++ b/.phive/phars.xml
@@ -1,5 +1,5 @@
-
-
+
+
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ab866e3..2a7732d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,13 @@ Changelog
Releases for CakePHP 5.0
------------------------
+* 2.1.0
+ * Add pluggable storage drivers via `StorageAdapterInterface` and `AdapterFactory`
+ * Add Google Cloud Storage (`gcs`) adapter (requires `google/cloud-storage`)
+ * Add Cloudflare R2 (`r2`) adapter
+ * New config key `Uppy.driver` (env `UPPY_DRIVER`) selects the active backend
+ * Rename `Uppy.Props.deleteFileS3` to `deleteFileStorage`; the old key is still honored for backwards compatibility
+
* 2.0.3
* Update documentation
diff --git a/Docs/Documentation/Configure.md b/Docs/Documentation/Configure.md
index 1c4e1b2..ee8e8ba 100644
--- a/Docs/Documentation/Configure.md
+++ b/Docs/Documentation/Configure.md
@@ -6,7 +6,7 @@ To configure the plugin first copy the base configuration to your project
$> cp vendor/cakedc/cakephp-uppy/config/uppy.php config/uppy.php
```
-Then run migration to create table *uppy_files* where save data for files uploaded
+Then run migration to create table *uppy_files* where data for uploaded files is stored
```
$> bin/cake migrations migrate -p CakeDC/Uppy
@@ -14,80 +14,135 @@ $> bin/cake migrations migrate -p CakeDC/Uppy
Note: you can change the name of this table in the configuration file *config/uppy.php*
+## Storage drivers
+
+The plugin supports three storage backends, selected via `Uppy.driver`:
+
+| Driver | Backend | Required env vars | Extra packages |
+|---------|--------------------------|----------------------------------------------------------------|--------------------------------------|
+| `s3` | Amazon S3 | `S3_REGION`, `S3_KEY`, `S3_SECRET`, `S3_BUCKET` | none (uses `aws/aws-sdk-php`) |
+| `gcs` | Google Cloud Storage | `GCS_PROJECT_ID`, `GCS_KEY_FILE_PATH`, `GCS_BUCKET` | `composer require google/cloud-storage:^1.50` |
+| `r2` | Cloudflare R2 | `R2_ACCOUNT_ID`, `R2_KEY`, `R2_SECRET`, `R2_BUCKET`, `R2_PUBLIC_DOMAIN`, `R2_ENDPOINT` | none (R2 is S3-compatible) |
+
+Set the driver via the `UPPY_DRIVER` env var (`s3` | `gcs` | `r2`), or override `Uppy.driver` directly in `config/uppy.php`. Default is `s3`.
+
+All drivers implement `CakeDC\Uppy\Storage\StorageAdapterInterface` and are instantiated by `CakeDC\Uppy\Storage\AdapterFactory::create()`. To add a custom driver, implement the interface and register a new case in the factory.
+
## Configuration file *config/uppy.php*
-By default, when debug is set to true the plugin will add a number of routes to your application `/uppy/files` in order to
-upload and view the uploaded files. You should protect these endpoints in case your application requires
-authentication to upload or manage files. Failing to do so would allow an authenticated user to *upload* files
-to your application.
+By default, when debug is set to true the plugin will add a number of routes to your application under `/uppy/files` for uploading and viewing files. You should protect these endpoints if your application requires authentication to upload or manage files. Failing to do so would allow any visitor to *upload* files to your application.
-### Default uppy config file:
+### Default uppy config file
```php
[
+ // Storage driver: 's3' | 'gcs' | 'r2'
+ 'driver' => env('UPPY_DRIVER', 's3'),
+
'Props' => [
'usersModel' => 'Users',
- 'deleteFileS3' => true,
+ 'deleteFileStorage' => true,
'tableFiles' => 'uppy_files',
],
+
'AcceptedContentTypes' => [
'application/pdf',
'image/png',
+ 'application/msword',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'application/vnd.ms-excel',
+ 'application/vnd.ms-powerpoint',
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
],
+
'AcceptedExtensions' => [
- 'pdf',
- 'png',
+ 'pdf', 'png', 'doc', 'docx', 'xls', 'ppt', 'pptx',
],
- /*
- * S3 configuration to manage files
- */
+
+ // Amazon S3 — used when driver = 's3'
'S3' => [
- 'contants' => [
+ 'constants' => [
'lifeTimeGetObject' => '+20 minutes',
'lifeTimePutObject' => '+5 minutes',
],
'config' => [
'version' => 'latest',
- 'region' => filter_var(env('S3_REGION', null)),
- 'endpoint' => filter_var(env('S3_END_POINT', null)),
+ 'region' => env('S3_REGION', null),
'credentials' => [
- 'key' => filter_var(env('S3_KEY', null)),
- 'secret' => filter_var(env('S3_SECRET', null)),
+ 'key' => env('S3_KEY', null),
+ 'secret' => env('S3_SECRET', null),
],
],
- 'bucket' => filter_var(env('S3_BUCKET')),
+ 'bucket' => env('S3_BUCKET', null),
+ ],
+
+ // Google Cloud Storage — used when driver = 'gcs'
+ // Requires: composer require google/cloud-storage ^1.50
+ 'GCS' => [
+ 'projectId' => env('GCS_PROJECT_ID', null),
+ 'keyFilePath' => env('GCS_KEY_FILE_PATH', null),
+ 'bucket' => env('GCS_BUCKET', null),
+ 'lifeTimePutObject' => '+5 minutes',
+ 'lifeTimeGetObject' => '+20 minutes',
+ ],
+
+ // Cloudflare R2 — used when driver = 'r2'
+ // R2 is S3-compatible; uses the AWS SDK with a custom endpoint.
+ 'R2' => [
+ 'bucket' => env('R2_BUCKET', null),
+ 'publicDomain' => env('R2_PUBLIC_DOMAIN', null),
+ 'constants' => [
+ 'lifeTimePutObject' => '+5 minutes',
+ 'lifeTimeGetObject' => '+20 minutes',
+ ],
+ 'config' => [
+ 'version' => env('R2_VERSION', 'latest'),
+ 'region' => env('R2_REGION', 'auto'),
+ 'endpoint' => env('R2_ENDPOINT', null),
+ 'credentials' => [
+ 'key' => env('R2_KEY', null),
+ 'secret' => env('R2_SECRET', null),
+ ],
+ 'use_path_style_endpoint' => true,
+ ],
],
],
];
```
-- usersModel = is the alias name used in you app
-- deleteFileS3 = if the record of the file in the database is deleted and it's marked true, the deletion is launched in the S3 deposit
-- tableFiles = name of table used to store file data, default is `uppy_files`
-- AcceptedContentTypes = list of content-type stored in S3 and saved in database
-- AcceptedExtensions = list of file extensions stored in S3 and saved in database
-- lifeTimeGetObject = life time generated link to access file in S3
-- lifeTimePutObject = life time generated link to post file in S3
-- region = configured region S3
-- endpoint = endpoint server to PUT/POST/GET S3 files
-- key = S3 account key
-- secret = S3 account secret
-- bucket = bucket name
-
-Enpoints
--------
-
-- /uppy/files/sign = sign with credentials and return signed url to upload file in S3 directly in front
-- /uppy/files/save = save register just uploaded in database with correct S3 path
-- /uppy/files/delete = delete register in database, if Uppy.Props.deleteFileS3 is true remove from S3
-- /uppy/files/view = sign with credentials and return signed url to access file in S3 directly in front
-
-Sample
--------
-
-- /uppy/files = list of files in database and link to view/delete in S3
-- /uppy/files/add = example uppy upload file to configured S3 and save data relationed in database
-- /uppy/files/drag = example uppy drag and upload multiple file, using Dashboard to configured S3 and save data relationed in database
+### Props
+
+- `usersModel` — alias name used in your app for the users association
+- `deleteFileStorage` — when `true`, deleting a file record also removes the object from the active storage backend. (Renamed from `deleteFileS3`; the old key still works as a fallback for backwards compatibility.)
+- `tableFiles` — name of the table used to store file metadata, default `uppy_files`
+
+### Accepted types
+
+- `AcceptedContentTypes` — MIME types accepted for upload and persisted in the database
+- `AcceptedExtensions` — file extensions accepted for upload and persisted in the database
+
+### Per-driver options
+
+- **S3 / R2** — `constants.lifeTimePutObject` / `constants.lifeTimeGetObject` control the signed URL lifetime for upload and read URLs respectively. `config` is passed directly to `Aws\S3\S3Client`.
+- **GCS** — `lifeTimePutObject` / `lifeTimeGetObject` at the top level of the `GCS` block; `keyFilePath` is the path to a service-account JSON key file.
+- **R2** — `publicDomain` is your custom domain or `pub-.r2.dev` URL; `endpoint` is the R2 S3-compatible endpoint (`https://.r2.cloudflarestorage.com`).
+
+In test environments, set `Uppy.S3.config.connection` or `Uppy.R2.config.connection` to `'dummy'` to bypass real network calls.
+
+## Endpoints
+
+- `/uppy/files/sign` — returns a signed URL for uploading a file directly from the browser to the configured storage
+- `/uppy/files/save` — persists the database record for a file just uploaded, with the correct storage path
+- `/uppy/files/delete` — deletes the database record and, when `Uppy.Props.deleteFileStorage` is `true`, also removes the object from storage
+- `/uppy/files/view` — returns a signed URL for reading the file from the configured storage
+
+## Sample pages (debug only)
+
+These routes are only registered when `debug` is `true`:
+
+- `/uppy/files` — list of files in the database with view/delete links
+- `/uppy/files/add` — example single-file upload to the configured storage
+- `/uppy/files/drag` — example multi-file drag-and-drop upload using Uppy Dashboard
diff --git a/Docs/Home.md b/Docs/Home.md
index d4ae13a..fcd03d7 100644
--- a/Docs/Home.md
+++ b/Docs/Home.md
@@ -1,6 +1,6 @@
Home
====
-The **Uppy** Plugin integrate https://github.com/transloadit/uppy to upload files directly to a configured S3 usign a signature, and in the callback response save data related to files uploaded in database.
+The **Uppy** Plugin integrates https://github.com/transloadit/uppy to upload files directly from the browser to a configured cloud storage backend using a presigned URL, and on callback persists the related file metadata in the database. Supported backends: Amazon S3, Google Cloud Storage, and Cloudflare R2.
The plugin is thought as a base to extend and use your app specific controllers and views from.
diff --git a/README.md b/README.md
index 1d4e84f..c73b84e 100644
--- a/README.md
+++ b/README.md
@@ -3,8 +3,9 @@ CakeDC Uppy Plugin
The **Uppy** plugin covers the following features:
-* Upload directly files to configured S3 from frontside using a signature
-* Save on configured table path to uploaded files on S3, mime_type, date, metadata and relation to user and specified related model in database indicated
+* Upload files directly from the browser to a configured cloud storage backend using a presigned URL
+* Pluggable storage drivers: **Amazon S3**, **Google Cloud Storage**, and **Cloudflare R2** (selectable via `Uppy.driver`)
+* Persist file path, mime type, date, metadata and the relation to the user (or other related model) in the `uppy_files` table
Requirements
------------
diff --git a/composer.json b/composer.json
index 0bd0144..9832fb7 100644
--- a/composer.json
+++ b/composer.json
@@ -20,7 +20,12 @@
"cakephp/cakephp-codesniffer": "^5.0",
"phpunit/phpunit": "^10.1.0",
"cakephp/migrations": "^4.0.0",
- "markstory/asset_compress": "^5.0.0"
+ "markstory/asset_compress": "^5.0.0",
+ "google/cloud-storage": "^1.50",
+ "phpstan/phpstan": "^1.10"
+ },
+ "suggest": {
+ "google/cloud-storage": "Required to use the GCS storage adapter (^1.50)"
},
"autoload": {
"psr-4": {
@@ -40,15 +45,15 @@
],
"cs-check": "phpcs --colors --parallel=16 -p src/ tests/",
"cs-fix": "phpcbf --colors --parallel=16 -p src/ tests/",
- "phpstan": "tools/phpstan analyse",
+ "phpstan": "vendor/bin/phpstan analyse",
"psalm": "tools/psalm --show-info=false",
"stan": [
"@phpstan",
"@psalm"
],
- "phpstan-tests": "tools/phpstan analyze -c tests/phpstan.neon",
- "phpstan-baseline": "tools/phpstan --generate-baseline",
- "psalm-baseline": "tools/psalm --set-baseline=psalm-baseline.xml",
+ "phpstan-tests": "vendor/bin/phpstan analyze -c tests/phpstan.neon",
+ "phpstan-baseline": "vendor/bin/phpstan --generate-baseline",
+ "psalm-baseline": "tools/psalm --set-baseline=psalm-baseline.xml",
"stan-setup": "phive install",
"test": "phpunit"
},
diff --git a/config/aliases.php b/config/aliases.php
index 3b3e913..3cf0946 100644
--- a/config/aliases.php
+++ b/config/aliases.php
@@ -13,5 +13,9 @@
use Cake\Controller\Controller;
use Cake\View\View;
+if (!defined('CONFIG')) {
+ define('CONFIG', dirname(__DIR__) . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR);
+}
+
class_alias(Controller::class, 'App\Controller\AppController');
class_alias(View::class, '\App\View\AppView');
diff --git a/config/uppy.php b/config/uppy.php
index c8356ab..7d935b8 100644
--- a/config/uppy.php
+++ b/config/uppy.php
@@ -9,17 +9,34 @@
*
* @copyright Copyright 2023, Cake Development Corporation (https://www.cakedc.com)
* @license MIT License (http://www.opensource.org/licenses/mit-license.php)
+ *
+ * Uppy plugin configuration template.
+ * Copy this file to your application's config/ directory and customise it.
+ *
+ * Required env vars per driver:
+ * s3: S3_REGION, S3_KEY, S3_SECRET, S3_BUCKET
+ * gcs: GCS_PROJECT_ID, GCS_KEY_FILE_PATH, GCS_BUCKET
+ * r2: R2_ACCOUNT_ID, R2_KEY, R2_SECRET, R2_BUCKET, R2_PUBLIC_DOMAIN
*/
return [
'Uppy' => [
+ /*
+ * Storage driver: 's3' | 'gcs' | 'r2'
+ * Set UPPY_DRIVER in your .env file or override here.
+ */
+ 'driver' => env('UPPY_DRIVER', 's3'),
+
'Props' => [
- // the Table Alias to identify the user who uploaded the file
- 'usersAliasModel' => 'Users',
- // the Table className to identify the user who uploaded the file
'usersModel' => 'Users',
- 'deleteFileS3' => true,
+ /*
+ * When true and a file record is deleted, the object is also
+ * removed from storage. Renamed from deleteFileS3 to apply to
+ * all drivers. Falls back to deleteFileS3 for backwards compat.
+ */
+ 'deleteFileStorage' => true,
'tableFiles' => 'uppy_files',
],
+
'AcceptedContentTypes' => [
'application/pdf',
'image/png',
@@ -29,6 +46,7 @@
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
],
+
'AcceptedExtensions' => [
'pdf',
'png',
@@ -38,25 +56,62 @@
'ppt',
'pptx',
],
+
/*
- * S3 configuration to manage files
- */
+ * Amazon S3 — used when driver = 's3'
+ * Set connection = 'dummy' in test environments.
+ */
'S3' => [
- 'contants' => [
+ 'constants' => [
'lifeTimeGetObject' => '+20 minutes',
'lifeTimePutObject' => '+5 minutes',
],
'config' => [
'version' => 'latest',
- 'connection' => 'real', //dummy
- 'region' => filter_var(env('S3_REGION', null)),
- 'endpoint' => filter_var(env('S3_END_POINT', null)),
+ 'region' => env('S3_REGION', null),
+ 'credentials' => [
+ 'key' => env('S3_KEY', null),
+ 'secret' => env('S3_SECRET', null),
+ ],
+ ],
+ 'bucket' => env('S3_BUCKET', null),
+ ],
+
+ /*
+ * Google Cloud Storage — used when driver = 'gcs'
+ * Requires: composer require google/cloud-storage ^1.50
+ */
+ 'GCS' => [
+ 'projectId' => env('GCS_PROJECT_ID', null),
+ 'keyFilePath' => env('GCS_KEY_FILE_PATH', null),
+ 'bucket' => env('GCS_BUCKET', null),
+ 'lifeTimePutObject' => '+5 minutes',
+ 'lifeTimeGetObject' => '+20 minutes',
+ ],
+
+ /*
+ * Cloudflare R2 — used when driver = 'r2'
+ * R2 is S3-compatible; uses AWS SDK with a custom endpoint.
+ * publicDomain: your custom domain or pub-.r2.dev URL.
+ * Set connection = 'dummy' in test environments.
+ */
+ 'R2' => [
+ 'bucket' => env('R2_BUCKET', null),
+ 'publicDomain' => env('R2_PUBLIC_DOMAIN', null),
+ 'constants' => [
+ 'lifeTimePutObject' => '+5 minutes',
+ 'lifeTimeGetObject' => '+20 minutes',
+ ],
+ 'config' => [
+ 'version' => env('R2_VERSION', 'latest'),
+ 'region' => env('R2_REGION', 'auto'),
+ 'endpoint' => env('R2_ENDPOINT', null),
'credentials' => [
- 'key' => filter_var(env('S3_KEY', null)),
- 'secret' => filter_var(env('S3_SECRET', null)),
+ 'key' => env('R2_KEY', null),
+ 'secret' => env('R2_SECRET', null),
],
+ 'use_path_style_endpoint' => true,
],
- 'bucket' => filter_var(env('S3_BUCKET')),
],
],
];
diff --git a/phive.xml b/phive.xml
new file mode 100644
index 0000000..7ef9317
--- /dev/null
+++ b/phive.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/phpcs.xml b/phpcs.xml
index 015430f..b29774e 100644
--- a/phpcs.xml
+++ b/phpcs.xml
@@ -1,6 +1,8 @@
-
+
+ src
+ tests
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
index 364905f..0fd0e7b 100644
--- a/phpstan-baseline.neon
+++ b/phpstan-baseline.neon
@@ -1,2 +1,10 @@
parameters:
ignoreErrors:
+ -
+ message: '#^Negated boolean expression is always false\.$#'
+ path: src/Storage/S3Adapter.php
+ count: 1
+ -
+ message: '#^Negated boolean expression is always false\.$#'
+ path: src/Storage/R2Adapter.php
+ count: 1
diff --git a/phpstan.neon b/phpstan.neon
index 0411426..dc64bec 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -2,8 +2,11 @@ includes:
- phpstan-baseline.neon
parameters:
level: 8
- checkMissingIterableValueType: false
- checkGenericClassInNonGenericObjectType: false
+ ignoreErrors:
+ -
+ identifier: missingType.iterableValue
+ -
+ identifier: missingType.generics
bootstrapFiles:
- config/aliases.php
paths:
diff --git a/psalm.xml b/psalm.xml
index b525688..6a8bebf 100644
--- a/psalm.xml
+++ b/psalm.xml
@@ -30,5 +30,9 @@
+
+
+
+
diff --git a/src/Controller/FilesController.php b/src/Controller/FilesController.php
index 4c3a8a7..189e7d9 100644
--- a/src/Controller/FilesController.php
+++ b/src/Controller/FilesController.php
@@ -19,7 +19,10 @@
use Cake\ORM\Exception\MissingTableClassException;
use Cake\Utility\Inflector;
use Cake\Utility\Text;
-use CakeDC\Uppy\Util\S3Trait;
+use CakeDC\Uppy\Storage\AdapterFactory;
+use CakeDC\Uppy\Storage\StorageAdapterInterface;
+use UnexpectedValueException;
+use function Cake\Core\h;
use function Cake\I18n\__;
/**
@@ -29,7 +32,7 @@
*/
class FilesController extends AppController
{
- use S3Trait;
+ private StorageAdapterInterface $storageAdapter;
/**
* Initialize method
@@ -41,6 +44,7 @@ class FilesController extends AppController
public function initialize(): void
{
parent::initialize();
+ $this->storageAdapter = AdapterFactory::create();
if (!$this->components()->has('FormProtection')) {
$this->loadComponent('FormProtection');
}
@@ -65,7 +69,7 @@ public function view(string|int|null $id = null): ?Response
/** @var \CakeDC\Uppy\Model\Entity\File $file */
$file = $this->Files->get($id);
- $presignedUrl = $this->presignedUrl($file->path, $file->filename);
+ $presignedUrl = $this->storageAdapter->presignedUrl($file->path ?? '');
return $this->redirect($presignedUrl);
}
@@ -87,8 +91,8 @@ public function save(): void
$files = [];
$result = [];
foreach ($items as $item) {
- $tableAlias = $item['model'] ?? null;
- if (!$tableAlias) {
+ $tableAlias = isset($item['model']) ? (string)$item['model'] : '';
+ if ($tableAlias === '') {
$result['error'] = true;
$result['message'] = __('model is required');
$this->set('result', $result);
@@ -106,8 +110,8 @@ public function save(): void
return;
}
- $foreignKey = $item['foreign_key'] ?? null;
- if (!$foreignKey) {
+ $foreignKey = isset($item['foreign_key']) ? (string)$item['foreign_key'] : '';
+ if ($foreignKey === '') {
$result['error'] = true;
$result['message'] = __('foreign key is required');
$this->set('result', $result);
@@ -136,7 +140,7 @@ public function save(): void
$files[] = $file;
}
- if ($this->Files->saveMany($files)) {
+ if ($this->Files->saveMany($files) !== false) {
$result['error'] = false;
$result['message'] = __('The association has been be saved correctly');
} else {
@@ -199,22 +203,24 @@ public function sign(): ?Response
throw new PageOutOfBoundsException(__('contenType {0} is not valid', h($contentType)));
}
- $presignedRequest = $this->createPresignedRequest($filename, $contentType);
+ $presignedRequest = $this->storageAdapter->createPresignedRequest($filename, $contentType);
+
+ $encoded = json_encode([
+ 'error' => false,
+ 'code' => 200,
+ 'method' => $presignedRequest->getMethod(),
+ 'url' => (string)$presignedRequest->getUri(),
+ 'fields' => [],
+ // Also set the content-type header on the request, to make sure that it is the same as the one we used to generate the signature.
+ // Else, the browser picks a content-type as it sees fit.
+ 'headers' => [
+ 'content-type' => $contentType,
+ ],
+ ]);
return $this->getResponse()
->withHeader('content-type', 'application/json')
- ->withStringBody(json_encode([
- 'error' => false,
- 'code' => 200,
- 'method' => $presignedRequest->getMethod(),
- 'url' => (string)$presignedRequest->getUri(),
- 'fields' => [],
- // Also set the content-type header on the request, to make sure that it is the same as the one we used to generate the signature.
- // Else, the browser picks a content-type as it sees fit.
- 'headers' => [
- 'content-type' => $contentType,
- ],
- ]) ?: '');
+ ->withStringBody($encoded !== false ? $encoded : '');
}
/**
@@ -223,7 +229,6 @@ public function sign(): ?Response
public function add(): void
{
$this->getRequest()->allowMethod('get');
-
$file = $this->Files->newEmptyEntity();
$this->set(compact('file'));
diff --git a/src/Model/Table/FilesTable.php b/src/Model/Table/FilesTable.php
index c8475b8..d4aaf27 100644
--- a/src/Model/Table/FilesTable.php
+++ b/src/Model/Table/FilesTable.php
@@ -24,7 +24,8 @@
use Cake\ORM\Table;
use Cake\Validation\Validator;
use CakeDC\Uppy\Model\Entity\File;
-use CakeDC\Uppy\Util\S3Trait;
+use CakeDC\Uppy\Storage\AdapterFactory;
+use CakeDC\Uppy\Storage\StorageAdapterInterface;
use function Cake\I18n\__;
/**
@@ -33,7 +34,7 @@
* @method \CakeDC\Uppy\Model\Entity\File newEmptyEntity()
* @method \CakeDC\Uppy\Model\Entity\File newEntity(array $data, array $options = [])
* @method \CakeDC\Uppy\Model\Entity\File[] newEntities(array $data, array $options = [])
- * @method \CakeDC\Uppy\Model\Entity\File findOrCreate($search, ?callable $callback = null, $options = [])
+ * @method \CakeDC\Uppy\Model\Entity\File findOrCreate(array|callable $search, ?callable $callback = null, array $options = [])
* @method \CakeDC\Uppy\Model\Entity\File patchEntity(\Cake\Datasource\EntityInterface $entity, array $data, array $options = [])
* @method \CakeDC\Uppy\Model\Entity\File[] patchEntities(iterable $entities, array $data, array $options = [])
* @method \CakeDC\Uppy\Model\Entity\File|false save(\Cake\Datasource\EntityInterface $entity, $options = [])
@@ -42,7 +43,19 @@
*/
class FilesTable extends Table
{
- use S3Trait;
+ private ?StorageAdapterInterface $storageAdapter = null;
+
+ /**
+ * @return \CakeDC\Uppy\Storage\StorageAdapterInterface
+ */
+ public function getStorageAdapter(): StorageAdapterInterface
+ {
+ if ($this->storageAdapter === null) {
+ $this->storageAdapter = AdapterFactory::create();
+ }
+
+ return $this->storageAdapter;
+ }
/**
* Initialize method
@@ -149,16 +162,16 @@ public function buildRules(RulesChecker $rules): RulesChecker
$rules->add(
$rules->existsIn(
'user_id',
- Configure::readOrFail('Uppy.Props.usersAliasModel')
+ Configure::readOrFail('Uppy.Props.usersAliasModel'),
),
- ['errorField' => 'user_id']
+ ['errorField' => 'user_id'],
);
return $rules;
}
/**
- * If it's configured prop deleteFileS3 delete file in S3 repository
+ * If it's configured prop deleteFileStorage delete file from storage
*
* @param \Cake\Event\EventInterface $event The beforeSave event that was fired
* @param \CakeDC\Uppy\Model\Entity\File $entity The entity that is going to be saved
@@ -167,8 +180,12 @@ public function buildRules(RulesChecker $rules): RulesChecker
*/
public function afterDelete(EventInterface $event, File $entity, ArrayObject $options): void
{
- if (Configure::read('Uppy.Props.deleteFileS3', false)) {
- $this->deleteObject($entity->path, $entity->filename);
+ $shouldDelete = Configure::read(
+ 'Uppy.Props.deleteFileStorage',
+ Configure::read('Uppy.Props.deleteFileS3', false),
+ );
+ if ($shouldDelete) {
+ $this->getStorageAdapter()->deleteObject($entity->path ?? '');
}
}
@@ -187,22 +204,22 @@ public function findDatatable(
int|string $patient_id,
array $q = [],
?string $from_date = null,
- ?string $to_date = null
+ ?string $to_date = null,
): SelectQuery {
- if ($q['value'] ?? false) {
- $query->where(fn (QueryExpression $exp): QueryExpression => $exp
+ if (isset($q['value']) && (string)$q['value'] !== '') {
+ $query->where(fn(QueryExpression $exp): QueryExpression => $exp
->like($this->aliasField('filename'), "%{$q['value']}%"));
}
- $query->where(fn (QueryExpression $exp): QueryExpression => $exp
+ $query->where(fn(QueryExpression $exp): QueryExpression => $exp
->eq($this->aliasField('user_id'), $patient_id));
- if ($from_date && $to_date) {
- $query->where(fn (QueryExpression $exp): QueryExpression => $exp->between(
+ if ($from_date !== null && $to_date !== null) {
+ $query->where(fn(QueryExpression $exp): QueryExpression => $exp->between(
$this->aliasField('created'),
DateTime::parse($from_date)->startOfDay(),
DateTime::parse($to_date)->endOfDay(),
- 'datetime'
+ 'datetime',
));
}
@@ -215,12 +232,12 @@ public function findDatatable(
'path',
'created',
])
- ->formatResults(fn (CollectionInterface $results): CollectionInterface => $results
+ ->formatResults(fn(CollectionInterface $results): CollectionInterface => $results
->map(function (File $file): array {
$row = [];
$row['filename'] = $file->filename;
$row['extension'] = $file->extension;
- $row['signedUrl'] = $this->presignedUrl($file->path, $file->filename);
+ $row['signedUrl'] = $this->getStorageAdapter()->presignedUrl($file->path ?? '');
$row['filesize'] = Number::toReadableSize($file->filesize ?? 0);
$row['created'] = $file->created?->i18nFormat('yyyy-MM-dd');
$row['id'] = $file->id;
diff --git a/src/Storage/AdapterFactory.php b/src/Storage/AdapterFactory.php
new file mode 100644
index 0000000..0e4572b
--- /dev/null
+++ b/src/Storage/AdapterFactory.php
@@ -0,0 +1,34 @@
+ new S3Adapter(),
+ 'gcs' => new GcsAdapter(),
+ 'r2' => new R2Adapter(),
+ default => throw new InvalidArgumentException("Unknown Uppy driver: {$driver}"),
+ };
+ }
+}
diff --git a/src/Storage/GcsAdapter.php b/src/Storage/GcsAdapter.php
new file mode 100644
index 0000000..ea66dc7
--- /dev/null
+++ b/src/Storage/GcsAdapter.php
@@ -0,0 +1,109 @@
+client ??= new StorageClient([
+ 'projectId' => Configure::readOrFail('Uppy.GCS.projectId'),
+ 'keyFilePath' => Configure::readOrFail('Uppy.GCS.keyFilePath'),
+ ]);
+ }
+
+ /**
+ * @return string
+ */
+ private function bucket(): string
+ {
+ return (string)Configure::readOrFail('Uppy.GCS.bucket');
+ }
+
+ /**
+ * @param string $path
+ * @param string $contentType
+ * @return \Psr\Http\Message\RequestInterface
+ * @throws \Exception
+ */
+ public function createPresignedRequest(string $path, string $contentType): RequestInterface
+ {
+ $expires = new DateTime(Configure::readOrFail('Uppy.GCS.lifeTimePutObject'));
+ $signedUrl = $this->getClient()
+ ->bucket($this->bucket())
+ ->object($path)
+ ->signedUrl($expires, [
+ 'method' => 'PUT',
+ 'contentType' => $contentType,
+ 'version' => 'v4',
+ ]);
+
+ return (new Request($signedUrl, 'PUT'))->withHeader('Content-Type', $contentType);
+ }
+
+ /**
+ * @param string $path
+ * @param int|null $ttlSeconds
+ * @return string
+ */
+ public function presignedUrl(string $path, ?int $ttlSeconds = null): string
+ {
+ $ttlString = $ttlSeconds !== null
+ ? "+{$ttlSeconds} seconds"
+ : Configure::read('Uppy.GCS.lifeTimeGetObject', '+1 hour');
+ $expires = new DateTime($ttlString);
+
+ return $this->getClient()
+ ->bucket($this->bucket())
+ ->object($path)
+ ->signedUrl($expires, [
+ 'method' => 'GET',
+ 'version' => 'v4',
+ ]);
+ }
+
+ /**
+ * @param string $path
+ * @return bool
+ */
+ public function deleteObject(string $path): bool
+ {
+ try {
+ $this->getClient()->bucket($this->bucket())->object($path)->delete();
+
+ return true;
+ } catch (NotFoundException) {
+ return true; // already gone — idempotent
+ } catch (GoogleException) {
+ return false;
+ }
+ }
+}
diff --git a/src/Storage/R2Adapter.php b/src/Storage/R2Adapter.php
new file mode 100644
index 0000000..f80b75c
--- /dev/null
+++ b/src/Storage/R2Adapter.php
@@ -0,0 +1,117 @@
+client ??= new S3Client(Configure::readOrFail('Uppy.R2.config'));
+ }
+
+ /**
+ * @return string
+ */
+ private function bucket(): string
+ {
+ return (string)Configure::readOrFail('Uppy.R2.bucket');
+ }
+
+ /**
+ * @param string $path
+ * @param string $contentType
+ * @return \Psr\Http\Message\RequestInterface
+ */
+ public function createPresignedRequest(string $path, string $contentType): RequestInterface
+ {
+ if (Configure::read('Uppy.R2.config.connection') === 'dummy') {
+ return (new Request())->withUri(new Uri('https://example.com'));
+ }
+
+ $client = $this->getClient();
+
+ $command = $client->getCommand('PutObject', [
+ 'Bucket' => $this->bucket(),
+ 'Key' => $path,
+ 'ContentType' => $contentType,
+ ]);
+
+ return $client->createPresignedRequest(
+ $command,
+ Configure::readOrFail('Uppy.R2.constants.lifeTimePutObject'),
+ );
+ }
+
+ /**
+ * @param string $path
+ * @param int|null $ttlSeconds
+ * @return string
+ */
+ public function presignedUrl(string $path, ?int $ttlSeconds = null): string
+ {
+ if (Configure::read('Uppy.R2.config.connection') === 'dummy') {
+ return 'https://example.com';
+ }
+
+ $client = $this->getClient();
+
+ $ttl = $ttlSeconds !== null
+ ? "+{$ttlSeconds} seconds"
+ : Configure::read('Uppy.R2.constants.lifeTimeGetObject', '+1 hour');
+
+ $cmd = $client->getCommand('GetObject', [
+ 'Bucket' => $this->bucket(),
+ 'Key' => $path,
+ ]);
+
+ $request = $client->createPresignedRequest($cmd, $ttl);
+
+ return (string)$request->getUri();
+ }
+
+ /**
+ * @param string $path
+ * @return bool
+ */
+ public function deleteObject(string $path): bool
+ {
+ if (Configure::read('Uppy.R2.config.connection') === 'dummy') {
+ return true;
+ }
+
+ $client = $this->getClient();
+ if (!$client->doesObjectExist($this->bucket(), $path)) {
+ return true;
+ }
+
+ $client->deleteObject(['Bucket' => $this->bucket(), 'Key' => $path]);
+
+ return !$client->doesObjectExist($this->bucket(), $path);
+ }
+}
diff --git a/src/Storage/S3Adapter.php b/src/Storage/S3Adapter.php
new file mode 100644
index 0000000..24b5550
--- /dev/null
+++ b/src/Storage/S3Adapter.php
@@ -0,0 +1,113 @@
+client ??= new S3Client(Configure::readOrFail('Uppy.S3.config'));
+ }
+
+ /**
+ * @return string
+ */
+ private function bucket(): string
+ {
+ return (string)Configure::readOrFail('Uppy.S3.bucket');
+ }
+
+ /**
+ * @param string $path
+ * @param string $contentType
+ * @return \Psr\Http\Message\RequestInterface
+ */
+ public function createPresignedRequest(string $path, string $contentType): RequestInterface
+ {
+ if (Configure::read('Uppy.S3.config.connection') === 'dummy') {
+ return (new Request())->withUri(new Uri('https://example.com'));
+ }
+
+ $command = $this->getClient()->getCommand('PutObject', [
+ 'Bucket' => $this->bucket(),
+ 'Key' => $path,
+ 'ContentType' => $contentType,
+ 'Body' => '',
+ ]);
+
+ return $this->getClient()->createPresignedRequest(
+ $command,
+ Configure::readOrFail('Uppy.S3.constants.lifeTimePutObject'),
+ );
+ }
+
+ /**
+ * @param string $path
+ * @param int|null $ttlSeconds
+ * @return string
+ */
+ public function presignedUrl(string $path, ?int $ttlSeconds = null): string
+ {
+ if (Configure::read('Uppy.S3.config.connection') === 'dummy') {
+ return 'https://example.com';
+ }
+
+ $ttl = $ttlSeconds !== null
+ ? "+{$ttlSeconds} seconds"
+ : Configure::read('Uppy.S3.constants.lifeTimeGetObject', '+1 hour');
+
+ $cmd = $this->getClient()->getCommand('GetObject', [
+ 'Bucket' => $this->bucket(),
+ 'Key' => $path,
+ ]);
+ $request = $this->getClient()->createPresignedRequest($cmd, $ttl);
+
+ return (string)$request->getUri();
+ }
+
+ /**
+ * @param string $path
+ * @return bool
+ */
+ public function deleteObject(string $path): bool
+ {
+ if (Configure::read('Uppy.S3.config.connection') === 'dummy') {
+ return true;
+ }
+
+ $client = $this->getClient();
+ if (!$client->doesObjectExist($this->bucket(), $path)) {
+ return true;
+ }
+
+ $client->deleteObject(['Bucket' => $this->bucket(), 'Key' => $path]);
+
+ return !$client->doesObjectExist($this->bucket(), $path);
+ }
+}
diff --git a/src/Storage/StorageAdapterInterface.php b/src/Storage/StorageAdapterInterface.php
new file mode 100644
index 0000000..831f523
--- /dev/null
+++ b/src/Storage/StorageAdapterInterface.php
@@ -0,0 +1,44 @@
+getS3Client();
$exist = $s3Client->doesObjectExist(Configure::readOrFail('Uppy.S3.bucket'), $path);
if ($exist) {
$s3Client->deleteObject([
@@ -54,8 +77,6 @@ protected function deleteObject(?string $path, ?string $name): bool
}
/**
- * Generate a signed GET uRI to acces files in S3, note CORS must be configured for the domain
- *
* @see /config/cors.xml
* @param string|null $path string used as path in S3
* @param string|null $name string filename
@@ -67,19 +88,17 @@ protected function presignedUrl(?string $path, ?string $name): string
return 'https://example.com';
}
- $s3Client = new S3Client(Configure::readOrFail('Uppy.S3.config'));
+ $s3Client = $this->getS3Client();
$cmd = $s3Client->getCommand('GetObject', [
'Bucket' => Configure::readOrFail('Uppy.S3.bucket'),
'Key' => $path,
]);
- $request = $s3Client->createPresignedRequest($cmd, Configure::read('Uppy.S3.contants.lifeTimeGetObject'));
+ $request = $s3Client->createPresignedRequest($cmd, $this->getS3Lifetime('lifeTimeGetObject'));
return (string)$request->getUri();
}
/**
- * Generate a presigned PUT request to send files to S3, note CORS must be configured for the domain
- *
* @see /config/cors.xml
* @param string $path string used as path in S3
* @param string $contentType string contenttype
@@ -90,22 +109,20 @@ protected function createPresignedRequest(string $path, string $contentType): Re
if (Configure::read('Uppy.S3.config.connection') === 'dummy') {
return (new Request())
->withUri(new Uri('https://example.com'));
- } else {
- $s3Client = new S3Client(Configure::readOrFail('Uppy.S3.config'));
- $command = $s3Client->getCommand('putObject', [
- 'Bucket' => Configure::readOrFail('Uppy.S3.bucket'),
- 'Key' => $path,
- 'ContentType' => $contentType,
- 'Body' => '',
- ]);
-
- return $s3Client->createPresignedRequest($command, Configure::readOrFail('Uppy.S3.contants.lifeTimePutObject'));
}
+
+ $s3Client = $this->getS3Client();
+ $command = $s3Client->getCommand('putObject', [
+ 'Bucket' => Configure::readOrFail('Uppy.S3.bucket'),
+ 'Key' => $path,
+ 'ContentType' => $contentType,
+ 'Body' => '',
+ ]);
+
+ return $s3Client->createPresignedRequest($command, $this->getS3Lifetime('lifeTimePutObject'));
}
/**
- * Upload source dir to target path using transfer options
- *
* @see /config/cors.xml
* @param string $source string used as filesystem source dir
* @param string $target string used as path in S3
@@ -113,7 +130,7 @@ protected function createPresignedRequest(string $path, string $contentType): Re
*/
public function uploadDir(string $source, string $target): void
{
- $s3Client = new S3Client(Configure::readOrFail('Uppy.S3.config'));
+ $s3Client = $this->getS3Client();
$dest = 's3://' . Configure::readOrFail('Uppy.S3.bucket') . DS . $target;
$manager = new Transfer($s3Client, $source, $dest);
$manager->transfer();
@@ -121,7 +138,6 @@ public function uploadDir(string $source, string $target): void
$promise = $manager->promise();
$promise->then(function (): void {
- //Do nothing
});
$promise->otherwise(function ($reason): void {
@@ -130,8 +146,6 @@ public function uploadDir(string $source, string $target): void
}
/**
- * Upload file using S3 putObject method
- *
* @param string $sourceFilePath string used as filesystem source file
* @param string $destinationS3Path string used as path in S3
* @return void
@@ -140,7 +154,7 @@ public function uploadDir(string $source, string $target): void
*/
public function uploadFile(string $sourceFilePath, string $destinationS3Path): void
{
- $s3Client = new S3Client(Configure::readOrFail('Uppy.S3.config'));
+ $s3Client = $this->getS3Client();
$s3Options = [
'Bucket' => Configure::readOrFail('Uppy.S3.bucket'),
'Key' => $destinationS3Path,
@@ -160,15 +174,13 @@ public function uploadFile(string $sourceFilePath, string $destinationS3Path): v
}
/**
- * Delete file using S3 deleteObject method
- *
* @see /config/cors.xml
* @param string $fileS3Path string used as path in S3
* @return void
*/
public function deleteFile(string $fileS3Path): void
{
- $s3Client = new S3Client(Configure::readOrFail('Uppy.S3.config'));
+ $s3Client = $this->getS3Client();
$s3Options = [
'Bucket' => Configure::read('Uppy.S3.bucket'),
'Key' => $fileS3Path,
@@ -177,8 +189,6 @@ public function deleteFile(string $fileS3Path): void
}
/**
- * Delete dir on S3 usign matching rule
- *
* @param string $fileS3Path string used as path in S3
* @return void
* @throws \Exception
@@ -186,7 +196,7 @@ public function deleteFile(string $fileS3Path): void
*/
public function deleteDir(string $fileS3Path): void
{
- $s3Client = new S3Client(Configure::readOrFail('Uppy.S3.config'));
+ $s3Client = $this->getS3Client();
if ($this->folderExists($fileS3Path)) {
$s3Client->deleteMatchingObjects(Configure::readOrFail('Uppy.S3.bucket'), $fileS3Path);
} else {
@@ -195,36 +205,32 @@ public function deleteDir(string $fileS3Path): void
}
/**
- * Check if file exists in S3 bucket
- *
* @param string $filename filename
* @return bool
*/
public function fileExists(string $filename): bool
{
- $s3Client = new S3Client(Configure::readOrFail('Uppy.S3.config'));
+ $s3Client = $this->getS3Client();
return $s3Client->doesObjectExist(Configure::readOrFail('Uppy.S3.bucket'), $filename);
}
/**
- * Check if folder exists in S3 bucket
- *
* @param string $filename filename
* @return bool
* @throws \Exception
*/
public function folderExists(string $filename): bool
{
- $s3Client = new S3Client(Configure::readOrFail('Uppy.S3.config'));
+ $s3Client = $this->getS3Client();
$list = $s3Client->listObjectsV2([
'Bucket' => Configure::readOrFail('Uppy.S3.bucket'),
'Prefix' => $filename,
]);
if ($list['Contents'] > 0) {
return true;
- } else {
- throw new Exception('Folder doesn\'t exist. Please try again.');
}
+
+ throw new Exception('Folder doesn\'t exist. Please try again.');
}
}
diff --git a/tests/TestCase/Storage/AdapterFactoryTest.php b/tests/TestCase/Storage/AdapterFactoryTest.php
new file mode 100644
index 0000000..725c27c
--- /dev/null
+++ b/tests/TestCase/Storage/AdapterFactoryTest.php
@@ -0,0 +1,53 @@
+assertInstanceOf(S3Adapter::class, AdapterFactory::create());
+ }
+
+ public function testCreateReturnsGcsAdapterForGcsDriver(): void
+ {
+ Configure::write('Uppy.driver', 'gcs');
+ $this->assertInstanceOf(GcsAdapter::class, AdapterFactory::create());
+ }
+
+ public function testCreateReturnsR2AdapterForR2Driver(): void
+ {
+ Configure::write('Uppy.driver', 'r2');
+ $this->assertInstanceOf(R2Adapter::class, AdapterFactory::create());
+ }
+
+ public function testCreateDefaultsToS3WhenDriverNotSet(): void
+ {
+ Configure::delete('Uppy.driver');
+ $this->assertInstanceOf(S3Adapter::class, AdapterFactory::create());
+ }
+
+ public function testCreateThrowsForUnknownDriver(): void
+ {
+ Configure::write('Uppy.driver', 'azure');
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Unknown Uppy driver: azure');
+ AdapterFactory::create();
+ }
+}
diff --git a/tests/TestCase/Storage/GcsAdapterTest.php b/tests/TestCase/Storage/GcsAdapterTest.php
new file mode 100644
index 0000000..c7c1bfe
--- /dev/null
+++ b/tests/TestCase/Storage/GcsAdapterTest.php
@@ -0,0 +1,109 @@
+ 'test-project',
+ 'keyFilePath' => '/tmp/fake-key.json',
+ 'bucket' => 'test-bucket',
+ 'lifeTimePutObject' => '+5 minutes',
+ 'lifeTimeGetObject' => '+20 minutes',
+ ]);
+
+ $this->mockObject = $this->createMock(StorageObject::class);
+ $this->mockBucket = $this->createMock(Bucket::class);
+ $this->mockBucket->method('object')->willReturn($this->mockObject);
+ $this->mockClient = $this->createMock(StorageClient::class);
+ $this->mockClient->method('bucket')->willReturn($this->mockBucket);
+ }
+
+ protected function tearDown(): void
+ {
+ Configure::delete('Uppy.GCS');
+ parent::tearDown();
+ }
+
+ public function testCreatePresignedRequestReturnsPsrRequestWithPutMethod(): void
+ {
+ $this->mockObject
+ ->method('signedUrl')
+ ->with($this->isInstanceOf(DateTime::class), $this->arrayHasKey('method'))
+ ->willReturn('https://storage.googleapis.com/signed-put-url');
+
+ $adapter = new GcsAdapter($this->mockClient);
+ $result = $adapter->createPresignedRequest('users/uuid/file.jpg', 'image/jpeg');
+
+ $this->assertInstanceOf(RequestInterface::class, $result);
+ $this->assertSame('PUT', $result->getMethod());
+ $this->assertStringContainsString('signed-put-url', (string)$result->getUri());
+ $this->assertSame('image/jpeg', $result->getHeaderLine('Content-Type'));
+ }
+
+ public function testPresignedUrlReturnsSignedGetUrl(): void
+ {
+ $this->mockObject
+ ->method('signedUrl')
+ ->with($this->isInstanceOf(DateTime::class), $this->arrayHasKey('method'))
+ ->willReturn('https://storage.googleapis.com/signed-get-url');
+
+ $adapter = new GcsAdapter($this->mockClient);
+ $result = $adapter->presignedUrl('users/uuid/file.jpg', 3600);
+
+ $this->assertSame('https://storage.googleapis.com/signed-get-url', $result);
+ }
+
+ public function testDeleteObjectReturnsTrueOnSuccess(): void
+ {
+ $this->mockObject->expects($this->once())->method('delete');
+
+ $adapter = new GcsAdapter($this->mockClient);
+ $result = $adapter->deleteObject('users/uuid/file.jpg');
+
+ $this->assertTrue($result);
+ }
+
+ public function testDeleteObjectReturnsFalseOnException(): void
+ {
+ $this->mockObject
+ ->method('delete')
+ ->willThrowException(new GoogleException('GCS error'));
+
+ $adapter = new GcsAdapter($this->mockClient);
+ $result = $adapter->deleteObject('users/uuid/file.jpg');
+
+ $this->assertFalse($result);
+ }
+
+ public function testDeleteObjectReturnsTrueWhenObjectNotFound(): void
+ {
+ $this->mockObject
+ ->method('delete')
+ ->willThrowException(new NotFoundException('Not found'));
+
+ $adapter = new GcsAdapter($this->mockClient);
+ $result = $adapter->deleteObject('users/uuid/file.jpg');
+
+ $this->assertTrue($result);
+ }
+}
diff --git a/tests/TestCase/Storage/R2AdapterTest.php b/tests/TestCase/Storage/R2AdapterTest.php
new file mode 100644
index 0000000..1af5449
--- /dev/null
+++ b/tests/TestCase/Storage/R2AdapterTest.php
@@ -0,0 +1,61 @@
+ 'test-bucket',
+ 'publicDomain' => 'files.example.com',
+ 'constants' => [
+ 'lifeTimePutObject' => '+5 minutes',
+ 'lifeTimeGetObject' => '+20 minutes',
+ ],
+ 'config' => [
+ 'version' => 'latest',
+ 'region' => 'auto',
+ 'endpoint' => 'https://test.r2.cloudflarestorage.com',
+ 'credentials' => ['key' => 'fake', 'secret' => 'fake'],
+ 'connection' => 'dummy',
+ 'use_path_style_endpoint' => true,
+ ],
+ ]);
+ }
+
+ protected function tearDown(): void
+ {
+ Configure::delete('Uppy.R2');
+ parent::tearDown();
+ }
+
+ public function testCreatePresignedRequestReturnsPsrRequest(): void
+ {
+ $adapter = new R2Adapter();
+ $result = $adapter->createPresignedRequest('users/uuid/file.jpg', 'image/jpeg');
+ $this->assertInstanceOf(RequestInterface::class, $result);
+ $this->assertSame('https://example.com', (string)$result->getUri());
+ }
+
+ public function testPresignedUrlReturnsString(): void
+ {
+ $adapter = new R2Adapter();
+ $result = $adapter->presignedUrl('users/uuid/file.jpg');
+ $this->assertSame('https://example.com', $result);
+ }
+
+ public function testDeleteObjectReturnsTrueWhenConnectionDummy(): void
+ {
+ $adapter = new R2Adapter();
+ $result = $adapter->deleteObject('users/uuid/file.jpg');
+ $this->assertTrue($result);
+ }
+}
diff --git a/tests/TestCase/Storage/S3AdapterTest.php b/tests/TestCase/Storage/S3AdapterTest.php
new file mode 100644
index 0000000..b373888
--- /dev/null
+++ b/tests/TestCase/Storage/S3AdapterTest.php
@@ -0,0 +1,58 @@
+ [
+ 'lifeTimeGetObject' => '+20 minutes',
+ 'lifeTimePutObject' => '+5 minutes',
+ ],
+ 'config' => [
+ 'version' => 'latest',
+ 'region' => 'us-east-1',
+ 'connection' => 'dummy',
+ 'credentials' => ['key' => 'fake', 'secret' => 'fake'],
+ ],
+ 'bucket' => 'test-bucket',
+ ]);
+ }
+
+ protected function tearDown(): void
+ {
+ Configure::delete('Uppy.S3');
+ parent::tearDown();
+ }
+
+ public function testCreatePresignedRequestReturnsPsrRequest(): void
+ {
+ $adapter = new S3Adapter();
+ $result = $adapter->createPresignedRequest('users/uuid/file.jpg', 'image/jpeg');
+ $this->assertInstanceOf(RequestInterface::class, $result);
+ $this->assertSame('https://example.com', (string)$result->getUri());
+ }
+
+ public function testPresignedUrlReturnsString(): void
+ {
+ $adapter = new S3Adapter();
+ $result = $adapter->presignedUrl('users/uuid/file.jpg');
+ $this->assertSame('https://example.com', $result);
+ }
+
+ public function testDeleteObjectReturnsTrueWhenConnectionDummy(): void
+ {
+ $adapter = new S3Adapter();
+ $result = $adapter->deleteObject('users/uuid/file.jpg');
+ $this->assertTrue($result);
+ }
+}