diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..43d7ca7e --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,82 @@ +name: "Tests" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: [pull_request] +jobs: + build: + name: Build & Unit + runs-on: ubuntu-latest + + steps: + - name: Checkout Repo + uses: actions/checkout@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Build image + uses: docker/build-push-action@v3 + with: + context: . + push: false + load: true + tags: storage-dev + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Start storage + env: + DO_ACCESS_KEY: ${{ secrets.DO_ACCESS_KEY }} + DO_SECRET: ${{ secrets.DO_SECRET }} + LINODE_ACCESS_KEY: ${{ secrets.LINODE_ACCESS_KEY }} + LINODE_SECRET: ${{ secrets.LINODE_SECRET }} + S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }} + S3_SECRET: ${{ secrets.S3_SECRET }} + WASABI_ACCESS_KEY: ${{ secrets.WASABI_ACCESS_KEY }} + WASABI_SECRET: ${{ secrets.WASABI_SECRET }} + BACKBLAZE_ACCESS_KEY: ${{ secrets.BACKBLAZE_ACCESS_KEY }} + BACKBLAZE_SECRET: ${{ secrets.BACKBLAZE_SECRET }} + run: | + docker compose up -d + sleep 10 + + - name: Doctor + run: | + docker compose logs tests + docker ps + + - name: Unit Tests + run: docker compose exec -T tests vendor/bin/phpunit --configuration phpunit.xml --debug --testsuite unit + + e2e_test: + name: E2E Test + runs-on: ubuntu-latest + needs: build + strategy: + fail-fast: false + matrix: + devices: [BackblazeTest, DOSpacesTest, LinodeTest, LocalTest, S3Test, WasabiTest] + + steps: + - name: checkout + uses: actions/checkout@v3 + - name: Start storage + env: + DO_ACCESS_KEY: ${{ secrets.DO_ACCESS_KEY }} + DO_SECRET: ${{ secrets.DO_SECRET }} + LINODE_ACCESS_KEY: ${{ secrets.LINODE_ACCESS_KEY }} + LINODE_SECRET: ${{ secrets.LINODE_SECRET }} + S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }} + S3_SECRET: ${{ secrets.S3_SECRET }} + WASABI_ACCESS_KEY: ${{ secrets.WASABI_ACCESS_KEY }} + WASABI_SECRET: ${{ secrets.WASABI_SECRET }} + BACKBLAZE_ACCESS_KEY: ${{ secrets.BACKBLAZE_ACCESS_KEY }} + BACKBLAZE_SECRET: ${{ secrets.BACKBLAZE_SECRET }} + run: | + docker compose up -d + sleep 10 + - name: Run ${{matrix.devices}} + run: docker compose exec -T tests vendor/bin/phpunit tests/Storage/Device/${{matrix.devices}}.php diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index c7438e66..00000000 --- a/.travis.yml +++ /dev/null @@ -1,36 +0,0 @@ -dist: focal - -arch: - - amd64 - -os: linux - -language: shell - -notifications: - email: - - team@appwrite.io - -before_script: docker run --rm --interactive --tty --volume "$(pwd)":/app composer update --ignore-platform-reqs --optimize-autoloader --no-plugins --no-scripts --prefer-dist - -before_install: - - curl -fsSL https://get.docker.com | sh - - echo '{"experimental":"enabled"}' | sudo tee /etc/docker/daemon.json - - mkdir -p $HOME/.docker - - echo '{"experimental":"enabled"}' | sudo tee $HOME/.docker/config.json - - sudo service docker start - - > - if [ ! -z "${DOCKERHUB_PULL_USERNAME:-}" ]; then - echo "${DOCKERHUB_PULL_PASSWORD}" | docker login --username "${DOCKERHUB_PULL_USERNAME}" --password-stdin - fi - - docker --version - -install: - - docker-compose up -d - - sleep 10 - -script: - - docker ps - - docker-compose exec tests vendor/bin/phpunit --configuration phpunit.xml --debug --testsuite unit - - docker-compose exec tests vendor/bin/phpunit --configuration phpunit.xml --debug --testsuite e2e - - docker-compose exec tests vendor/bin/psalm --show-info=true \ No newline at end of file diff --git a/composer.json b/composer.json index 46d12e03..5d588d68 100644 --- a/composer.json +++ b/composer.json @@ -24,12 +24,13 @@ "ext-lz4": "*", "ext-snappy": "*", "php": ">=8.0", - "utopia-php/framework": "0.*.*" + "utopia-php/framework": "1.0.*", + "utopia-php/system": "0.9.*" }, "require-dev": { "phpunit/phpunit": "^9.3", "vimeo/psalm": "4.0.1", "laravel/pint": "1.2.*" }, - "minimum-stability": "dev" + "minimum-stability": "stable" } diff --git a/composer.lock b/composer.lock index 8aa41c70..46ee6e1c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,30 +4,82 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "31a62f29d84509b2ba01e75fd652d6ba", + "content-hash": "7b6b27dd419e73452c225a74f0de6a0c", "packages": [ + { + "name": "utopia-php/di", + "version": "0.1.0", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/di.git", + "reference": "22490c95f7ac3898ed1c33f1b1b5dd577305ee31" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/di/zipball/22490c95f7ac3898ed1c33f1b1b5dd577305ee31", + "reference": "22490c95f7ac3898ed1c33f1b1b5dd577305ee31", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "laravel/pint": "^1.2", + "phpbench/phpbench": "^1.2", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5.25", + "swoole/ide-helper": "4.8.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\": "src/", + "Tests\\E2E\\": "tests/e2e" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A simple and lite library for managing dependency injections", + "keywords": [ + "framework", + "http", + "php", + "upf" + ], + "support": { + "issues": "https://github.com/utopia-php/di/issues", + "source": "https://github.com/utopia-php/di/tree/0.1.0" + }, + "time": "2024-08-08T14:35:19+00:00" + }, { "name": "utopia-php/framework", - "version": "0.x-dev", + "version": "1.0.2", "source": { "type": "git", "url": "https://github.com/utopia-php/http.git", - "reference": "e3ff6b933082d57b48e7c4267bb605c0bf2250fd" + "reference": "fc63ec61c720190a5ea5bb484c615145850951e6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/http/zipball/e3ff6b933082d57b48e7c4267bb605c0bf2250fd", - "reference": "e3ff6b933082d57b48e7c4267bb605c0bf2250fd", + "url": "https://api.github.com/repos/utopia-php/http/zipball/fc63ec61c720190a5ea5bb484c615145850951e6", + "reference": "fc63ec61c720190a5ea5bb484c615145850951e6", "shasum": "" }, "require": { - "php": ">=8.0" + "ext-swoole": "*", + "php": ">=8.0", + "utopia-php/servers": "0.1.*" }, "require-dev": { + "ext-xdebug": "*", "laravel/pint": "^1.2", "phpbench/phpbench": "^1.2", "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^9.5.25" + "phpunit/phpunit": "^9.5.25", + "swoole/ide-helper": "4.8.3" }, "type": "library", "autoload": { @@ -39,23 +91,133 @@ "license": [ "MIT" ], - "description": "A simple, light and advanced PHP framework", + "description": "A simple, light and advanced PHP HTTP framework", "keywords": [ "framework", + "http", "php", "upf" ], "support": { "issues": "https://github.com/utopia-php/http/issues", - "source": "https://github.com/utopia-php/http/tree/0.33.0" + "source": "https://github.com/utopia-php/http/tree/1.0.2" + }, + "time": "2024-09-10T09:04:19+00:00" + }, + { + "name": "utopia-php/servers", + "version": "0.1.1", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/servers.git", + "reference": "fd5c8d32778f265256c1936372a071b944f5ba8a" }, - "time": "2024-01-08T13:30:27+00:00" + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/servers/zipball/fd5c8d32778f265256c1936372a071b944f5ba8a", + "reference": "fd5c8d32778f265256c1936372a071b944f5ba8a", + "shasum": "" + }, + "require": { + "php": ">=8.0", + "utopia-php/di": "0.1.*" + }, + "require-dev": { + "laravel/pint": "^0.2.3", + "phpstan/phpstan": "^1.8", + "phpunit/phpunit": "^9.5.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\Servers\\": "src/Servers" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Team Appwrite", + "email": "team@appwrite.io" + } + ], + "description": "A base library for building Utopia style servers.", + "keywords": [ + "framework", + "php", + "servers", + "upf", + "utopia" + ], + "support": { + "issues": "https://github.com/utopia-php/servers/issues", + "source": "https://github.com/utopia-php/servers/tree/0.1.1" + }, + "time": "2024-09-06T02:25:56+00:00" + }, + { + "name": "utopia-php/system", + "version": "0.9.0", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/system.git", + "reference": "8e4a7edaf2dfeb4c9524e9f766d27754f2c4b64d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/system/zipball/8e4a7edaf2dfeb4c9524e9f766d27754f2c4b64d", + "reference": "8e4a7edaf2dfeb4c9524e9f766d27754f2c4b64d", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "require-dev": { + "laravel/pint": "1.13.*", + "phpstan/phpstan": "1.10.*", + "phpunit/phpunit": "9.6.*" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\System\\": "src/System" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eldad Fux", + "email": "eldad@appwrite.io" + }, + { + "name": "Torsten Dittmann", + "email": "torsten@appwrite.io" + } + ], + "description": "A simple library for obtaining information about the host's system.", + "keywords": [ + "framework", + "php", + "system", + "upf", + "utopia" + ], + "support": { + "issues": "https://github.com/utopia-php/system/issues", + "source": "https://github.com/utopia-php/system/tree/0.9.0" + }, + "time": "2024-10-09T14:44:01+00:00" } ], "packages-dev": [ { "name": "amphp/amp", - "version": "2.x-dev", + "version": "v2.6.4", "source": { "type": "git", "url": "https://github.com/amphp/amp.git", @@ -132,7 +294,7 @@ "support": { "irc": "irc://irc.freenode.org/amphp", "issues": "https://github.com/amphp/amp/issues", - "source": "https://github.com/amphp/amp/tree/2.x" + "source": "https://github.com/amphp/amp/tree/v2.6.4" }, "funding": [ { @@ -144,7 +306,7 @@ }, { "name": "amphp/byte-stream", - "version": "1.x-dev", + "version": "v1.8.2", "source": { "type": "git", "url": "https://github.com/amphp/byte-stream.git", @@ -203,7 +365,7 @@ ], "support": { "issues": "https://github.com/amphp/byte-stream/issues", - "source": "https://github.com/amphp/byte-stream/tree/windows-test-failures" + "source": "https://github.com/amphp/byte-stream/tree/v1.8.2" }, "funding": [ { @@ -215,7 +377,7 @@ }, { "name": "composer/package-versions-deprecated", - "version": "dev-master", + "version": "1.11.99.5", "source": { "type": "git", "url": "https://github.com/composer/package-versions-deprecated.git", @@ -239,7 +401,6 @@ "ext-zip": "^1.13", "phpunit/phpunit": "^6.5 || ^7" }, - "default-branch": true, "type": "composer-plugin", "extra": { "class": "PackageVersions\\Installer", @@ -289,7 +450,7 @@ }, { "name": "composer/semver", - "version": "dev-main", + "version": "3.4.3", "source": { "type": "git", "url": "https://github.com/composer/semver.git", @@ -308,7 +469,6 @@ "phpstan/phpstan": "^1.11", "symfony/phpunit-bridge": "^3 || ^7" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { @@ -371,16 +531,16 @@ }, { "name": "composer/xdebug-handler", - "version": "1.4.x-dev", + "version": "1.4.6", "source": { "type": "git", "url": "https://github.com/composer/xdebug-handler.git", - "reference": "dee81bf97571cb7168019825ee9522e8dc2a5936" + "reference": "f27e06cd9675801df441b3656569b328e04aa37c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/dee81bf97571cb7168019825ee9522e8dc2a5936", - "reference": "dee81bf97571cb7168019825ee9522e8dc2a5936", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/f27e06cd9675801df441b3656569b328e04aa37c", + "reference": "f27e06cd9675801df441b3656569b328e04aa37c", "shasum": "" }, "require": { @@ -415,7 +575,7 @@ "support": { "irc": "irc://irc.freenode.org/composer", "issues": "https://github.com/composer/xdebug-handler/issues", - "source": "https://github.com/composer/xdebug-handler/tree/1.4" + "source": "https://github.com/composer/xdebug-handler/tree/1.4.6" }, "funding": [ { @@ -431,7 +591,7 @@ "type": "tidelift" } ], - "time": "2022-01-05T14:26:21+00:00" + "time": "2021-03-25T17:01:18+00:00" }, { "name": "dnoegel/php-xdg-base-dir", @@ -472,36 +632,37 @@ }, { "name": "doctrine/deprecations", - "version": "1.1.x-dev", + "version": "1.1.3", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "02c46054f3c34755a533d07ce27b87b20afd8cdc" + "reference": "dfbaa3c2d2e9a9df1118213f3b8b0c597bb99fab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/02c46054f3c34755a533d07ce27b87b20afd8cdc", - "reference": "02c46054f3c34755a533d07ce27b87b20afd8cdc", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/dfbaa3c2d2e9a9df1118213f3b8b0c597bb99fab", + "reference": "dfbaa3c2d2e9a9df1118213f3b8b0c597bb99fab", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, "require-dev": { - "doctrine/coding-standard": "^9 || ^12", - "phpstan/phpstan": "1.4.10 || 2.0.3", - "phpstan/phpstan-phpunit": "^1.0 || ^2", + "doctrine/coding-standard": "^9", + "phpstan/phpstan": "1.4.10 || 1.10.15", + "phpstan/phpstan-phpunit": "^1.0", "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "psr/log": "^1 || ^2 || ^3" + "psalm/plugin-phpunit": "0.18.4", + "psr/log": "^1 || ^2 || ^3", + "vimeo/psalm": "4.30.0 || 5.12.0" }, "suggest": { "psr/log": "Allows logging deprecations via PSR-3 logger implementation" }, - "default-branch": true, "type": "library", "autoload": { "psr-4": { - "Doctrine\\Deprecations\\": "src" + "Doctrine\\Deprecations\\": "lib/Doctrine/Deprecations" } }, "notification-url": "https://packagist.org/downloads/", @@ -512,37 +673,37 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.x" + "source": "https://github.com/doctrine/deprecations/tree/1.1.3" }, - "time": "2024-12-03T23:11:46+00:00" + "time": "2024-01-30T19:34:25+00:00" }, { "name": "doctrine/instantiator", - "version": "2.0.x-dev", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/doctrine/instantiator.git", - "reference": "a9e64e5ea80184e14a66c262e5bcf3c2cb4a94d7" + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/a9e64e5ea80184e14a66c262e5bcf3c2cb4a94d7", - "reference": "a9e64e5ea80184e14a66c262e5bcf3c2cb4a94d7", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", "shasum": "" }, "require": { "php": "^8.1" }, "require-dev": { - "doctrine/coding-standard": "^12", + "doctrine/coding-standard": "^11", "ext-pdo": "*", "ext-phar": "*", "phpbench/phpbench": "^1.2", "phpstan/phpstan": "^1.9.4", "phpstan/phpstan-phpunit": "^1.3", - "phpunit/phpunit": "^10.5" + "phpunit/phpunit": "^9.5.27", + "vimeo/psalm": "^5.4" }, - "default-branch": true, "type": "library", "autoload": { "psr-4": { @@ -568,7 +729,7 @@ ], "support": { "issues": "https://github.com/doctrine/instantiator/issues", - "source": "https://github.com/doctrine/instantiator/tree/2.0.x" + "source": "https://github.com/doctrine/instantiator/tree/2.0.0" }, "funding": [ { @@ -584,7 +745,7 @@ "type": "tidelift" } ], - "time": "2024-12-02T21:34:17+00:00" + "time": "2022-12-30T00:23:10+00:00" }, { "name": "felixfbecker/advanced-json-rpc", @@ -633,7 +794,7 @@ }, { "name": "felixfbecker/language-server-protocol", - "version": "dev-master", + "version": "v1.5.3", "source": { "type": "git", "url": "https://github.com/felixfbecker/php-language-server-protocol.git", @@ -653,7 +814,6 @@ "squizlabs/php_codesniffer": "^3.1", "vimeo/psalm": "^4.0" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { @@ -756,16 +916,16 @@ }, { "name": "myclabs/deep-copy", - "version": "1.x-dev", + "version": "1.12.1", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "4764e040f8743e92b86c36f488f32d0265dd1dae" + "reference": "123267b2c49fbf30d78a7b2d333f6be754b94845" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/4764e040f8743e92b86c36f488f32d0265dd1dae", - "reference": "4764e040f8743e92b86c36f488f32d0265dd1dae", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/123267b2c49fbf30d78a7b2d333f6be754b94845", + "reference": "123267b2c49fbf30d78a7b2d333f6be754b94845", "shasum": "" }, "require": { @@ -781,7 +941,6 @@ "phpspec/prophecy": "^1.10", "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" }, - "default-branch": true, "type": "library", "autoload": { "files": [ @@ -805,7 +964,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.x" + "source": "https://github.com/myclabs/DeepCopy/tree/1.12.1" }, "funding": [ { @@ -813,7 +972,7 @@ "type": "tidelift" } ], - "time": "2024-11-26T13:04:49+00:00" + "time": "2024-11-08T17:47:46+00:00" }, { "name": "netresearch/jsonmapper", @@ -868,7 +1027,7 @@ }, { "name": "nikic/php-parser", - "version": "4.x-dev", + "version": "v4.19.4", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", @@ -918,7 +1077,7 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/4.x" + "source": "https://github.com/nikic/PHP-Parser/tree/v4.19.4" }, "time": "2024-09-29T15:01:53+00:00" }, @@ -977,7 +1136,7 @@ }, { "name": "phar-io/manifest", - "version": "dev-master", + "version": "2.0.4", "source": { "type": "git", "url": "https://github.com/phar-io/manifest.git", @@ -997,7 +1156,6 @@ "phar-io/version": "^3.0.1", "php": "^7.2 || ^8.0" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { @@ -1096,25 +1254,25 @@ }, { "name": "phpdocumentor/reflection-common", - "version": "dev-master", + "version": "2.2.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionCommon.git", - "reference": "a0eeab580cbdf4414fef6978732510a36ed0a9d6" + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/a0eeab580cbdf4414fef6978732510a36ed0a9d6", - "reference": "a0eeab580cbdf4414fef6978732510a36ed0a9d6", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", "shasum": "" }, "require": { - "php": ">=7.1" + "php": "^7.2 || ^8.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.x-dev" + "dev-2.x": "2.x-dev" } }, "autoload": { @@ -1143,13 +1301,13 @@ ], "support": { "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", - "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/master" + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" }, - "time": "2021-06-25T13:47:51+00:00" + "time": "2020-06-27T09:03:43+00:00" }, { "name": "phpdocumentor/reflection-docblock", - "version": "5.x-dev", + "version": "5.6.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", @@ -1179,7 +1337,6 @@ "phpunit/phpunit": "^9.5", "psalm/phar": "^5.26" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { @@ -1214,7 +1371,7 @@ }, { "name": "phpdocumentor/type-resolver", - "version": "1.x-dev", + "version": "1.10.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", @@ -1242,7 +1399,6 @@ "rector/rector": "^0.13.9", "vimeo/psalm": "^4.25" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { @@ -1273,16 +1429,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "2.0.x-dev", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "04c8de0d350731bccfa216a5b5e9b3e1c26e4e24" + "reference": "c00d78fb6b29658347f9d37ebe104bffadf36299" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/04c8de0d350731bccfa216a5b5e9b3e1c26e4e24", - "reference": "04c8de0d350731bccfa216a5b5e9b3e1c26e4e24", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/c00d78fb6b29658347f9d37ebe104bffadf36299", + "reference": "c00d78fb6b29658347f9d37ebe104bffadf36299", "shasum": "" }, "require": { @@ -1299,7 +1455,6 @@ "phpunit/phpunit": "^9.6", "symfony/process": "^5.2" }, - "default-branch": true, "type": "library", "autoload": { "psr-4": { @@ -1315,22 +1470,22 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.0.x" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.0.0" }, - "time": "2024-11-07T10:15:44+00:00" + "time": "2024-10-13T11:29:49+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "9.2.x-dev", + "version": "9.2.32", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "0448d60087a382392a1b2a1abe434466e03dcc87" + "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/0448d60087a382392a1b2a1abe434466e03dcc87", - "reference": "0448d60087a382392a1b2a1abe434466e03dcc87", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/85402a822d1ecf1db1096959413d35e1c37cf1a5", + "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5", "shasum": "" }, "require": { @@ -1387,7 +1542,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.32" }, "funding": [ { @@ -1395,20 +1550,20 @@ "type": "github" } ], - "time": "2024-10-31T05:58:25+00:00" + "time": "2024-08-22T04:23:01+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "3.0.x-dev", + "version": "3.0.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "38b24367e1b340aa78b96d7cab042942d917bb84" + "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/38b24367e1b340aa78b96d7cab042942d917bb84", - "reference": "38b24367e1b340aa78b96d7cab042942d917bb84", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", + "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", "shasum": "" }, "require": { @@ -1447,7 +1602,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.6" }, "funding": [ { @@ -1455,7 +1610,7 @@ "type": "github" } ], - "time": "2022-02-11T16:23:04+00:00" + "time": "2021-12-02T12:48:52+00:00" }, { "name": "phpunit/php-invoker", @@ -1640,16 +1795,16 @@ }, { "name": "phpunit/phpunit", - "version": "9.6.x-dev", + "version": "9.6.21", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "5e58fee65c32a3eb5df82b1f5bc3a711cf7fa96f" + "reference": "de6abf3b6f8dd955fac3caad3af7a9504e8c2ffa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5e58fee65c32a3eb5df82b1f5bc3a711cf7fa96f", - "reference": "5e58fee65c32a3eb5df82b1f5bc3a711cf7fa96f", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/de6abf3b6f8dd955fac3caad3af7a9504e8c2ffa", + "reference": "de6abf3b6f8dd955fac3caad3af7a9504e8c2ffa", "shasum": "" }, "require": { @@ -1660,7 +1815,7 @@ "ext-mbstring": "*", "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.12.1", + "myclabs/deep-copy": "^1.12.0", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=7.3", @@ -1723,7 +1878,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.21" }, "funding": [ { @@ -1739,26 +1894,25 @@ "type": "tidelift" } ], - "time": "2024-11-25T11:16:31+00:00" + "time": "2024-09-19T10:50:18+00:00" }, { "name": "psr/container", - "version": "dev-master", + "version": "2.0.2", "source": { "type": "git", "url": "https://github.com/php-fig/container.git", - "reference": "707984727bd5b2b670e59559d3ed2500240cf875" + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/707984727bd5b2b670e59559d3ed2500240cf875", - "reference": "707984727bd5b2b670e59559d3ed2500240cf875", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", "shasum": "" }, "require": { "php": ">=7.4.0" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { @@ -1791,9 +1945,9 @@ ], "support": { "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container" + "source": "https://github.com/php-fig/container/tree/2.0.2" }, - "time": "2023-09-22T11:11:30+00:00" + "time": "2021-11-05T16:47:00+00:00" }, { "name": "psr/log", @@ -1847,7 +2001,7 @@ }, { "name": "sebastian/cli-parser", - "version": "1.0.x-dev", + "version": "1.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", @@ -2014,16 +2168,16 @@ }, { "name": "sebastian/comparator", - "version": "4.0.x-dev", + "version": "4.0.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "b247957a1c8dc81a671770f74b479c0a78a818f1" + "reference": "fa0f136dd2334583309d32b62544682ee972b51a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/b247957a1c8dc81a671770f74b479c0a78a818f1", - "reference": "b247957a1c8dc81a671770f74b479c0a78a818f1", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/fa0f136dd2334583309d32b62544682ee972b51a", + "reference": "fa0f136dd2334583309d32b62544682ee972b51a", "shasum": "" }, "require": { @@ -2076,7 +2230,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", - "source": "https://github.com/sebastianbergmann/comparator/tree/4.0" + "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.8" }, "funding": [ { @@ -2084,11 +2238,11 @@ "type": "github" } ], - "time": "2022-09-14T12:46:14+00:00" + "time": "2022-09-14T12:41:17+00:00" }, { "name": "sebastian/complexity", - "version": "2.0.x-dev", + "version": "2.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/complexity.git", @@ -2145,7 +2299,7 @@ }, { "name": "sebastian/diff", - "version": "4.0.x-dev", + "version": "4.0.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", @@ -2211,7 +2365,7 @@ }, { "name": "sebastian/environment", - "version": "5.1.x-dev", + "version": "5.1.5", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", @@ -2262,7 +2416,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", - "source": "https://github.com/sebastianbergmann/environment/tree/5.1" + "source": "https://github.com/sebastianbergmann/environment/tree/5.1.5" }, "funding": [ { @@ -2274,7 +2428,7 @@ }, { "name": "sebastian/exporter", - "version": "4.0.x-dev", + "version": "4.0.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", @@ -2351,7 +2505,7 @@ }, { "name": "sebastian/global-state", - "version": "5.0.x-dev", + "version": "5.0.7", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", @@ -2415,7 +2569,7 @@ }, { "name": "sebastian/lines-of-code", - "version": "1.0.x-dev", + "version": "1.0.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", @@ -2584,7 +2738,7 @@ }, { "name": "sebastian/recursion-context", - "version": "4.0.x-dev", + "version": "4.0.5", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", @@ -2647,16 +2801,16 @@ }, { "name": "sebastian/resource-operations", - "version": "dev-main", + "version": "3.0.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/resource-operations.git", - "reference": "ff553e7482dcee39fa4acc2b175d6ddeb0f7bc25" + "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/ff553e7482dcee39fa4acc2b175d6ddeb0f7bc25", - "reference": "ff553e7482dcee39fa4acc2b175d6ddeb0f7bc25", + "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/05d5692a7993ecccd56a03e40cd7e5b09b1d404e", + "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e", "shasum": "" }, "require": { @@ -2665,7 +2819,6 @@ "require-dev": { "phpunit/phpunit": "^9.0" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { @@ -2690,7 +2843,7 @@ "description": "Provides a list of PHP built-in functions that operate on resources", "homepage": "https://www.github.com/sebastianbergmann/resource-operations", "support": { - "source": "https://github.com/sebastianbergmann/resource-operations/tree/main" + "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.4" }, "funding": [ { @@ -2698,11 +2851,11 @@ "type": "github" } ], - "time": "2024-03-14T18:47:08+00:00" + "time": "2024-03-14T16:00:52+00:00" }, { "name": "sebastian/type", - "version": "3.2.x-dev", + "version": "3.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", @@ -2746,7 +2899,7 @@ "homepage": "https://github.com/sebastianbergmann/type", "support": { "issues": "https://github.com/sebastianbergmann/type/issues", - "source": "https://github.com/sebastianbergmann/type/tree/3.2" + "source": "https://github.com/sebastianbergmann/type/tree/3.2.1" }, "funding": [ { @@ -2758,7 +2911,7 @@ }, { "name": "sebastian/version", - "version": "3.0.x-dev", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/version.git", @@ -2811,7 +2964,7 @@ }, { "name": "symfony/console", - "version": "5.4.x-dev", + "version": "v5.4.47", "source": { "type": "git", "url": "https://github.com/symfony/console.git", @@ -2890,7 +3043,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/5.4" + "source": "https://github.com/symfony/console/tree/v5.4.47" }, "funding": [ { @@ -2910,26 +3063,25 @@ }, { "name": "symfony/deprecation-contracts", - "version": "dev-main", + "version": "v3.5.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", + "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", "shasum": "" }, "require": { "php": ">=8.1" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.5-dev" }, "thanks": { "name": "symfony/contracts", @@ -2958,7 +3110,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/main" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.1" }, "funding": [ { @@ -2974,11 +3126,11 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2024-09-25T14:20:29+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "1.x-dev", + "version": "v1.31.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", @@ -2999,7 +3151,6 @@ "suggest": { "ext-ctype": "For best performance" }, - "default-branch": true, "type": "library", "extra": { "thanks": { @@ -3058,7 +3209,7 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "1.x-dev", + "version": "v1.31.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", @@ -3076,7 +3227,6 @@ "suggest": { "ext-intl": "For best performance" }, - "default-branch": true, "type": "library", "extra": { "thanks": { @@ -3137,7 +3287,7 @@ }, { "name": "symfony/polyfill-intl-normalizer", - "version": "1.x-dev", + "version": "v1.31.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -3155,7 +3305,6 @@ "suggest": { "ext-intl": "For best performance" }, - "default-branch": true, "type": "library", "extra": { "thanks": { @@ -3219,16 +3368,16 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "1.x-dev", + "version": "v1.31.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "2369cb908b33d7b7518cce042615de430142497f" + "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/2369cb908b33d7b7518cce042615de430142497f", - "reference": "2369cb908b33d7b7518cce042615de430142497f", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", + "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", "shasum": "" }, "require": { @@ -3240,7 +3389,6 @@ "suggest": { "ext-mbstring": "For best performance" }, - "default-branch": true, "type": "library", "extra": { "thanks": { @@ -3280,7 +3428,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/1.x" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" }, "funding": [ { @@ -3296,11 +3444,11 @@ "type": "tidelift" } ], - "time": "2024-09-10T14:38:51+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/polyfill-php73", - "version": "1.x-dev", + "version": "v1.31.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php73.git", @@ -3315,7 +3463,6 @@ "require": { "php": ">=7.2" }, - "default-branch": true, "type": "library", "extra": { "thanks": { @@ -3377,7 +3524,7 @@ }, { "name": "symfony/polyfill-php80", - "version": "1.x-dev", + "version": "v1.31.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", @@ -3392,7 +3539,6 @@ "require": { "php": ">=7.2" }, - "default-branch": true, "type": "library", "extra": { "thanks": { @@ -3458,16 +3604,16 @@ }, { "name": "symfony/service-contracts", - "version": "dev-main", + "version": "v3.5.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "5ad38698559cf88b6296629e19b15ef3239c9d7a" + "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/5ad38698559cf88b6296629e19b15ef3239c9d7a", - "reference": "5ad38698559cf88b6296629e19b15ef3239c9d7a", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/e53260aabf78fb3d63f8d79d69ece59f80d5eda0", + "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0", "shasum": "" }, "require": { @@ -3478,11 +3624,10 @@ "conflict": { "ext-psr": "<1.1|>=2" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.5-dev" }, "thanks": { "name": "symfony/contracts", @@ -3522,7 +3667,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/main" + "source": "https://github.com/symfony/service-contracts/tree/v3.5.1" }, "funding": [ { @@ -3538,11 +3683,11 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2024-09-25T14:20:29+00:00" }, { "name": "symfony/string", - "version": "6.4.x-dev", + "version": "v6.4.15", "source": { "type": "git", "url": "https://github.com/symfony/string.git", @@ -3608,7 +3753,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/6.4" + "source": "https://github.com/symfony/string/tree/v6.4.15" }, "funding": [ { @@ -3783,32 +3928,30 @@ }, { "name": "webmozart/assert", - "version": "dev-master", + "version": "1.9.1", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "190f0e154d3e70d76560c93e03e3e0f7ac4095ca" + "reference": "bafc69caeb4d49c39fd0779086c03a3738cbb389" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/190f0e154d3e70d76560c93e03e3e0f7ac4095ca", - "reference": "190f0e154d3e70d76560c93e03e3e0f7ac4095ca", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/bafc69caeb4d49c39fd0779086c03a3738cbb389", + "reference": "bafc69caeb4d49c39fd0779086c03a3738cbb389", "shasum": "" }, "require": { - "ext-ctype": "*", - "php": "^7.2 || ^8.0" + "php": "^5.3.3 || ^7.0 || ^8.0", + "symfony/polyfill-ctype": "^1.8" }, - "suggest": { - "ext-simplexml": "" + "conflict": { + "phpstan/phpstan": "<0.12.20", + "vimeo/psalm": "<3.9.1" }, - "default-branch": true, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.10-dev" - } + "require-dev": { + "phpunit/phpunit": "^4.8.36 || ^7.5.13" }, + "type": "library", "autoload": { "psr-4": { "Webmozart\\Assert\\": "src/" @@ -3832,22 +3975,22 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/master" + "source": "https://github.com/webmozarts/assert/tree/1.9.1" }, - "time": "2024-11-19T17:25:59+00:00" + "time": "2020-07-08T17:02:28+00:00" }, { "name": "webmozart/glob", - "version": "4.8.x-dev", + "version": "4.7.0", "source": { "type": "git", "url": "https://github.com/webmozarts/glob.git", - "reference": "6712c9c4a8b0f6f629303bd1b26b9f88339d901e" + "reference": "8a2842112d6916e61e0e15e316465b611f3abc17" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/glob/zipball/6712c9c4a8b0f6f629303bd1b26b9f88339d901e", - "reference": "6712c9c4a8b0f6f629303bd1b26b9f88339d901e", + "url": "https://api.github.com/repos/webmozarts/glob/zipball/8a2842112d6916e61e0e15e316465b611f3abc17", + "reference": "8a2842112d6916e61e0e15e316465b611f3abc17", "shasum": "" }, "require": { @@ -3857,7 +4000,6 @@ "phpunit/phpunit": "^9.5", "symfony/filesystem": "^5.3" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { @@ -3882,33 +4024,32 @@ "description": "A PHP implementation of Ant's glob.", "support": { "issues": "https://github.com/webmozarts/glob/issues", - "source": "https://github.com/webmozarts/glob/tree/4.8.x" + "source": "https://github.com/webmozarts/glob/tree/4.7.0" }, - "time": "2024-08-06T15:56:59+00:00" + "time": "2024-03-07T20:33:40+00:00" }, { "name": "webmozart/path-util", - "version": "dev-master", + "version": "2.3.0", "source": { "type": "git", "url": "https://github.com/webmozart/path-util.git", - "reference": "6099b5238073f87f246863fd58c2e447acfc0d24" + "reference": "d939f7edc24c9a1bb9c0dee5cb05d8e859490725" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozart/path-util/zipball/6099b5238073f87f246863fd58c2e447acfc0d24", - "reference": "6099b5238073f87f246863fd58c2e447acfc0d24", + "url": "https://api.github.com/repos/webmozart/path-util/zipball/d939f7edc24c9a1bb9c0dee5cb05d8e859490725", + "reference": "d939f7edc24c9a1bb9c0dee5cb05d8e859490725", "shasum": "" }, "require": { - "php": "^5.3.3|^7.0", + "php": ">=5.3.3", "webmozart/assert": "~1.0" }, "require-dev": { "phpunit/phpunit": "^4.6", "sebastian/version": "^1.0.1" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { @@ -3933,14 +4074,14 @@ "description": "A robust cross-platform utility for normalizing, comparing and modifying file paths.", "support": { "issues": "https://github.com/webmozart/path-util/issues", - "source": "https://github.com/webmozart/path-util/tree/master" + "source": "https://github.com/webmozart/path-util/tree/2.3.0" }, "abandoned": "symfony/filesystem", - "time": "2021-11-08T08:17:20+00:00" + "time": "2015-12-17T08:42:14+00:00" } ], "aliases": [], - "minimum-stability": "dev", + "minimum-stability": "stable", "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, diff --git a/docker-compose.yml b/docker-compose.yml index e6af4bb2..4cd5e9f7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,10 +3,13 @@ version: '3.1' services: tests: container_name: tests + image: storage-dev build: context: . volumes: - - ./:/usr/src/code + - ./src:/usr/src/code/src + - ./tests:/usr/src/code/tests + - ./phpunit.xml:/usr/src/code/phpunit.xml environment: - S3_ACCESS_KEY - S3_SECRET diff --git a/src/Storage/Device.php b/src/Storage/Device.php index 3756e97e..ac742146 100644 --- a/src/Storage/Device.php +++ b/src/Storage/Device.php @@ -2,8 +2,41 @@ namespace Utopia\Storage; +use Exception; + abstract class Device { + /** + * Max chunk size while transferring file from one device to another + */ + protected int $transferChunkSize = 20000000; //20 MB + + /** + * Sets the maximum number of keys returned to the response. By default, the action returns up to 1,000 key names. + */ + protected const MAX_PAGE_SIZE = PHP_INT_MAX; + + /** + * Set Transfer Chunk Size + * + * @param int $chunkSize + * @return void + */ + public function setTransferChunkSize(int $chunkSize): void + { + $this->transferChunkSize = $chunkSize; + } + + /** + * Get Transfer Chunk Size + * + * @return int + */ + public function getTransferChunkSize(): int + { + return $this->transferChunkSize; + } + /** * Get Name. * @@ -64,10 +97,28 @@ abstract public function getPath(string $filename, string $prefix = null): strin * @param array $metadata * @return int * - * @throws \Exception + * @throws Exception */ abstract public function upload(string $source, string $path, int $chunk = 1, int $chunks = 1, array &$metadata = []): int; + /** + * Upload Data. + * + * Upload file contents to desired destination in the selected disk. + * return number of chunks uploaded or 0 if it fails. + * + * @param string $data + * @param string $path + * @param string $contentType + * @param int chunk + * @param int chunks + * @param array $metadata + * @return int + * + * @throws Exception + */ + abstract public function uploadData(string $data, string $path, string $contentType, int $chunk = 1, int $chunks = 1, array &$metadata = []): int; + /** * Abort Chunked Upload * @@ -87,6 +138,17 @@ abstract public function abort(string $path, string $extra = ''): bool; */ abstract public function read(string $path, int $offset = 0, int $length = null): string; + /** + * Transfer + * Transfer a file from current device to destination device. + * + * @param string $path + * @param string $destination + * @param Device $device + * @return bool + */ + abstract public function transfer(string $path, string $destination, Device $device): bool; + /** * Write file by given path. * @@ -105,7 +167,18 @@ abstract public function write(string $path, string $data, string $contentType): * @param string $target * @return bool */ - abstract public function move(string $source, string $target): bool; + public function move(string $source, string $target): bool + { + if ($source === $target) { + return false; + } + + if ($this->transfer($source, $target, $this)) { + return $this->delete($source); + } + + return false; + } /** * Delete file in given path return true on success and false on failure. @@ -205,6 +278,16 @@ abstract public function getPartitionFreeSpace(): float; */ abstract public function getPartitionTotalSpace(): float; + /** + * Get all files and directories inside a directory. + * + * @param string $dir Directory to scan + * @param int $max + * @param string $continuationToken + * @return array + */ + abstract public function getFiles(string $dir, int $max = self::MAX_PAGE_SIZE, string $continuationToken = ''): array; + /** * Get the absolute path by resolving strings like ../, .., //, /\ and so on. * diff --git a/src/Storage/Device/Local.php b/src/Storage/Device/Local.php index 8367158e..a587a7cf 100644 --- a/src/Storage/Device/Local.php +++ b/src/Storage/Device/Local.php @@ -82,11 +82,7 @@ public function getPath(string $filename, string $prefix = null): string */ public function upload(string $source, string $path, int $chunk = 1, int $chunks = 1, array &$metadata = []): int { - if (! \file_exists(\dirname($path))) { // Checks if directory path to file exists - if (! @\mkdir(\dirname($path), 0755, true)) { - throw new Exception('Can\'t create directory: '.\dirname($path)); - } - } + $this->createDirectory(\dirname($path)); //move_uploaded_file() verifies the file is not tampered with if ($chunks === 1) { @@ -98,11 +94,67 @@ public function upload(string $source, string $path, int $chunk = 1, int $chunks } $tmp = \dirname($path).DIRECTORY_SEPARATOR.'tmp_'.\basename($path).DIRECTORY_SEPARATOR.\basename($path).'_chunks.log'; - if (! \file_exists(\dirname($tmp))) { // Checks if directory path to file exists - if (! @\mkdir(\dirname($tmp), 0755, true)) { - throw new Exception('Can\'t create directory: '.\dirname($tmp)); + $this->createDirectory(\dirname($tmp)); + + $chunkFilePath = dirname($tmp).DIRECTORY_SEPARATOR.pathinfo($path, PATHINFO_FILENAME).'.part.'.$chunk; + + // skip writing chunk if the chunk was re-uploaded + if (! file_exists($chunkFilePath)) { + if (! file_put_contents($tmp, "$chunk\n", FILE_APPEND)) { + throw new Exception('Can\'t write chunk log '.$tmp); } } + + $chunkLogs = file($tmp); + if (! $chunkLogs) { + throw new Exception('Unable to read chunk log '.$tmp); + } + + $chunksReceived = count(file($tmp)); + + if (! \rename($source, $chunkFilePath)) { + throw new Exception('Failed to write chunk '.$chunk); + } + + if ($chunks === $chunksReceived) { + $this->joinChunks($path, $chunks); + + return $chunksReceived; + } + + return $chunksReceived; + } + + /** + * Upload Data. + * + * Upload file contents to desired destination in the selected disk. + * return number of chunks uploaded or 0 if it fails. + * + * @param string $source + * @param string $path + * @param string $contentType + * @param int chunk + * @param int chunks + * @param array $metadata + * @return int + * + * @throws \Exception + */ + public function uploadData(string $data, string $path, string $contentType, int $chunk = 1, int $chunks = 1, array &$metadata = []): int + { + $this->createDirectory(\dirname($path)); + + if ($chunks === 1) { + if (! \file_put_contents($path, $data)) { + throw new Exception('Can\'t write file '.$path); + } + + return $chunks; + } + $tmp = \dirname($path).DIRECTORY_SEPARATOR.'tmp_'.\basename($path).DIRECTORY_SEPARATOR.\basename($path).'_chunks.log'; + + $this->createDirectory(\dirname($tmp)); if (! file_put_contents($tmp, "$chunk\n", FILE_APPEND)) { throw new Exception('Can\'t write chunk log '.$tmp); } @@ -114,24 +166,12 @@ public function upload(string $source, string $path, int $chunk = 1, int $chunks $chunksReceived = count(file($tmp)); - if (! \rename($source, dirname($tmp).DIRECTORY_SEPARATOR.pathinfo($path, PATHINFO_FILENAME).'.part.'.$chunk)) { + if (! \file_put_contents(dirname($tmp).DIRECTORY_SEPARATOR.pathinfo($path, PATHINFO_FILENAME).'.part.'.$chunk, $data)) { throw new Exception('Failed to write chunk '.$chunk); } if ($chunks === $chunksReceived) { - for ($i = 1; $i <= $chunks; $i++) { - $part = dirname($tmp).DIRECTORY_SEPARATOR.pathinfo($path, PATHINFO_FILENAME).'.part.'.$i; - $data = file_get_contents($part); - if (! $data) { - throw new Exception('Failed to read chunk '.$part); - } - - if (! file_put_contents($path, $data, FILE_APPEND)) { - throw new Exception('Failed to append chunk '.$part); - } - \unlink($part); - } - \unlink($tmp); + $this->joinChunks($path, $chunks); return $chunksReceived; } @@ -139,6 +179,58 @@ public function upload(string $source, string $path, int $chunk = 1, int $chunks return $chunksReceived; } + private function joinChunks(string $path, int $chunks) + { + $tmp = \dirname($path).DIRECTORY_SEPARATOR.'tmp_'.\basename($path).DIRECTORY_SEPARATOR.\basename($path).'_chunks.log'; + for ($i = 1; $i <= $chunks; $i++) { + $part = dirname($tmp).DIRECTORY_SEPARATOR.pathinfo($path, PATHINFO_FILENAME).'.part.'.$i; + $data = file_get_contents($part); + if (! $data) { + throw new Exception('Failed to read chunk '.$part); + } + + if (! file_put_contents($path, $data, FILE_APPEND)) { + throw new Exception('Failed to append chunk '.$part); + } + \unlink($part); + } + \unlink($tmp); + \rmdir(dirname($tmp)); + } + + /** + * Transfer + * + * @param string $path + * @param string $destination + * @param Device $device + * @return string + */ + public function transfer(string $path, string $destination, Device $device): bool + { + if (! $this->exists($path)) { + throw new Exception('File Not Found'); + } + $size = $this->getFileSize($path); + $contentType = $this->getFileMimeType($path); + + if ($size <= $this->transferChunkSize) { + $source = $this->read($path); + + return $device->write($destination, $source, $contentType); + } + + $totalChunks = \ceil($size / $this->transferChunkSize); + $metadata = ['content_type' => $contentType]; + for ($counter = 0; $counter < $totalChunks; $counter++) { + $start = $counter * $this->transferChunkSize; + $data = $this->read($path, $start, $this->transferChunkSize); + $device->uploadData($data, $destination, $contentType, $counter + 1, $totalChunks, $metadata); + } + + return true; + } + /** * Abort Chunked Upload * @@ -212,9 +304,15 @@ public function write(string $path, string $data, string $contentType = ''): boo * @param string $source * @param string $target * @return bool + * + * @throws Exception */ public function move(string $source, string $target): bool { + if ($source === $target) { + return false; + } + if (! \file_exists(\dirname($target))) { // Checks if directory path to file exists if (! @\mkdir(\dirname($target), 0755, true)) { throw new Exception('Can\'t create directory '.\dirname($target)); @@ -247,7 +345,7 @@ public function delete(string $path, bool $recursive = false): bool } \rmdir($path); - } elseif (\is_file($path)) { + } elseif (\is_file($path) || \is_link($path)) { return \unlink($path); } @@ -264,19 +362,21 @@ public function deletePath(string $path): bool { $path = realpath($this->getRoot().DIRECTORY_SEPARATOR.$path); - if (\is_dir($path)) { - $files = $this->getFiles($path); + if (! file_exists($path) || ! is_dir($path)) { + return false; + } - foreach ($files as $file) { + $files = $this->getFiles($path); + + foreach ($files as $file) { + if (is_dir($file)) { + $this->deletePath(\substr_replace($file, '', 0, \strlen($this->getRoot().DIRECTORY_SEPARATOR))); + } else { $this->delete($file, true); } - - \rmdir($path); - - return true; } - return false; + return \rmdir($path); } /** @@ -412,25 +512,27 @@ public function getPartitionTotalSpace(): float } /** - * Get all files inside a directory. + * Get all files and directories inside a directory. * - * @param string $dir Directory to scan + * @param string $dir + * @param int $max + * @param string $continuationToken * @return string[] */ - private function getFiles(string $dir): array + public function getFiles(string $dir, int $max = self::MAX_PAGE_SIZE, string $continuationToken = ''): array { - if (! (\str_ends_with($dir, DIRECTORY_SEPARATOR))) { - $dir .= DIRECTORY_SEPARATOR; - } - + $dir = rtrim($dir, DIRECTORY_SEPARATOR); $files = []; - foreach (\scandir($dir) as $file) { - if ($file === '.' || $file === '..') { - continue; - } + foreach (\glob($dir.DIRECTORY_SEPARATOR.'*') as $file) { + $files[] = $file; + } - $files[] = $dir.$file; + /** + * Hidden files + */ + foreach (\glob($dir.DIRECTORY_SEPARATOR.'.[!.]*') as $file) { + $files[] = $file; } return $files; diff --git a/src/Storage/Device/S3.php b/src/Storage/Device/S3.php index a8447b98..c21bb9cb 100644 --- a/src/Storage/Device/S3.php +++ b/src/Storage/Device/S3.php @@ -26,6 +26,14 @@ class S3 extends Device const METHOD_TRACE = 'TRACE'; + const HTTP_VERSION_1_1 = CURL_HTTP_VERSION_1_1; + + const HTTP_VERSION_2_0 = CURL_HTTP_VERSION_2_0; + + const HTTP_VERSION_2 = CURL_HTTP_VERSION_2; + + const HTTP_VERSION_1_0 = CURL_HTTP_VERSION_1_0; + /** * AWS Regions constants */ @@ -92,6 +100,12 @@ class S3 extends Device const ACL_AUTHENTICATED_READ = 'authenticated-read'; + protected const MAX_PAGE_SIZE = 1000; + + protected static int $retryAttempts = 3; + + protected static int $retryDelay = 500; // milliseconds + /** * @var string */ @@ -126,7 +140,8 @@ class S3 extends Device * @var array */ protected array $headers = [ - 'host' => '', 'date' => '', + 'host' => '', + 'date' => '', 'content-md5' => '', 'content-type' => '', ]; @@ -136,6 +151,13 @@ class S3 extends Device */ protected array $amzHeaders; + /** + * Http version + * + * @var int|null + */ + protected ?int $curlHttpVersion = null; + /** * S3 Constructor * @@ -146,7 +168,7 @@ class S3 extends Device * @param string $region * @param string $acl */ - public function __construct(string $root, string $accessKey, string $secretKey, string $bucket, string $region = self::US_EAST_1, string $acl = self::ACL_PRIVATE) + public function __construct(string $root, string $accessKey, string $secretKey, string $bucket, string $region = self::US_EAST_1, string $acl = self::ACL_PRIVATE, $endpointUrl = '') { $this->accessKey = $accessKey; $this->secretKey = $secretKey; @@ -156,10 +178,14 @@ public function __construct(string $root, string $accessKey, string $secretKey, $this->acl = $acl; $this->amzHeaders = []; - $host = match ($region) { - self::CN_NORTH_1, self::CN_NORTH_4, self::CN_NORTHWEST_1 => $bucket.'.s3.'.$region.'.amazonaws.cn', - default => $bucket.'.s3.'.$region.'.amazonaws.com' - }; + if (! empty($endpointUrl)) { + $host = $bucket.'.'.$endpointUrl; + } else { + $host = match ($region) { + self::CN_NORTH_1, self::CN_NORTH_4, self::CN_NORTHWEST_1 => $bucket.'.s3.'.$region.'.amazonaws.cn', + default => $bucket.'.s3.'.$region.'.amazonaws.com' + }; + } $this->headers['host'] = $host; } @@ -206,6 +232,42 @@ public function getPath(string $filename, string $prefix = null): string return $this->getRoot().DIRECTORY_SEPARATOR.$filename; } + /** + * Set http version + * + * + * @param int|null $httpVersion + * @return self + */ + public function setHttpVersion(?int $httpVersion): self + { + $this->curlHttpVersion = $httpVersion; + + return $this; + } + + /** + * Set retry attempts + * + * @param int $attempts + * @return void + */ + public static function setRetryAttempts(int $attempts) + { + self::$retryAttempts = $attempts; + } + + /** + * Set retry delay in milliseconds + * + * @param int $delay + * @return void + */ + public static function setRetryDelay(int $delay): void + { + self::$retryDelay = $delay; + } + /** * Upload. * @@ -222,21 +284,46 @@ public function getPath(string $filename, string $prefix = null): string * @throws \Exception */ public function upload(string $source, string $path, int $chunk = 1, int $chunks = 1, array &$metadata = []): int + { + return $this->uploadData(\file_get_contents($source), $path, \mime_content_type($source), $chunk, $chunks, $metadata); + } + + /** + * Upload Data. + * + * Upload file contents to desired destination in the selected disk. + * return number of chunks uploaded or 0 if it fails. + * + * @param string $source + * @param string $path + * @param string $contentType + * @param int chunk + * @param int chunks + * @param array $metadata + * @return int + * + * @throws \Exception + */ + public function uploadData(string $data, string $path, string $contentType, int $chunk = 1, int $chunks = 1, array &$metadata = []): int { if ($chunk == 1 && $chunks == 1) { - return $this->write($path, \file_get_contents($source), \mime_content_type($source)); + return $this->write($path, $data, $contentType); } $uploadId = $metadata['uploadId'] ?? null; if (empty($uploadId)) { - $uploadId = $this->createMultipartUpload($path, $metadata['content_type']); + $uploadId = $this->createMultipartUpload($path, $contentType); $metadata['uploadId'] = $uploadId; } - $etag = $this->uploadPart($source, $path, $chunk, $uploadId); $metadata['parts'] ??= []; - $metadata['parts'][] = ['partNumber' => $chunk, 'etag' => $etag]; $metadata['chunks'] ??= 0; - $metadata['chunks']++; + + $etag = $this->uploadPart($data, $path, $contentType, $chunk, $uploadId); + // skip incrementing if the chunk was re-uploaded + if (! array_key_exists($chunk, $metadata['parts'])) { + $metadata['chunks']++; + } + $metadata['parts'][$chunk] = $etag; if ($metadata['chunks'] == $chunks) { $this->completeMultipartUpload($path, $uploadId, $metadata['parts']); } @@ -244,6 +331,42 @@ public function upload(string $source, string $path, int $chunk = 1, int $chunks return $metadata['chunks']; } + /** + * Transfer + * + * @param string $path + * @param string $destination + * @param Device $device + * @return string + */ + public function transfer(string $path, string $destination, Device $device): bool + { + $response = []; + try { + $response = $this->getInfo($path); + } catch (\Throwable $e) { + throw new Exception('File not found'); + } + $size = (int) ($response['content-length'] ?? 0); + $contentType = $response['content-type'] ?? ''; + + if ($size <= $this->transferChunkSize) { + $source = $this->read($path); + + return $device->write($destination, $source, $contentType); + } + + $totalChunks = \ceil($size / $this->transferChunkSize); + $metadata = ['content_type' => $contentType]; + for ($counter = 0; $counter < $totalChunks; $counter++) { + $start = $counter * $this->transferChunkSize; + $data = $this->read($path, $start, $this->transferChunkSize); + $device->uploadData($data, $destination, $contentType, $counter + 1, $totalChunks, $metadata); + } + + return true; + } + /** * Start Multipart Upload * @@ -279,12 +402,11 @@ protected function createMultipartUpload(string $path, string $contentType): str * * @throws \Exception */ - protected function uploadPart(string $source, string $path, int $chunk, string $uploadId): string + protected function uploadPart(string $data, string $path, string $contentType, int $chunk, string $uploadId): string { $uri = $path !== '' ? '/'.\str_replace(['%2F', '%3F'], ['/', '?'], \rawurlencode($path)) : '/'; - $data = \file_get_contents($source); - $this->headers['content-type'] = \mime_content_type($source); + $this->headers['content-type'] = $contentType; $this->headers['content-md5'] = \base64_encode(md5($data, true)); $this->amzHeaders['x-amz-content-sha256'] = \hash('sha256', $data); unset($this->amzHeaders['x-amz-acl']); // ACL header is not allowed in parts, only createMultipartUpload accepts this header. @@ -312,8 +434,8 @@ protected function completeMultipartUpload(string $path, string $uploadId, array $uri = $path !== '' ? '/'.\str_replace(['%2F', '%3F'], ['/', '?'], \rawurlencode($path)) : '/'; $body = ''; - foreach ($parts as $part) { - $body .= "{$part['etag']}{$part['partNumber']}"; + foreach ($parts as $key => $etag) { + $body .= "{$etag}{$key}"; } $body .= ''; @@ -392,29 +514,6 @@ public function write(string $path, string $data, string $contentType = ''): boo return true; } - /** - * Move file from given source to given path, Return true on success and false on failure. - * - * @see http://php.net/manual/en/function.filesize.php - * - * @param string $source - * @param string $target - * - * @throw \Exception - * - * @return bool - */ - public function move(string $source, string $target): bool - { - $type = $this->getFileMimeType($source); - - if ($this->write($target, $this->read($source), $type)) { - $this->delete($source); - } - - return true; - } - /** * Delete file in given path, Return true on success and false on failure. * @@ -441,26 +540,37 @@ public function delete(string $path, bool $recursive = false): bool /** * Get list of objects in the given path. * - * @param string $path + * @param string $prefix + * @param int $maxKeys + * @param string $continuationToken * @return array * - * @throws \Exception + * @throws Exception */ - private function listObjects($prefix = '', $maxKeys = 1000, $continuationToken = '') + protected function listObjects(string $prefix = '', int $maxKeys = self::MAX_PAGE_SIZE, string $continuationToken = ''): array { + if ($maxKeys > self::MAX_PAGE_SIZE) { + throw new Exception('Cannot list more than '.self::MAX_PAGE_SIZE.' objects'); + } + $uri = '/'; $prefix = ltrim($prefix, '/'); /** S3 specific requirement that prefix should never contain a leading slash */ $this->headers['content-type'] = 'text/plain'; $this->headers['content-md5'] = \base64_encode(md5('', true)); + unset($this->amzHeaders['x-amz-content-sha256']); + unset($this->amzHeaders['x-amz-acl']); + $parameters = [ 'list-type' => 2, 'prefix' => $prefix, 'max-keys' => $maxKeys, ]; + if (! empty($continuationToken)) { $parameters['continuation-token'] = $continuationToken; } + $response = $this->call(self::METHOD_GET, $uri, '', $parameters); return $response->body; @@ -620,10 +730,39 @@ public function getPartitionTotalSpace(): float return -1; } + /** + * Get all files and directories inside a directory. + * + * @param string $dir Directory to scan + * @param int $max + * @param string $continuationToken + * @return array + * + * @throws Exception + */ + public function getFiles(string $dir, int $max = self::MAX_PAGE_SIZE, string $continuationToken = ''): array + { + $data = $this->listObjects($dir, $max, $continuationToken); + + // Set to false if all the results were returned. Set to true if more keys are available to return. + $data['IsTruncated'] = $data['IsTruncated'] === 'true'; + + // KeyCount is the number of keys returned with this request. + $data['KeyCount'] = intval($data['KeyCount']); + + // Sets the maximum number of keys returned to the response. By default, the action returns up to 1,000 key names. + $data['MaxKeys'] = intval($data['MaxKeys']); + + return $data; + } + /** * Get file info * + * @param string $path * @return array + * + * @throws Exception */ private function getInfo(string $path): array { @@ -693,8 +832,10 @@ private function getSignatureV4(string $method, string $uri, array $parameters = // stringToSign $stringToSignStr = \implode("\n", [ - $algorithm, $this->amzHeaders['x-amz-date'], - \implode('/', $credentialScope), \hash('sha256', $amzPayloadStr), + $algorithm, + $this->amzHeaders['x-amz-date'], + \implode('/', $credentialScope), + \hash('sha256', $amzPayloadStr), ]); // Make Signature @@ -704,7 +845,7 @@ private function getSignatureV4(string $method, string $uri, array $parameters = $kService = \hash_hmac('sha256', $service, $kRegion, true); $kSigning = \hash_hmac('sha256', 'aws4_request', $kService, true); - $signature = \hash_hmac('sha256', \utf8_encode($stringToSignStr), $kSigning); + $signature = \hash_hmac('sha256', \mb_convert_encoding($stringToSignStr, 'utf-8'), $kSigning); return $algorithm.' '.\implode(',', [ 'Credential='.$this->accessKey.'/'.\implode('/', $credentialScope), @@ -725,7 +866,7 @@ private function getSignatureV4(string $method, string $uri, array $parameters = * * @throws \Exception */ - private function call(string $method, string $uri, string $data = '', array $parameters = [], bool $decode = true) + protected function call(string $method, string $uri, string $data = '', array $parameters = [], bool $decode = true) { $uri = $this->getAbsolutePath($uri); $url = 'https://'.$this->headers['host'].$uri.'?'.\http_build_query($parameters, '', '&', PHP_QUERY_RFC3986); @@ -765,6 +906,11 @@ private function call(string $method, string $uri, string $data = '', array $par \curl_setopt($curl, CURLOPT_HTTPHEADER, $httpHeaders); \curl_setopt($curl, CURLOPT_HEADER, false); \curl_setopt($curl, CURLOPT_RETURNTRANSFER, false); + + if ($this->curlHttpVersion != null) { + \curl_setopt($curl, CURLOPT_HTTP_VERSION, $this->curlHttpVersion); + } + \curl_setopt($curl, CURLOPT_WRITEFUNCTION, function ($curl, string $data) use ($response) { $response->body .= $data; @@ -799,11 +945,20 @@ private function call(string $method, string $uri, string $data = '', array $par $result = \curl_exec($curl); + $response->code = \curl_getinfo($curl, CURLINFO_HTTP_CODE); + + $attempt = 0; + while ($attempt < self::$retryAttempts && $response->code === 503) { + usleep(self::$retryDelay * 1000); + $attempt++; + $result = \curl_exec($curl); + $response->code = \curl_getinfo($curl, CURLINFO_HTTP_CODE); + } + if (! $result) { throw new Exception(\curl_error($curl)); } - $response->code = \curl_getinfo($curl, CURLINFO_HTTP_CODE); if ($response->code >= 400) { throw new Exception($response->body, $response->code); } diff --git a/src/Storage/Validator/File.php b/src/Storage/Validator/File.php index c8ffe95e..0bc4cd2d 100644 --- a/src/Storage/Validator/File.php +++ b/src/Storage/Validator/File.php @@ -2,7 +2,7 @@ namespace Utopia\Storage\Validator; -use Utopia\Validator; +use Utopia\Http\Validator; class File extends Validator { diff --git a/src/Storage/Validator/FileExt.php b/src/Storage/Validator/FileExt.php index 80506c2d..62f575a0 100644 --- a/src/Storage/Validator/FileExt.php +++ b/src/Storage/Validator/FileExt.php @@ -2,7 +2,7 @@ namespace Utopia\Storage\Validator; -use Utopia\Validator; +use Utopia\Http\Validator; class FileExt extends Validator { @@ -46,6 +46,7 @@ public function getDescription(): string public function isValid($filename): bool { $ext = pathinfo($filename, PATHINFO_EXTENSION); + $ext = strtolower($ext); if (! in_array($ext, $this->allowed)) { return false; diff --git a/src/Storage/Validator/FileName.php b/src/Storage/Validator/FileName.php index 7a1885e8..b263e6f4 100644 --- a/src/Storage/Validator/FileName.php +++ b/src/Storage/Validator/FileName.php @@ -2,7 +2,7 @@ namespace Utopia\Storage\Validator; -use Utopia\Validator; +use Utopia\Http\Validator; class FileName extends Validator { diff --git a/src/Storage/Validator/FileSize.php b/src/Storage/Validator/FileSize.php index e31aa4c6..e133d067 100644 --- a/src/Storage/Validator/FileSize.php +++ b/src/Storage/Validator/FileSize.php @@ -2,7 +2,7 @@ namespace Utopia\Storage\Validator; -use Utopia\Validator; +use Utopia\Http\Validator; class FileSize extends Validator { diff --git a/src/Storage/Validator/FileType.php b/src/Storage/Validator/FileType.php index 9385f360..a5e366bc 100644 --- a/src/Storage/Validator/FileType.php +++ b/src/Storage/Validator/FileType.php @@ -3,7 +3,7 @@ namespace Utopia\Storage\Validator; use Exception; -use Utopia\Validator; +use Utopia\Http\Validator; class FileType extends Validator { diff --git a/src/Storage/Validator/Upload.php b/src/Storage/Validator/Upload.php index d3a70918..139d46e4 100644 --- a/src/Storage/Validator/Upload.php +++ b/src/Storage/Validator/Upload.php @@ -2,7 +2,7 @@ namespace Utopia\Storage\Validator; -use Utopia\Validator; +use Utopia\Http\Validator; class Upload extends Validator { diff --git a/tests/Storage/Device/BackblazeTest.php b/tests/Storage/Device/BackblazeTest.php index 159bcb0f..d2d277b4 100644 --- a/tests/Storage/Device/BackblazeTest.php +++ b/tests/Storage/Device/BackblazeTest.php @@ -12,7 +12,7 @@ protected function init(): void $this->root = '/root'; $key = $_SERVER['BACKBLAZE_ACCESS_KEY'] ?? ''; $secret = $_SERVER['BACKBLAZE_SECRET'] ?? ''; - $bucket = 'backblaze-demo'; + $bucket = 'utopia-storage-test-new'; $this->object = new Backblaze($this->root, $key, $secret, $bucket, Backblaze::US_WEST_004, Backblaze::ACL_PRIVATE); } diff --git a/tests/Storage/Device/LinodeTest.php b/tests/Storage/Device/LinodeTest.php index 397e62bc..95118739 100644 --- a/tests/Storage/Device/LinodeTest.php +++ b/tests/Storage/Device/LinodeTest.php @@ -12,9 +12,9 @@ protected function init(): void $this->root = '/root'; $key = $_SERVER['LINODE_ACCESS_KEY'] ?? ''; $secret = $_SERVER['LINODE_SECRET'] ?? ''; - $bucket = 'everly-test'; + $bucket = 'storage-test'; - $this->object = new Linode($this->root, $key, $secret, $bucket, Linode::EU_CENTRAL_1, Linode::ACL_PRIVATE); + $this->object = new Linode($this->root, $key, $secret, $bucket, Linode::AP_SOUTH_1, Linode::ACL_PRIVATE); } protected function getAdapterName(): string diff --git a/tests/Storage/Device/LocalTest.php b/tests/Storage/Device/LocalTest.php index 1cc3ea11..c3d91b23 100644 --- a/tests/Storage/Device/LocalTest.php +++ b/tests/Storage/Device/LocalTest.php @@ -4,6 +4,7 @@ use PHPUnit\Framework\TestCase; use Utopia\Storage\Device\Local; +use Utopia\Storage\Device\S3; class LocalTest extends TestCase { @@ -172,6 +173,60 @@ public function testPartUpload() return $dest; } + public function testPartUploadRetry() + { + $source = __DIR__.'/../../resources/disk-a/large_file.mp4'; + $dest = $this->object->getPath('uploaded2.mp4'); + $totalSize = \filesize($source); + // AWS S3 requires each part to be at least 5MB except for last part + $chunkSize = 5 * 1024 * 1024; + + $chunks = ceil($totalSize / $chunkSize); + + $chunk = 1; + $start = 0; + $handle = @fopen($source, 'rb'); + $op = __DIR__.'/chunkx.part'; + while ($start < $totalSize) { + $contents = fread($handle, $chunkSize); + $op = __DIR__.'/chunkx.part'; + $cc = fopen($op, 'wb'); + fwrite($cc, $contents); + fclose($cc); + $this->object->upload($op, $dest, $chunk, $chunks); + $start += strlen($contents); + $chunk++; + if ($chunk == 2) { + break; + } + fseek($handle, $start); + } + @fclose($handle); + + $chunk = 1; + $start = 0; + // retry from first to make sure duplicate chunk re-upload works without issue + $handle = @fopen($source, 'rb'); + $op = __DIR__.'/chunkx.part'; + while ($start < $totalSize) { + $contents = fread($handle, $chunkSize); + $op = __DIR__.'/chunkx.part'; + $cc = fopen($op, 'wb'); + fwrite($cc, $contents); + fclose($cc); + $this->object->upload($op, $dest, $chunk, $chunks); + $start += strlen($contents); + $chunk++; + fseek($handle, $start); + } + @fclose($handle); + + $this->assertEquals(\filesize($source), $this->object->getFileSize($dest)); + $this->assertEquals(\md5_file($source), $this->object->getFileHash($dest)); + + return $dest; + } + public function testAbort() { $source = __DIR__.'/../../resources/disk-a/large_file.mp4'; @@ -234,7 +289,6 @@ public function testPartRead($path) $chunk = file_get_contents($source, false, null, 0, 500); $readChunk = $this->object->read($path, 0, 500); $this->assertEquals($chunk, $readChunk); - $this->object->delete($path); } public function testPartitionFreeSpace() @@ -247,6 +301,51 @@ public function testPartitionTotalSpace() $this->assertGreaterThan(0, $this->object->getPartitionTotalSpace()); } + /** + * @depends testPartUpload + */ + public function testTransferLarge($path) + { + // chunked file + $this->object->setTransferChunkSize(10000000); //10 mb + + $key = $_SERVER['S3_ACCESS_KEY'] ?? ''; + $secret = $_SERVER['S3_SECRET'] ?? ''; + $bucket = 'utopia-storage-test'; + + $device = new S3('/root', $key, $secret, $bucket, S3::EU_CENTRAL_1, S3::ACL_PRIVATE); + $destination = $device->getPath('largefile.mp4'); + + $this->assertTrue($this->object->transfer($path, $destination, $device)); + $this->assertTrue($device->exists($destination)); + $this->assertEquals($device->getFileMimeType($destination), 'video/mp4'); + + $device->delete($destination); + $this->object->delete($path); + } + + public function testTransferSmall() + { + $this->object->setTransferChunkSize(10000000); //10 mb + + $key = $_SERVER['S3_ACCESS_KEY'] ?? ''; + $secret = $_SERVER['S3_SECRET'] ?? ''; + $bucket = 'utopia-storage-test'; + + $device = new S3('/root', $key, $secret, $bucket, S3::EU_CENTRAL_1, S3::ACL_PRIVATE); + + $path = $this->object->getPath('text-for-read.txt'); + $this->object->write($path, 'Hello World'); + + $destination = $device->getPath('hello.txt'); + $this->assertTrue($this->object->transfer($path, $destination, $device)); + $this->assertTrue($device->exists($destination)); + $this->assertEquals($device->read($destination), 'Hello World'); + + $this->object->delete($path); + $device->delete($destination); + } + public function testDeletePath() { // Test Single Object @@ -278,4 +377,37 @@ public function testDeletePath() $this->assertEquals(false, $this->object->exists($path2)); $this->assertEquals(false, $this->object->exists($path3)); } + + public function testGetFiles() + { + $dir = DIRECTORY_SEPARATOR.'get-files-test'; + + $this->assertTrue($this->object->createDirectory($dir)); + + $files = $this->object->getFiles($dir); + $this->assertEquals(0, \count($files)); + + $this->object->write($dir.DIRECTORY_SEPARATOR.'new-file.txt', 'Hello World'); + $this->object->write($dir.DIRECTORY_SEPARATOR.'new-file-two.txt', 'Hello World'); + + $files = $this->object->getFiles($dir); + $this->assertEquals(2, \count($files)); + } + + public function testNestedDeletePath() + { + $dir = $this->object->getPath('nested-delete-path-test'); + $dir2 = $dir.DIRECTORY_SEPARATOR.'dir2'; + $dir3 = $dir2.DIRECTORY_SEPARATOR.'dir3'; + + $this->assertTrue($this->object->createDirectory($dir)); + $this->object->write($dir.DIRECTORY_SEPARATOR.'new-file.txt', 'Hello World'); + $this->assertTrue($this->object->createDirectory($dir2)); + $this->object->write($dir2.DIRECTORY_SEPARATOR.'new-file-2.txt', 'Hello World'); + $this->assertTrue($this->object->createDirectory($dir3)); + $this->object->write($dir3.DIRECTORY_SEPARATOR.'new-file-3.txt', 'Hello World'); + + $this->assertTrue($this->object->deletePath('nested-delete-path-test')); + $this->assertFalse($this->object->exists($dir)); + } } diff --git a/tests/Storage/Device/S3Test.php b/tests/Storage/Device/S3Test.php index d8e05c3b..2b0255c8 100644 --- a/tests/Storage/Device/S3Test.php +++ b/tests/Storage/Device/S3Test.php @@ -12,9 +12,9 @@ protected function init(): void $this->root = '/root'; $key = $_SERVER['S3_ACCESS_KEY'] ?? ''; $secret = $_SERVER['S3_SECRET'] ?? ''; - $bucket = 'appwrite-test-bucket'; + $bucket = 'utopia-storage-test'; - $this->object = new S3($this->root, $key, $secret, $bucket, S3::EU_WEST_1, S3::ACL_PRIVATE); + $this->object = new S3($this->root, $key, $secret, $bucket, S3::EU_CENTRAL_1, S3::ACL_PRIVATE); } /** diff --git a/tests/Storage/S3Base.php b/tests/Storage/S3Base.php index cf17d915..314cfe02 100644 --- a/tests/Storage/S3Base.php +++ b/tests/Storage/S3Base.php @@ -3,6 +3,7 @@ namespace Utopia\Tests\Storage; use PHPUnit\Framework\TestCase; +use Utopia\Storage\Device\Local; use Utopia\Storage\Device\S3; abstract class S3Base extends TestCase @@ -56,6 +57,42 @@ public function tearDown(): void $this->removeTestFiles(); } + public function testGetFiles() + { + $path = $this->object->getPath('testing/'); + $files = $this->object->getFiles($path); + $this->assertEquals(4, $files['KeyCount']); + $this->assertEquals(false, $files['IsTruncated']); + $this->assertIsArray($files['Contents']); + + $file = $files['Contents'][0]; + + $this->assertArrayHasKey('Key', $file); + $this->assertArrayHasKey('LastModified', $file); + $this->assertArrayHasKey('ETag', $file); + $this->assertArrayHasKey('StorageClass', $file); + $this->assertArrayHasKey('Size', $file); + } + + public function testGetFilesPagination() + { + $path = $this->object->getPath('testing/'); + + $files = $this->object->getFiles($path, 3); + $this->assertEquals(3, $files['KeyCount']); + $this->assertEquals(3, $files['MaxKeys']); + $this->assertEquals(true, $files['IsTruncated']); + $this->assertIsArray($files['Contents']); + $this->assertArrayHasKey('NextContinuationToken', $files); + + $files = $this->object->getFiles($path, 1000, $files['NextContinuationToken']); + $this->assertEquals(1, $files['KeyCount']); + $this->assertEquals(1000, $files['MaxKeys']); + $this->assertEquals(false, $files['IsTruncated']); + $this->assertIsArray($files['Contents']); + $this->assertArrayNotHasKey('NextContinuationToken', $files); + } + public function testName() { $this->assertEquals($this->getAdapterName(), $this->object->getName()); @@ -113,6 +150,12 @@ public function testMove() $this->object->delete($this->object->getPath('text-for-move-new.txt')); } + public function testMoveIdenticalName() + { + $file = '/kitten-1.jpg'; + $this->assertFalse($this->object->move($file, $file)); + } + public function testDelete() { $this->assertEquals(true, $this->object->write($this->object->getPath('text-for-delete.txt'), 'Hello World', 'text/plain')); @@ -231,8 +274,76 @@ public function testPartUpload() $cc = fopen($op, 'wb'); fwrite($cc, $contents); fclose($cc); - $etag = $this->object->upload($op, $dest, $chunk, $chunks, $metadata); - $parts[] = ['partNumber' => $chunk, 'etag' => $etag]; + $this->object->upload($op, $dest, $chunk, $chunks, $metadata); + $start += strlen($contents); + $chunk++; + fseek($handle, $start); + } + @fclose($handle); + unlink($op); + + $this->assertEquals(\filesize($source), $this->object->getFileSize($dest)); + + // S3 doesnt provide a method to get a proper MD5-hash of a file created using multipart upload + // https://stackoverflow.com/questions/8618218/amazon-s3-checksum + // More info on how AWS calculates ETag for multipart upload here + // https://savjee.be/2015/10/Verifying-Amazon-S3-multi-part-uploads-with-ETag-hash/ + // TODO + // $this->assertEquals(\md5_file($source), $this->object->getFileHash($dest)); + // $this->object->delete($dest); + return $dest; + } + + public function testPartUploadRetry() + { + $source = __DIR__.'/../resources/disk-a/large_file.mp4'; + $dest = $this->object->getPath('uploaded.mp4'); + $totalSize = \filesize($source); + // AWS S3 requires each part to be at least 5MB except for last part + $chunkSize = 5 * 1024 * 1024; + + $chunks = ceil($totalSize / $chunkSize); + + $chunk = 1; + $start = 0; + + $metadata = [ + 'parts' => [], + 'chunks' => 0, + 'uploadId' => null, + 'content_type' => \mime_content_type($source), + ]; + $handle = @fopen($source, 'rb'); + $op = __DIR__.'/chunk.part'; + while ($start < $totalSize) { + $contents = fread($handle, $chunkSize); + $op = __DIR__.'/chunk.part'; + $cc = fopen($op, 'wb'); + fwrite($cc, $contents); + fclose($cc); + $this->object->upload($op, $dest, $chunk, $chunks, $metadata); + $start += strlen($contents); + $chunk++; + if ($chunk == 2) { + break; + } + fseek($handle, $start); + } + @fclose($handle); + unlink($op); + + $chunk = 1; + $start = 0; + // retry from first to make sure duplicate chunk re-upload works without issue + $handle = @fopen($source, 'rb'); + $op = __DIR__.'/chunk.part'; + while ($start < $totalSize) { + $contents = fread($handle, $chunkSize); + $op = __DIR__.'/chunk.part'; + $cc = fopen($op, 'wb'); + fwrite($cc, $contents); + fclose($cc); + $this->object->upload($op, $dest, $chunk, $chunks, $metadata); $start += strlen($contents); $chunk++; fseek($handle, $start); @@ -261,6 +372,42 @@ public function testPartRead($path) $chunk = file_get_contents($source, false, null, 0, 500); $readChunk = $this->object->read($path, 0, 500); $this->assertEquals($chunk, $readChunk); + } + + /** + * @depends testPartUpload + */ + public function testTransferLarge($path) + { + // chunked file + $this->object->setTransferChunkSize(10000000); //10 mb + + $device = new Local(__DIR__.'/../resources/disk-a'); + $destination = $device->getPath('largefile.mp4'); + + $this->assertTrue($this->object->transfer($path, $destination, $device)); + $this->assertTrue($device->exists($destination)); + $this->assertEquals($device->getFileMimeType($destination), 'video/mp4'); + + $device->delete($destination); + $this->object->delete($path); + } + + public function testTransferSmall() + { + $this->object->setTransferChunkSize(10000000); //10 mb + + $device = new Local(__DIR__.'/../resources/disk-a'); + + $path = $this->object->getPath('text-for-read.txt'); + $this->object->write($path, 'Hello World', 'text/plain'); + + $destination = $device->getPath('hello.txt'); + $this->assertTrue($this->object->transfer($path, $destination, $device)); + $this->assertTrue($device->exists($destination)); + $this->assertEquals($device->read($destination), 'Hello World'); + $this->object->delete($path); + $device->delete($destination); } } diff --git a/tests/Storage/StorageTest.php b/tests/Storage/StorageTest.php index 3f04feb8..981a4fc2 100644 --- a/tests/Storage/StorageTest.php +++ b/tests/Storage/StorageTest.php @@ -39,4 +39,11 @@ public function testExists() $this->assertEquals(Storage::exists('disk-b'), true); $this->assertEquals(Storage::exists('disk-c'), false); } + + public function testMoveIdenticalName() + { + $file = '/kitten-1.jpg'; + $device = Storage::getDevice('disk-a'); + $this->assertFalse($device->move($file, $file)); + } } diff --git a/tests/Storage/Validator/FileExtTest.php b/tests/Storage/Validator/FileExtTest.php index b3b6a69a..e7f4f4be 100644 --- a/tests/Storage/Validator/FileExtTest.php +++ b/tests/Storage/Validator/FileExtTest.php @@ -35,5 +35,6 @@ public function testValues() $this->assertEquals($this->object->isValid('file.tar.g'), false); $this->assertEquals($this->object->isValid('file.tar.gz'), true); $this->assertEquals($this->object->isValid('file.gz'), true); + $this->assertEquals($this->object->isValid('file.GIF'), true); } }